From 08bca30fcd8b0beb313d5c318f37d84b8ac9c0e1 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 3 Jan 2026 00:46:34 +0700 Subject: [PATCH] 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 --- CHANGELOG.md | 13 + README.md | 10 - devtools_options.yaml | 3 + go_backend/spotify.go | 22 +- lib/providers/download_queue_provider.dart | 6 + lib/providers/track_provider.dart | 24 ++ lib/screens/home_tab.dart | 436 ++++++++++++--------- lib/screens/main_shell.dart | 16 +- lib/screens/queue_tab.dart | 80 ++-- lib/services/apk_downloader.dart | 22 +- lib/services/update_checker.dart | 6 + lib/theme/dynamic_color_wrapper.dart | 2 - 12 files changed, 395 insertions(+), 245 deletions(-) create mode 100644 devtools_options.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index a62cbf51..2e02b843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,22 @@ ## [1.6.2] - 2026-01-02 +### Added +- **HTTPS-Only Downloads**: APK downloads and update checks now enforce HTTPS-only connections for security + ### Changed - **Home Tab Rename**: Renamed "Search" tab to "Home" with home icon - **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 diff --git a/README.md b/README.md index 93e4191c..44fc1f7a 100644 --- a/README.md +++ b/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) -## 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

diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /dev/null +++ b/devtools_options.yaml @@ -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: diff --git a/go_backend/spotify.go b/go_backend/spotify.go index ed5b49e6..ca553282 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -10,6 +10,7 @@ import ( "math/rand" "net/http" "net/url" + "os" "strings" "sync" "time" @@ -34,6 +35,7 @@ type SpotifyMetadataClient struct { clientSecret string cachedToken string tokenExpiresAt time.Time + tokenMu sync.Mutex // Protects token cache for concurrent access rng *rand.Rand rngMu sync.Mutex userAgent string @@ -43,19 +45,23 @@ type SpotifyMetadataClient struct { func NewSpotifyMetadataClient() *SpotifyMetadataClient { src := rand.NewSource(time.Now().UnixNano()) - // Decode credentials from base64 - clientID := "" - if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil { - clientID = string(decoded) + // Prefer environment variables for credentials (more secure), fall back to built-in + clientID := os.Getenv("SPOTIFY_CLIENT_ID") + if clientID == "" { + if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil { + clientID = string(decoded) + } } - clientSecret := "" - if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil { - clientSecret = string(decoded) + clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET") + if clientSecret == "" { + if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil { + clientSecret = string(decoded) + } } c := &SpotifyMetadataClient{ - httpClient: &http.Client{Timeout: 15 * time.Second}, + httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling clientID: clientID, clientSecret: clientSecret, rng: rand.New(src), diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index fb68b050..ac22244e 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -249,6 +249,12 @@ class DownloadQueueNotifier extends Notifier { @override DownloadQueueState build() { + // Cleanup timer when provider is disposed + ref.onDispose(() { + _progressTimer?.cancel(); + _progressTimer = null; + }); + // Initialize output directory and load persisted queue asynchronously Future.microtask(() async { await _initOutputDir(); diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 638f7190..d2b4b3a0 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -81,12 +81,21 @@ class ArtistAlbum { } class TrackNotifier extends Notifier { + /// Request ID to track and cancel outdated requests + int _currentRequestId = 0; + @override TrackState build() { return const TrackState(); } + /// Check if request is still valid (not cancelled by newer request) + bool _isRequestValid(int requestId) => requestId == _currentRequestId; + Future 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) final savedState = state.hasContent ? TrackState( tracks: state.tracks, @@ -102,9 +111,12 @@ class TrackNotifier extends Notifier { try { final parsed = await PlatformBridge.parseSpotifyUrl(url); + if (!_isRequestValid(requestId)) return; // Request cancelled + final type = parsed['type'] as String; final metadata = await PlatformBridge.getSpotifyMetadata(url); + if (!_isRequestValid(requestId)) return; // Request cancelled if (type == 'track') { final trackData = metadata['track'] as Map; @@ -152,11 +164,15 @@ class TrackNotifier extends Notifier { ); } } catch (e) { + if (!_isRequestValid(requestId)) return; // Request cancelled state = TrackState(isLoading: false, error: e.toString(), previousState: savedState); } } Future search(String query) async { + // Increment request ID to cancel any pending requests + final requestId = ++_currentRequestId; + // Save current state for back navigation final savedState = state.hasContent ? TrackState( tracks: state.tracks, @@ -172,6 +188,8 @@ class TrackNotifier extends Notifier { try { final results = await PlatformBridge.searchSpotify(query, limit: 20); + if (!_isRequestValid(requestId)) return; // Request cancelled + final trackList = results['tracks'] as List? ?? []; final tracks = trackList.map((t) => _parseSearchTrack(t as Map)).toList(); state = TrackState( @@ -180,6 +198,7 @@ class TrackNotifier extends Notifier { previousState: savedState, ); } catch (e) { + if (!_isRequestValid(requestId)) return; // Request cancelled state = TrackState(isLoading: false, error: e.toString(), previousState: savedState); } } @@ -242,6 +261,9 @@ class TrackNotifier extends Notifier { /// Fetch album from artist view - saves current artist state for back navigation Future fetchAlbumFromArtist(String albumId) async { + // Increment request ID to cancel any pending requests + final requestId = ++_currentRequestId; + // Save current artist state before fetching album final savedState = TrackState( artistName: state.artistName, @@ -258,6 +280,7 @@ class TrackNotifier extends Notifier { try { final url = 'https://open.spotify.com/album/$albumId'; final metadata = await PlatformBridge.getSpotifyMetadata(url); + if (!_isRequestValid(requestId)) return; // Request cancelled final albumInfo = metadata['album_info'] as Map; final trackList = metadata['track_list'] as List; @@ -271,6 +294,7 @@ class TrackNotifier extends Notifier { previousState: savedState, ); } catch (e) { + if (!_isRequestValid(requestId)) return; // Request cancelled state = TrackState( isLoading: false, error: e.toString(), diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 7e5f7abf..7e8e0995 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -74,16 +75,14 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient }); } - // Don't live search for URLs - wait for submit - if (text.startsWith('http') || text.startsWith('spotify:')) { - _debounce?.cancel(); - return; - } - - // Debounce search queries + // Debounce all requests (URLs and searches) _debounce?.cancel(); _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); } }); @@ -196,12 +195,6 @@ class _HomeTabState extends ConsumerState 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 Widget build(BuildContext context) { super.build(context); @@ -209,11 +202,21 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient // Listen for state changes to sync search bar ref.listen(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 hasResults = _hasResults; + final hasResults = _isTyping || tracks.isNotEmpty || artistAlbums != null || isLoading; final screenHeight = MediaQuery.of(context).size.height; - final historyItems = ref.watch(downloadHistoryProvider).items; + final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); return Scaffold( body: CustomScrollView( @@ -296,7 +299,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ? const SizedBox.shrink() : Column( children: [ - if (!ref.watch(settingsProvider).hasSearchedBefore) + if (!hasSearchedBefore) Padding( padding: const EdgeInsets.only(top: 8), child: Text( @@ -318,7 +321,18 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), // 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 with AutomaticKeepAliveClient itemCount: displayItems.length, itemBuilder: (context, index) { final item = displayItems[index]; - return GestureDetector( - onTap: () => _navigateToMetadataScreen(item), - child: Container( - width: 60, - margin: const EdgeInsets.only(right: 12), - child: Column( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: item.coverUrl != null - ? CachedNetworkImage( - imageUrl: item.coverUrl!, - width: 56, - height: 56, - fit: BoxFit.cover, - memCacheWidth: 112, - memCacheHeight: 112, - ) - : Container( - width: 56, - 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, + return KeyedSubtree( + key: ValueKey(item.id), + child: GestureDetector( + onTap: () => _navigateToMetadataScreen(item), + child: Container( + width: 60, + margin: const EdgeInsets.only(right: 12), + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: item.coverUrl != null + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: 112, + memCacheHeight: 112, + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24), + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), - ], + const SizedBox(height: 4), + Text( + 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 with AutomaticKeepAliveClient )); } - // Results content slivers (without app bar and search bar) - List _buildResultsContent(TrackState trackState, ColorScheme colorScheme, bool hasResults) { + // Results content slivers (without app bar and search bar) - optimized version + List _buildResultsContentOptimized({ + required List tracks, + required bool isLoading, + required String? error, + required String? albumName, + required String? playlistName, + required String? artistName, + required String? coverUrl, + required List? artistAlbums, + required ColorScheme colorScheme, + required bool hasResults, + }) { // Return empty slivers when no results to keep tree structure stable if (!hasResults) { return [const SliverToBoxAdapter(child: SizedBox.shrink())]; @@ -410,40 +438,57 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient return [ // Error message - if (trackState.error != null) + if (error != null) SliverToBoxAdapter(child: Padding( 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 - if (trackState.isLoading) + if (isLoading) const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())), // Album/Playlist header - if (trackState.albumName != null || trackState.playlistName != null) - SliverToBoxAdapter(child: _buildHeader(trackState, colorScheme)), + if (albumName != null || playlistName != null) + SliverToBoxAdapter(child: _buildHeaderOptimized( + albumName: albumName, + playlistName: playlistName, + coverUrl: coverUrl, + trackCount: tracks.length, + colorScheme: colorScheme, + )), // Artist header and discography - if (trackState.artistName != null && trackState.artistAlbums != null) - SliverToBoxAdapter(child: _buildArtistHeader(trackState, colorScheme)), + if (artistName != null && artistAlbums != null) + SliverToBoxAdapter(child: _buildArtistHeaderOptimized( + artistName: artistName, + coverUrl: coverUrl, + albumCount: artistAlbums.length, + colorScheme: colorScheme, + )), - if (trackState.artistAlbums != null && trackState.artistAlbums!.isNotEmpty) - SliverToBoxAdapter(child: _buildArtistDiscography(trackState, colorScheme)), + if (artistAlbums != null && artistAlbums.isNotEmpty) + SliverToBoxAdapter(child: _buildArtistDiscographyOptimized(artistAlbums, colorScheme)), // 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( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 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))), )), - // Track list + // Track list with keys for efficient updates SliverList(delegate: SliverChildBuilderDelegate( - (context, index) => _buildTrackTile(index, colorScheme), - childCount: trackState.tracks.length, + (context, index) { + final track = tracks[index]; + return KeyedSubtree( + key: ValueKey(track.id), + child: _buildTrackTileOptimized(track, index, colorScheme), + ); + }, + childCount: tracks.length, )), // Bottom padding @@ -451,6 +496,131 @@ class _HomeTabState extends ConsumerState 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 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) { final hasText = _urlController.text.isNotEmpty; @@ -498,101 +668,6 @@ class _HomeTabState extends ConsumerState 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 albums, ColorScheme colorScheme) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -613,7 +688,13 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12), 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 with AutomaticKeepAliveClient ref.read(trackProvider.notifier).fetchAlbumFromArtist(albumId); 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 { diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index c7672fb9..0acc0de0 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -47,11 +47,17 @@ class _MainShellState extends ConsumerState { _handleSharedUrl(pendingUrl); } - // Listen for future shared URLs - _shareSubscription = ShareIntentService().sharedUrlStream.listen((url) { - _log.d('Received shared URL from stream: $url'); - _handleSharedUrl(url); - }); + // Listen for future shared URLs with error handling + _shareSubscription = ShareIntentService().sharedUrlStream.listen( + (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) { diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 67e0b094..bada1730 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -16,12 +16,19 @@ class QueueTab extends ConsumerStatefulWidget { class _QueueTabState extends ConsumerState { final Map _fileExistsCache = {}; + static const int _maxCacheSize = 500; // Limit cache size to prevent memory leak bool _checkFileExists(String? filePath) { if (filePath == null) return false; if (_fileExistsCache.containsKey(filePath)) { return _fileExistsCache[filePath]!; } + + // Limit cache size - remove oldest entry if full + if (_fileExistsCache.length >= _maxCacheSize) { + _fileExistsCache.remove(_fileExistsCache.keys.first); + } + Future.microtask(() async { final exists = await File(filePath).exists(); if (mounted && _fileExistsCache[filePath] != exists) { @@ -69,8 +76,13 @@ class _QueueTabState extends ConsumerState { @override Widget build(BuildContext context) { - final queueState = ref.watch(downloadQueueProvider); - final historyState = ref.watch(downloadHistoryProvider); + // Use select() to only rebuild when specific fields change + 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 colorScheme = Theme.of(context).colorScheme; @@ -100,7 +112,7 @@ class _QueueTabState extends ConsumerState { ), // 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( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), @@ -113,14 +125,14 @@ class _QueueTabState extends ConsumerState { Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: queueState.isPaused + color: isPaused ? colorScheme.errorContainer : colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12), ), child: Icon( - queueState.isPaused ? Icons.pause : Icons.downloading, - color: queueState.isPaused + isPaused ? Icons.pause : Icons.downloading, + color: isPaused ? colorScheme.onErrorContainer : colorScheme.onPrimaryContainer, ), @@ -129,9 +141,9 @@ class _QueueTabState extends ConsumerState { // Status text - simplified Expanded( child: Text( - queueState.isPaused + isPaused ? 'Paused' - : '${queueState.completedCount}/${queueState.items.length}', + : '$completedCount/${queueItems.length}', style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, ), @@ -140,7 +152,7 @@ class _QueueTabState extends ConsumerState { // Pause/Resume button FilledButton.tonal( 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 { ), // Queue header - if (queueState.items.isNotEmpty) + if (queueItems.isNotEmpty) SliverToBoxAdapter( child: Padding( 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)), ), ), - // Queue list - if (queueState.items.isNotEmpty) + // Queue list with keys for efficient updates + if (queueItems.isNotEmpty) SliverList(delegate: SliverChildBuilderDelegate( - (context, index) => _buildQueueItem(context, queueState.items[index], colorScheme), - childCount: queueState.items.length, + (context, index) { + final item = queueItems[index]; + return KeyedSubtree( + key: ValueKey(item.id), + child: _buildQueueItem(context, item, colorScheme), + ); + }, + childCount: queueItems.length, )), // History section header - show count only - if (historyState.items.isNotEmpty && queueState.items.isEmpty) + if (historyItems.isNotEmpty && queueItems.isEmpty) SliverToBoxAdapter( child: Padding( 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)), ), ), // History section header when queue has items (show "Downloaded" label) - if (historyState.items.isNotEmpty && queueState.items.isNotEmpty) + if (historyItems.isNotEmpty && queueItems.isNotEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), @@ -186,8 +204,8 @@ class _QueueTabState extends ConsumerState { ), ), - // History - Grid or List based on setting - if (historyState.items.isNotEmpty) + // History - Grid or List based on setting (with keys) + if (historyItems.isNotEmpty) historyViewMode == 'grid' ? SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -199,18 +217,30 @@ class _QueueTabState extends ConsumerState { childAspectRatio: 0.75, ), delegate: SliverChildBuilderDelegate( - (context, index) => _buildHistoryGridItem(context, historyState.items[index], colorScheme), - childCount: historyState.items.length, + (context, index) { + final item = historyItems[index]; + return KeyedSubtree( + key: ValueKey(item.id), + child: _buildHistoryGridItem(context, item, colorScheme), + ); + }, + childCount: historyItems.length, ), ), ) : SliverList(delegate: SliverChildBuilderDelegate( - (context, index) => _buildHistoryItem(context, historyState.items[index], colorScheme), - childCount: historyState.items.length, + (context, index) { + 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 - if (queueState.items.isEmpty && historyState.items.isEmpty) + if (queueItems.isEmpty && historyItems.isEmpty) SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(context, colorScheme)) else const SliverToBoxAdapter(child: SizedBox(height: 16)), diff --git a/lib/services/apk_downloader.dart b/lib/services/apk_downloader.dart index 3c12b5b7..f17f7970 100644 --- a/lib/services/apk_downloader.dart +++ b/lib/services/apk_downloader.dart @@ -14,9 +14,18 @@ class ApkDownloader { required String version, ProgressCallback? onProgress, }) 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 { - final client = http.Client(); - final request = http.Request('GET', Uri.parse(url)); + final request = http.Request('GET', uri); final response = await client.send(request); if (response.statusCode != 200) { @@ -41,7 +50,7 @@ class ApkDownloader { await file.delete(); } - final sink = file.openWrite(); + sink = file.openWrite(); int received = 0; await for (final chunk in response.stream) { @@ -50,14 +59,15 @@ class ApkDownloader { onProgress?.call(received, contentLength); } - await sink.close(); - client.close(); - + await sink.flush(); _log.i('Downloaded to: $filePath'); return filePath; } catch (e) { _log.e('Error: $e'); return null; + } finally { + await sink?.close(); + client.close(); } } diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index 68b2d4e3..72130c12 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -92,6 +92,12 @@ class UpdateChecker { final name = (asset['name'] as String? ?? '').toLowerCase(); if (name.endsWith('.apk')) { 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')) { arm64Url = downloadUrl; } else if (name.contains('arm32') || name.contains('v7a') || name.contains('armeabi')) { diff --git a/lib/theme/dynamic_color_wrapper.dart b/lib/theme/dynamic_color_wrapper.dart index d74cc1f3..43c829af 100644 --- a/lib/theme/dynamic_color_wrapper.dart +++ b/lib/theme/dynamic_color_wrapper.dart @@ -27,7 +27,6 @@ class DynamicColorWrapper extends ConsumerWidget { // Use dynamic colors from wallpaper (Android 12+) lightScheme = lightDynamic; darkScheme = darkDynamic; - debugPrint('Using dynamic color from wallpaper'); } else { // Fallback to seed color final seedColor = themeSettings.seedColor; @@ -39,7 +38,6 @@ class DynamicColorWrapper extends ConsumerWidget { seedColor: seedColor, brightness: Brightness.dark, ); - debugPrint('Using fallback seed color: ${seedColor.toARGB32().toRadixString(16)}'); } // Build themes