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
+13
View File
@@ -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
-10
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)
## 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
<p align="center">
+3
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:
+14 -8
View File
@@ -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),
@@ -249,6 +249,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
@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();
+24
View File
@@ -81,12 +81,21 @@ class ArtistAlbum {
}
class TrackNotifier extends Notifier<TrackState> {
/// 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<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)
final savedState = state.hasContent ? TrackState(
tracks: state.tracks,
@@ -102,9 +111,12 @@ class TrackNotifier extends Notifier<TrackState> {
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<String, dynamic>;
@@ -152,11 +164,15 @@ class TrackNotifier extends Notifier<TrackState> {
);
}
} catch (e) {
if (!_isRequestValid(requestId)) return; // Request cancelled
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
}
}
Future<void> 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<TrackState> {
try {
final results = await PlatformBridge.searchSpotify(query, limit: 20);
if (!_isRequestValid(requestId)) return; // Request cancelled
final trackList = results['tracks'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
state = TrackState(
@@ -180,6 +198,7 @@ class TrackNotifier extends Notifier<TrackState> {
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<TrackState> {
/// Fetch album from artist view - saves current artist state for back navigation
Future<void> 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<TrackState> {
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<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
@@ -271,6 +294,7 @@ class TrackNotifier extends Notifier<TrackState> {
previousState: savedState,
);
} catch (e) {
if (!_isRequestValid(requestId)) return; // Request cancelled
state = TrackState(
isLoading: false,
error: e.toString(),
+247 -189
View File
@@ -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<HomeTab> 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<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
Widget build(BuildContext context) {
super.build(context);
@@ -209,11 +202,21 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
// Listen for state changes to sync search bar
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 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> with AutomaticKeepAliveClient
));
}
// Results content slivers (without app bar and search bar)
List<Widget> _buildResultsContent(TrackState trackState, ColorScheme colorScheme, bool hasResults) {
// Results content slivers (without app bar and search bar) - optimized version
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
if (!hasResults) {
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
@@ -410,40 +438,57 @@ class _HomeTabState extends ConsumerState<HomeTab> 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<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) {
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) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -613,7 +688,13 @@ class _HomeTabState extends ConsumerState<HomeTab> 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<HomeTab> 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 {
+11 -5
View File
@@ -47,11 +47,17 @@ class _MainShellState extends ConsumerState<MainShell> {
_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) {
+55 -25
View File
@@ -16,12 +16,19 @@ class QueueTab extends ConsumerStatefulWidget {
class _QueueTabState extends ConsumerState<QueueTab> {
final Map<String, bool> _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<QueueTab> {
@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<QueueTab> {
),
// 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<QueueTab> {
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<QueueTab> {
// 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<QueueTab> {
// 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<QueueTab> {
),
// 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<QueueTab> {
),
),
// 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<QueueTab> {
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)),
+16 -6
View File
@@ -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();
}
}
+6
View File
@@ -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')) {
-2
View File
@@ -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