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:
zarzet 2026-01-03 00:46:34 +07:00
parent a7c5afdd20
commit 08bca30fcd
12 changed files with 395 additions and 245 deletions

View file

@ -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

View file

@ -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
View 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:

View file

@ -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),

View file

@ -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();

View file

@ -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(),

View file

@ -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 {

View file

@ -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) {

View file

@ -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)),

View file

@ -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();
} }
} }

View file

@ -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')) {

View file

@ -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