mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
perf: optimize state management, add HTTPS validation, improve UI performance
- Add HTTPS-only validation for APK downloads and update checks - Use .select() for Riverpod providers to prevent unnecessary rebuilds - Add keys to all list builders for efficient updates - Implement request cancellation for outdated API requests - Debounce all network requests (URLs and searches) - Limit file existence cache to 500 entries - Add ref.onDispose for timer cleanup - Add error handling for share intent stream - Redesign About page with Material Expressive 3 style - Rename Search tab to Home - Remove Features section from README
This commit is contained in:
parent
a7c5afdd20
commit
08bca30fcd
12 changed files with 395 additions and 245 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -2,9 +2,22 @@
|
||||||
|
|
||||||
## [1.6.2] - 2026-01-02
|
## [1.6.2] - 2026-01-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **HTTPS-Only Downloads**: APK downloads and update checks now enforce HTTPS-only connections for security
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **Home Tab Rename**: Renamed "Search" tab to "Home" with home icon
|
- **Home Tab Rename**: Renamed "Search" tab to "Home" with home icon
|
||||||
- **Branding**: Changed idle screen title from "Search Music" to "SpotiFLAC"
|
- **Branding**: Changed idle screen title from "Search Music" to "SpotiFLAC"
|
||||||
|
- **About Page Redesign**: New Material Expressive 3 grouped layout with app header, contributors section with GitHub avatars, and organized links
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- **Optimized State Management**: Use `.select()` for Riverpod providers to prevent unnecessary widget rebuilds
|
||||||
|
- **List Keys**: Added keys to all list builders for efficient list updates and reordering
|
||||||
|
- **Request Cancellation**: Outdated API requests are ignored when new search/fetch is triggered
|
||||||
|
- **Debounced URL Fetches**: All network requests now debounced to prevent rapid duplicate calls
|
||||||
|
- **Bounded File Cache**: File existence cache now limited to 500 entries to prevent memory leak
|
||||||
|
- **Timer Cleanup**: Progress polling timer properly disposed when provider is destroyed
|
||||||
|
- **Stream Error Handling**: Share intent stream now has proper error handling
|
||||||
|
|
||||||
## [1.6.1] - 2026-01-02
|
## [1.6.1] - 2026-01-02
|
||||||
|
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -15,16 +15,6 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
||||||
|
|
||||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Download tracks, albums, and playlists from Spotify links
|
|
||||||
- True lossless FLAC quality from Tidal, Qobuz & Amazon Music
|
|
||||||
- Material Expressive 3 design with dynamic colors
|
|
||||||
- High performance rendering with Impeller (Vulkan)
|
|
||||||
- Concurrent downloads up to 3 simultaneous
|
|
||||||
- Real-time download progress tracking
|
|
||||||
- Download notifications
|
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|
|
||||||
3
devtools_options.yaml
Normal file
3
devtools_options.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
|
extensions:
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -34,6 +35,7 @@ type SpotifyMetadataClient struct {
|
||||||
clientSecret string
|
clientSecret string
|
||||||
cachedToken string
|
cachedToken string
|
||||||
tokenExpiresAt time.Time
|
tokenExpiresAt time.Time
|
||||||
|
tokenMu sync.Mutex // Protects token cache for concurrent access
|
||||||
rng *rand.Rand
|
rng *rand.Rand
|
||||||
rngMu sync.Mutex
|
rngMu sync.Mutex
|
||||||
userAgent string
|
userAgent string
|
||||||
|
|
@ -43,19 +45,23 @@ type SpotifyMetadataClient struct {
|
||||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||||
src := rand.NewSource(time.Now().UnixNano())
|
src := rand.NewSource(time.Now().UnixNano())
|
||||||
|
|
||||||
// Decode credentials from base64
|
// Prefer environment variables for credentials (more secure), fall back to built-in
|
||||||
clientID := ""
|
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
if clientID == "" {
|
||||||
clientID = string(decoded)
|
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
||||||
|
clientID = string(decoded)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clientSecret := ""
|
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
if clientSecret == "" {
|
||||||
clientSecret = string(decoded)
|
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
||||||
|
clientSecret = string(decoded)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c := &SpotifyMetadataClient{
|
c := &SpotifyMetadataClient{
|
||||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
rng: rand.New(src),
|
rng: rand.New(src),
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
DownloadQueueState build() {
|
DownloadQueueState build() {
|
||||||
|
// Cleanup timer when provider is disposed
|
||||||
|
ref.onDispose(() {
|
||||||
|
_progressTimer?.cancel();
|
||||||
|
_progressTimer = null;
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize output directory and load persisted queue asynchronously
|
// Initialize output directory and load persisted queue asynchronously
|
||||||
Future.microtask(() async {
|
Future.microtask(() async {
|
||||||
await _initOutputDir();
|
await _initOutputDir();
|
||||||
|
|
|
||||||
|
|
@ -81,12 +81,21 @@ class ArtistAlbum {
|
||||||
}
|
}
|
||||||
|
|
||||||
class TrackNotifier extends Notifier<TrackState> {
|
class TrackNotifier extends Notifier<TrackState> {
|
||||||
|
/// Request ID to track and cancel outdated requests
|
||||||
|
int _currentRequestId = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TrackState build() {
|
TrackState build() {
|
||||||
return const TrackState();
|
return const TrackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if request is still valid (not cancelled by newer request)
|
||||||
|
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||||
|
|
||||||
Future<void> fetchFromUrl(String url) async {
|
Future<void> fetchFromUrl(String url) async {
|
||||||
|
// Increment request ID to cancel any pending requests
|
||||||
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Save current state for back navigation (only if we have content or it's empty)
|
// Save current state for back navigation (only if we have content or it's empty)
|
||||||
final savedState = state.hasContent ? TrackState(
|
final savedState = state.hasContent ? TrackState(
|
||||||
tracks: state.tracks,
|
tracks: state.tracks,
|
||||||
|
|
@ -102,9 +111,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||||
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
final type = parsed['type'] as String;
|
final type = parsed['type'] as String;
|
||||||
|
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
||||||
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
if (type == 'track') {
|
if (type == 'track') {
|
||||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||||
|
|
@ -152,11 +164,15 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
|
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> search(String query) async {
|
Future<void> search(String query) async {
|
||||||
|
// Increment request ID to cancel any pending requests
|
||||||
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Save current state for back navigation
|
// Save current state for back navigation
|
||||||
final savedState = state.hasContent ? TrackState(
|
final savedState = state.hasContent ? TrackState(
|
||||||
tracks: state.tracks,
|
tracks: state.tracks,
|
||||||
|
|
@ -172,6 +188,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final results = await PlatformBridge.searchSpotify(query, limit: 20);
|
final results = await PlatformBridge.searchSpotify(query, limit: 20);
|
||||||
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
|
|
@ -180,6 +198,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||||
previousState: savedState,
|
previousState: savedState,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
|
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -242,6 +261,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||||
|
|
||||||
/// Fetch album from artist view - saves current artist state for back navigation
|
/// Fetch album from artist view - saves current artist state for back navigation
|
||||||
Future<void> fetchAlbumFromArtist(String albumId) async {
|
Future<void> fetchAlbumFromArtist(String albumId) async {
|
||||||
|
// Increment request ID to cancel any pending requests
|
||||||
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Save current artist state before fetching album
|
// Save current artist state before fetching album
|
||||||
final savedState = TrackState(
|
final savedState = TrackState(
|
||||||
artistName: state.artistName,
|
artistName: state.artistName,
|
||||||
|
|
@ -258,6 +280,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||||
try {
|
try {
|
||||||
final url = 'https://open.spotify.com/album/$albumId';
|
final url = 'https://open.spotify.com/album/$albumId';
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
||||||
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
|
|
@ -271,6 +294,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||||
previousState: savedState,
|
previousState: savedState,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: e.toString(),
|
error: e.toString(),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
|
@ -74,16 +75,14 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't live search for URLs - wait for submit
|
// Debounce all requests (URLs and searches)
|
||||||
if (text.startsWith('http') || text.startsWith('spotify:')) {
|
|
||||||
_debounce?.cancel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounce search queries
|
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
_debounce = Timer(const Duration(milliseconds: 400), () {
|
_debounce = Timer(const Duration(milliseconds: 400), () {
|
||||||
if (text.length >= 2) {
|
if (text.isEmpty) return;
|
||||||
|
|
||||||
|
if (text.startsWith('http') || text.startsWith('spotify:')) {
|
||||||
|
_fetchMetadata();
|
||||||
|
} else if (text.length >= 2) {
|
||||||
_performSearch(text);
|
_performSearch(text);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -196,12 +195,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _hasResults {
|
|
||||||
final trackState = ref.watch(trackProvider);
|
|
||||||
// Show results view when typing, loading, or has results
|
|
||||||
return _isTyping || trackState.tracks.isNotEmpty || trackState.artistAlbums != null || trackState.isLoading;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
|
|
@ -209,11 +202,21 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
// Listen for state changes to sync search bar
|
// Listen for state changes to sync search bar
|
||||||
ref.listen<TrackState>(trackProvider, _onTrackStateChanged);
|
ref.listen<TrackState>(trackProvider, _onTrackStateChanged);
|
||||||
|
|
||||||
final trackState = ref.watch(trackProvider);
|
// Use select() to only rebuild when specific fields change
|
||||||
|
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
|
||||||
|
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
||||||
|
final error = ref.watch(trackProvider.select((s) => s.error));
|
||||||
|
final albumName = ref.watch(trackProvider.select((s) => s.albumName));
|
||||||
|
final playlistName = ref.watch(trackProvider.select((s) => s.playlistName));
|
||||||
|
final artistName = ref.watch(trackProvider.select((s) => s.artistName));
|
||||||
|
final coverUrl = ref.watch(trackProvider.select((s) => s.coverUrl));
|
||||||
|
final artistAlbums = ref.watch(trackProvider.select((s) => s.artistAlbums));
|
||||||
|
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
|
||||||
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final hasResults = _hasResults;
|
final hasResults = _isTyping || tracks.isNotEmpty || artistAlbums != null || isLoading;
|
||||||
final screenHeight = MediaQuery.of(context).size.height;
|
final screenHeight = MediaQuery.of(context).size.height;
|
||||||
final historyItems = ref.watch(downloadHistoryProvider).items;
|
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
|
|
@ -296,7 +299,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: Column(
|
: Column(
|
||||||
children: [
|
children: [
|
||||||
if (!ref.watch(settingsProvider).hasSearchedBefore)
|
if (!hasSearchedBefore)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -318,7 +321,18 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
),
|
),
|
||||||
|
|
||||||
// Results content - always in tree
|
// Results content - always in tree
|
||||||
..._buildResultsContent(trackState, colorScheme, hasResults),
|
..._buildResultsContentOptimized(
|
||||||
|
tracks: tracks,
|
||||||
|
isLoading: isLoading,
|
||||||
|
error: error,
|
||||||
|
albumName: albumName,
|
||||||
|
playlistName: playlistName,
|
||||||
|
artistName: artistName,
|
||||||
|
coverUrl: coverUrl,
|
||||||
|
artistAlbums: artistAlbums,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
hasResults: hasResults,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -346,42 +360,45 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
itemCount: displayItems.length,
|
itemCount: displayItems.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = displayItems[index];
|
final item = displayItems[index];
|
||||||
return GestureDetector(
|
return KeyedSubtree(
|
||||||
onTap: () => _navigateToMetadataScreen(item),
|
key: ValueKey(item.id),
|
||||||
child: Container(
|
child: GestureDetector(
|
||||||
width: 60,
|
onTap: () => _navigateToMetadataScreen(item),
|
||||||
margin: const EdgeInsets.only(right: 12),
|
child: Container(
|
||||||
child: Column(
|
width: 60,
|
||||||
children: [
|
margin: const EdgeInsets.only(right: 12),
|
||||||
ClipRRect(
|
child: Column(
|
||||||
borderRadius: BorderRadius.circular(8),
|
children: [
|
||||||
child: item.coverUrl != null
|
ClipRRect(
|
||||||
? CachedNetworkImage(
|
borderRadius: BorderRadius.circular(8),
|
||||||
imageUrl: item.coverUrl!,
|
child: item.coverUrl != null
|
||||||
width: 56,
|
? CachedNetworkImage(
|
||||||
height: 56,
|
imageUrl: item.coverUrl!,
|
||||||
fit: BoxFit.cover,
|
width: 56,
|
||||||
memCacheWidth: 112,
|
height: 56,
|
||||||
memCacheHeight: 112,
|
fit: BoxFit.cover,
|
||||||
)
|
memCacheWidth: 112,
|
||||||
: Container(
|
memCacheHeight: 112,
|
||||||
width: 56,
|
)
|
||||||
height: 56,
|
: Container(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
width: 56,
|
||||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24),
|
height: 56,
|
||||||
),
|
color: colorScheme.surfaceContainerHighest,
|
||||||
),
|
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24),
|
||||||
const SizedBox(height: 4),
|
),
|
||||||
Text(
|
|
||||||
item.trackName,
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
const SizedBox(height: 4),
|
||||||
overflow: TextOverflow.ellipsis,
|
Text(
|
||||||
textAlign: TextAlign.center,
|
item.trackName,
|
||||||
),
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
],
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -401,8 +418,19 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Results content slivers (without app bar and search bar)
|
// Results content slivers (without app bar and search bar) - optimized version
|
||||||
List<Widget> _buildResultsContent(TrackState trackState, ColorScheme colorScheme, bool hasResults) {
|
List<Widget> _buildResultsContentOptimized({
|
||||||
|
required List<Track> tracks,
|
||||||
|
required bool isLoading,
|
||||||
|
required String? error,
|
||||||
|
required String? albumName,
|
||||||
|
required String? playlistName,
|
||||||
|
required String? artistName,
|
||||||
|
required String? coverUrl,
|
||||||
|
required List<ArtistAlbum>? artistAlbums,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
required bool hasResults,
|
||||||
|
}) {
|
||||||
// Return empty slivers when no results to keep tree structure stable
|
// Return empty slivers when no results to keep tree structure stable
|
||||||
if (!hasResults) {
|
if (!hasResults) {
|
||||||
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
|
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
|
||||||
|
|
@ -410,40 +438,57 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// Error message
|
// Error message
|
||||||
if (trackState.error != null)
|
if (error != null)
|
||||||
SliverToBoxAdapter(child: Padding(
|
SliverToBoxAdapter(child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(trackState.error!, style: TextStyle(color: colorScheme.error)),
|
child: Text(error, style: TextStyle(color: colorScheme.error)),
|
||||||
)),
|
)),
|
||||||
|
|
||||||
// Loading indicator
|
// Loading indicator
|
||||||
if (trackState.isLoading)
|
if (isLoading)
|
||||||
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
|
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
|
||||||
|
|
||||||
// Album/Playlist header
|
// Album/Playlist header
|
||||||
if (trackState.albumName != null || trackState.playlistName != null)
|
if (albumName != null || playlistName != null)
|
||||||
SliverToBoxAdapter(child: _buildHeader(trackState, colorScheme)),
|
SliverToBoxAdapter(child: _buildHeaderOptimized(
|
||||||
|
albumName: albumName,
|
||||||
|
playlistName: playlistName,
|
||||||
|
coverUrl: coverUrl,
|
||||||
|
trackCount: tracks.length,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
)),
|
||||||
|
|
||||||
// Artist header and discography
|
// Artist header and discography
|
||||||
if (trackState.artistName != null && trackState.artistAlbums != null)
|
if (artistName != null && artistAlbums != null)
|
||||||
SliverToBoxAdapter(child: _buildArtistHeader(trackState, colorScheme)),
|
SliverToBoxAdapter(child: _buildArtistHeaderOptimized(
|
||||||
|
artistName: artistName,
|
||||||
|
coverUrl: coverUrl,
|
||||||
|
albumCount: artistAlbums.length,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
)),
|
||||||
|
|
||||||
if (trackState.artistAlbums != null && trackState.artistAlbums!.isNotEmpty)
|
if (artistAlbums != null && artistAlbums.isNotEmpty)
|
||||||
SliverToBoxAdapter(child: _buildArtistDiscography(trackState, colorScheme)),
|
SliverToBoxAdapter(child: _buildArtistDiscographyOptimized(artistAlbums, colorScheme)),
|
||||||
|
|
||||||
// Download All button
|
// Download All button
|
||||||
if (trackState.tracks.length > 1 && trackState.albumName == null && trackState.playlistName == null && trackState.artistAlbums == null)
|
if (tracks.length > 1 && albumName == null && playlistName == null && artistAlbums == null)
|
||||||
SliverToBoxAdapter(child: Padding(
|
SliverToBoxAdapter(child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: FilledButton.icon(onPressed: _downloadAll, icon: const Icon(Icons.download),
|
child: FilledButton.icon(onPressed: _downloadAll, icon: const Icon(Icons.download),
|
||||||
label: Text('Download All (${trackState.tracks.length})'),
|
label: Text('Download All (${tracks.length})'),
|
||||||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))),
|
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))),
|
||||||
)),
|
)),
|
||||||
|
|
||||||
// Track list
|
// Track list with keys for efficient updates
|
||||||
SliverList(delegate: SliverChildBuilderDelegate(
|
SliverList(delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) => _buildTrackTile(index, colorScheme),
|
(context, index) {
|
||||||
childCount: trackState.tracks.length,
|
final track = tracks[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(track.id),
|
||||||
|
child: _buildTrackTileOptimized(track, index, colorScheme),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: tracks.length,
|
||||||
)),
|
)),
|
||||||
|
|
||||||
// Bottom padding
|
// Bottom padding
|
||||||
|
|
@ -451,6 +496,131 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildHeaderOptimized({
|
||||||
|
required String? albumName,
|
||||||
|
required String? playlistName,
|
||||||
|
required String? coverUrl,
|
||||||
|
required int trackCount,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
}) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.all(16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (coverUrl != null)
|
||||||
|
ClipRRect(borderRadius: BorderRadius.circular(12),
|
||||||
|
child: CachedNetworkImage(imageUrl: coverUrl, width: 80, height: 80, fit: BoxFit.cover,
|
||||||
|
placeholder: (_, _) => Container(width: 80, height: 80, color: colorScheme.surfaceContainerHighest))),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(albumName ?? playlistName ?? '',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('$trackCount tracks',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
])),
|
||||||
|
FilledButton.tonal(onPressed: _downloadAll,
|
||||||
|
style: FilledButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(16)),
|
||||||
|
child: const Icon(Icons.download)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildArtistHeaderOptimized({
|
||||||
|
required String? artistName,
|
||||||
|
required String? coverUrl,
|
||||||
|
required int albumCount,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
}) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.all(16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (coverUrl != null)
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(40),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: coverUrl,
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, _) => Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(Icons.person, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
artistName ?? '',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'$albumCount releases',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildArtistDiscographyOptimized(List<ArtistAlbum> albums, ColorScheme colorScheme) {
|
||||||
|
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
|
||||||
|
final singles = albums.where((a) => a.albumType == 'single').toList();
|
||||||
|
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (albumsOnly.isNotEmpty) _buildAlbumSection('Albums', albumsOnly, colorScheme),
|
||||||
|
if (singles.isNotEmpty) _buildAlbumSection('Singles & EPs', singles, colorScheme),
|
||||||
|
if (compilations.isNotEmpty) _buildAlbumSection('Compilations', compilations, colorScheme),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackTileOptimized(Track track, int index, ColorScheme colorScheme) {
|
||||||
|
return ListTile(
|
||||||
|
leading: track.coverUrl != null
|
||||||
|
? ClipRRect(borderRadius: BorderRadius.circular(8),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: track.coverUrl!,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 96,
|
||||||
|
memCacheHeight: 96,
|
||||||
|
))
|
||||||
|
: Container(width: 48, height: 48,
|
||||||
|
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)),
|
||||||
|
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||||
|
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||||
|
trailing: IconButton(icon: Icon(Icons.download, color: colorScheme.primary), onPressed: () => _downloadTrack(index)),
|
||||||
|
onTap: () => _downloadTrack(index),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildSearchBar(ColorScheme colorScheme) {
|
Widget _buildSearchBar(ColorScheme colorScheme) {
|
||||||
final hasText = _urlController.text.isNotEmpty;
|
final hasText = _urlController.text.isNotEmpty;
|
||||||
|
|
||||||
|
|
@ -498,101 +668,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHeader(TrackState state, ColorScheme colorScheme) {
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.all(16),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
if (state.coverUrl != null)
|
|
||||||
ClipRRect(borderRadius: BorderRadius.circular(12),
|
|
||||||
child: CachedNetworkImage(imageUrl: state.coverUrl!, width: 80, height: 80, fit: BoxFit.cover,
|
|
||||||
placeholder: (_, _) => Container(width: 80, height: 80, color: colorScheme.surfaceContainerHighest))),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Text(state.albumName ?? state.playlistName ?? '',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
maxLines: 2, overflow: TextOverflow.ellipsis),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text('${state.tracks.length} tracks',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
|
||||||
])),
|
|
||||||
FilledButton.tonal(onPressed: _downloadAll,
|
|
||||||
style: FilledButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(16)),
|
|
||||||
child: const Icon(Icons.download)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildArtistHeader(TrackState state, ColorScheme colorScheme) {
|
|
||||||
final albumCount = state.artistAlbums?.length ?? 0;
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.all(16),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
if (state.coverUrl != null)
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(40),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: state.coverUrl!,
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
placeholder: (_, _) => Container(
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
child: Icon(Icons.person, color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
state.artistName ?? '',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'$albumCount releases',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildArtistDiscography(TrackState state, ColorScheme colorScheme) {
|
|
||||||
final albums = state.artistAlbums ?? [];
|
|
||||||
|
|
||||||
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
|
|
||||||
final singles = albums.where((a) => a.albumType == 'single').toList();
|
|
||||||
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (albumsOnly.isNotEmpty) _buildAlbumSection('Albums', albumsOnly, colorScheme),
|
|
||||||
if (singles.isNotEmpty) _buildAlbumSection('Singles & EPs', singles, colorScheme),
|
|
||||||
if (compilations.isNotEmpty) _buildAlbumSection('Compilations', compilations, colorScheme),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAlbumSection(String title, List<ArtistAlbum> albums, ColorScheme colorScheme) {
|
Widget _buildAlbumSection(String title, List<ArtistAlbum> albums, ColorScheme colorScheme) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -613,7 +688,13 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
itemCount: albums.length,
|
itemCount: albums.length,
|
||||||
itemBuilder: (context, index) => _buildAlbumCard(albums[index], colorScheme),
|
itemBuilder: (context, index) {
|
||||||
|
final album = albums[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(album.id),
|
||||||
|
child: _buildAlbumCard(album, colorScheme),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -674,29 +755,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||||
ref.read(trackProvider.notifier).fetchAlbumFromArtist(albumId);
|
ref.read(trackProvider.notifier).fetchAlbumFromArtist(albumId);
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
|
|
||||||
final track = ref.watch(trackProvider).tracks[index];
|
|
||||||
return ListTile(
|
|
||||||
leading: track.coverUrl != null
|
|
||||||
? ClipRRect(borderRadius: BorderRadius.circular(8),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: track.coverUrl!,
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
memCacheWidth: 96,
|
|
||||||
memCacheHeight: 96,
|
|
||||||
))
|
|
||||||
: Container(width: 48, height: 48,
|
|
||||||
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)),
|
|
||||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
|
||||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
||||||
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
|
||||||
trailing: IconButton(icon: Icon(Icons.download, color: colorScheme.primary), onPressed: () => _downloadTrack(index)),
|
|
||||||
onTap: () => _downloadTrack(index),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _QualityPickerOption extends StatelessWidget {
|
class _QualityPickerOption extends StatelessWidget {
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,17 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||||
_handleSharedUrl(pendingUrl);
|
_handleSharedUrl(pendingUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for future shared URLs
|
// Listen for future shared URLs with error handling
|
||||||
_shareSubscription = ShareIntentService().sharedUrlStream.listen((url) {
|
_shareSubscription = ShareIntentService().sharedUrlStream.listen(
|
||||||
_log.d('Received shared URL from stream: $url');
|
(url) {
|
||||||
_handleSharedUrl(url);
|
_log.d('Received shared URL from stream: $url');
|
||||||
});
|
_handleSharedUrl(url);
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
_log.e('Share stream error: $error');
|
||||||
|
},
|
||||||
|
cancelOnError: false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleSharedUrl(String url) {
|
void _handleSharedUrl(String url) {
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,19 @@ class QueueTab extends ConsumerStatefulWidget {
|
||||||
|
|
||||||
class _QueueTabState extends ConsumerState<QueueTab> {
|
class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
final Map<String, bool> _fileExistsCache = {};
|
final Map<String, bool> _fileExistsCache = {};
|
||||||
|
static const int _maxCacheSize = 500; // Limit cache size to prevent memory leak
|
||||||
|
|
||||||
bool _checkFileExists(String? filePath) {
|
bool _checkFileExists(String? filePath) {
|
||||||
if (filePath == null) return false;
|
if (filePath == null) return false;
|
||||||
if (_fileExistsCache.containsKey(filePath)) {
|
if (_fileExistsCache.containsKey(filePath)) {
|
||||||
return _fileExistsCache[filePath]!;
|
return _fileExistsCache[filePath]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Limit cache size - remove oldest entry if full
|
||||||
|
if (_fileExistsCache.length >= _maxCacheSize) {
|
||||||
|
_fileExistsCache.remove(_fileExistsCache.keys.first);
|
||||||
|
}
|
||||||
|
|
||||||
Future.microtask(() async {
|
Future.microtask(() async {
|
||||||
final exists = await File(filePath).exists();
|
final exists = await File(filePath).exists();
|
||||||
if (mounted && _fileExistsCache[filePath] != exists) {
|
if (mounted && _fileExistsCache[filePath] != exists) {
|
||||||
|
|
@ -69,8 +76,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final queueState = ref.watch(downloadQueueProvider);
|
// Use select() to only rebuild when specific fields change
|
||||||
final historyState = ref.watch(downloadHistoryProvider);
|
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
|
||||||
|
final isProcessing = ref.watch(downloadQueueProvider.select((s) => s.isProcessing));
|
||||||
|
final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused));
|
||||||
|
final queuedCount = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||||
|
final completedCount = ref.watch(downloadQueueProvider.select((s) => s.completedCount));
|
||||||
|
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||||
final historyViewMode = ref.watch(settingsProvider.select((s) => s.historyViewMode));
|
final historyViewMode = ref.watch(settingsProvider.select((s) => s.historyViewMode));
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
|
@ -100,7 +112,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
),
|
),
|
||||||
|
|
||||||
// Pause/Resume controls - only show when multiple items or paused
|
// Pause/Resume controls - only show when multiple items or paused
|
||||||
if ((queueState.isProcessing || queueState.queuedCount > 0) && (queueState.items.length > 1 || queueState.isPaused))
|
if ((isProcessing || queuedCount > 0) && (queueItems.length > 1 || isPaused))
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
|
@ -113,14 +125,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: queueState.isPaused
|
color: isPaused
|
||||||
? colorScheme.errorContainer
|
? colorScheme.errorContainer
|
||||||
: colorScheme.primaryContainer,
|
: colorScheme.primaryContainer,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
queueState.isPaused ? Icons.pause : Icons.downloading,
|
isPaused ? Icons.pause : Icons.downloading,
|
||||||
color: queueState.isPaused
|
color: isPaused
|
||||||
? colorScheme.onErrorContainer
|
? colorScheme.onErrorContainer
|
||||||
: colorScheme.onPrimaryContainer,
|
: colorScheme.onPrimaryContainer,
|
||||||
),
|
),
|
||||||
|
|
@ -129,9 +141,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
// Status text - simplified
|
// Status text - simplified
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
queueState.isPaused
|
isPaused
|
||||||
? 'Paused'
|
? 'Paused'
|
||||||
: '${queueState.completedCount}/${queueState.items.length}',
|
: '$completedCount/${queueItems.length}',
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
|
|
@ -140,7 +152,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
// Pause/Resume button
|
// Pause/Resume button
|
||||||
FilledButton.tonal(
|
FilledButton.tonal(
|
||||||
onPressed: () => ref.read(downloadQueueProvider.notifier).togglePause(),
|
onPressed: () => ref.read(downloadQueueProvider.notifier).togglePause(),
|
||||||
child: Text(queueState.isPaused ? 'Resume' : 'Pause'),
|
child: Text(isPaused ? 'Resume' : 'Pause'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -150,34 +162,40 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
),
|
),
|
||||||
|
|
||||||
// Queue header
|
// Queue header
|
||||||
if (queueState.items.isNotEmpty)
|
if (queueItems.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
child: Text('Downloading (${queueState.items.length})',
|
child: Text('Downloading (${queueItems.length})',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Queue list
|
// Queue list with keys for efficient updates
|
||||||
if (queueState.items.isNotEmpty)
|
if (queueItems.isNotEmpty)
|
||||||
SliverList(delegate: SliverChildBuilderDelegate(
|
SliverList(delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) => _buildQueueItem(context, queueState.items[index], colorScheme),
|
(context, index) {
|
||||||
childCount: queueState.items.length,
|
final item = queueItems[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(item.id),
|
||||||
|
child: _buildQueueItem(context, item, colorScheme),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: queueItems.length,
|
||||||
)),
|
)),
|
||||||
|
|
||||||
// History section header - show count only
|
// History section header - show count only
|
||||||
if (historyState.items.isNotEmpty && queueState.items.isEmpty)
|
if (historyItems.isNotEmpty && queueItems.isEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
child: Text('${historyState.items.length} ${historyState.items.length == 1 ? 'track' : 'tracks'}',
|
child: Text('${historyItems.length} ${historyItems.length == 1 ? 'track' : 'tracks'}',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// History section header when queue has items (show "Downloaded" label)
|
// History section header when queue has items (show "Downloaded" label)
|
||||||
if (historyState.items.isNotEmpty && queueState.items.isNotEmpty)
|
if (historyItems.isNotEmpty && queueItems.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
|
@ -186,8 +204,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// History - Grid or List based on setting
|
// History - Grid or List based on setting (with keys)
|
||||||
if (historyState.items.isNotEmpty)
|
if (historyItems.isNotEmpty)
|
||||||
historyViewMode == 'grid'
|
historyViewMode == 'grid'
|
||||||
? SliverPadding(
|
? SliverPadding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
|
@ -199,18 +217,30 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
childAspectRatio: 0.75,
|
childAspectRatio: 0.75,
|
||||||
),
|
),
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) => _buildHistoryGridItem(context, historyState.items[index], colorScheme),
|
(context, index) {
|
||||||
childCount: historyState.items.length,
|
final item = historyItems[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(item.id),
|
||||||
|
child: _buildHistoryGridItem(context, item, colorScheme),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: historyItems.length,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: SliverList(delegate: SliverChildBuilderDelegate(
|
: SliverList(delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) => _buildHistoryItem(context, historyState.items[index], colorScheme),
|
(context, index) {
|
||||||
childCount: historyState.items.length,
|
final item = historyItems[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(item.id),
|
||||||
|
child: _buildHistoryItem(context, item, colorScheme),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: historyItems.length,
|
||||||
)),
|
)),
|
||||||
|
|
||||||
// Empty state when both queue and history are empty
|
// Empty state when both queue and history are empty
|
||||||
if (queueState.items.isEmpty && historyState.items.isEmpty)
|
if (queueItems.isEmpty && historyItems.isEmpty)
|
||||||
SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(context, colorScheme))
|
SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(context, colorScheme))
|
||||||
else
|
else
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,18 @@ class ApkDownloader {
|
||||||
required String version,
|
required String version,
|
||||||
ProgressCallback? onProgress,
|
ProgressCallback? onProgress,
|
||||||
}) async {
|
}) async {
|
||||||
|
// Validate URL for security
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null || uri.scheme != 'https') {
|
||||||
|
_log.e('Refusing to download from invalid or non-HTTPS URL');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final client = http.Client();
|
||||||
|
IOSink? sink;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final client = http.Client();
|
final request = http.Request('GET', uri);
|
||||||
final request = http.Request('GET', Uri.parse(url));
|
|
||||||
final response = await client.send(request);
|
final response = await client.send(request);
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
|
|
@ -41,7 +50,7 @@ class ApkDownloader {
|
||||||
await file.delete();
|
await file.delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
final sink = file.openWrite();
|
sink = file.openWrite();
|
||||||
int received = 0;
|
int received = 0;
|
||||||
|
|
||||||
await for (final chunk in response.stream) {
|
await for (final chunk in response.stream) {
|
||||||
|
|
@ -50,14 +59,15 @@ class ApkDownloader {
|
||||||
onProgress?.call(received, contentLength);
|
onProgress?.call(received, contentLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sink.close();
|
await sink.flush();
|
||||||
client.close();
|
|
||||||
|
|
||||||
_log.i('Downloaded to: $filePath');
|
_log.i('Downloaded to: $filePath');
|
||||||
return filePath;
|
return filePath;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.e('Error: $e');
|
_log.e('Error: $e');
|
||||||
return null;
|
return null;
|
||||||
|
} finally {
|
||||||
|
await sink?.close();
|
||||||
|
client.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,12 @@ class UpdateChecker {
|
||||||
final name = (asset['name'] as String? ?? '').toLowerCase();
|
final name = (asset['name'] as String? ?? '').toLowerCase();
|
||||||
if (name.endsWith('.apk')) {
|
if (name.endsWith('.apk')) {
|
||||||
final downloadUrl = asset['browser_download_url'] as String?;
|
final downloadUrl = asset['browser_download_url'] as String?;
|
||||||
|
// Only accept HTTPS URLs for security
|
||||||
|
final uri = downloadUrl != null ? Uri.tryParse(downloadUrl) : null;
|
||||||
|
if (uri == null || uri.scheme != 'https') {
|
||||||
|
_log.w('Skipping non-HTTPS APK URL: $downloadUrl');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (name.contains('arm64') || name.contains('v8a')) {
|
if (name.contains('arm64') || name.contains('v8a')) {
|
||||||
arm64Url = downloadUrl;
|
arm64Url = downloadUrl;
|
||||||
} else if (name.contains('arm32') || name.contains('v7a') || name.contains('armeabi')) {
|
} else if (name.contains('arm32') || name.contains('v7a') || name.contains('armeabi')) {
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ class DynamicColorWrapper extends ConsumerWidget {
|
||||||
// Use dynamic colors from wallpaper (Android 12+)
|
// Use dynamic colors from wallpaper (Android 12+)
|
||||||
lightScheme = lightDynamic;
|
lightScheme = lightDynamic;
|
||||||
darkScheme = darkDynamic;
|
darkScheme = darkDynamic;
|
||||||
debugPrint('Using dynamic color from wallpaper');
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback to seed color
|
// Fallback to seed color
|
||||||
final seedColor = themeSettings.seedColor;
|
final seedColor = themeSettings.seedColor;
|
||||||
|
|
@ -39,7 +38,6 @@ class DynamicColorWrapper extends ConsumerWidget {
|
||||||
seedColor: seedColor,
|
seedColor: seedColor,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
);
|
);
|
||||||
debugPrint('Using fallback seed color: ${seedColor.toARGB32().toRadixString(16)}');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build themes
|
// Build themes
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue