diff --git a/CHANGELOG.md b/CHANGELOG.md index e842141c..aac4e343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,40 @@ # Changelog -## [3.5.1] - 2026-02-07 +## [3.5.1] - 2026-02-08 ### Performance - Removed PaletteService (palette_generator) from all screens for faster navigation and reduced memory usage - Album, Playlist, Downloaded Album, Local Album, and Track Metadata screens now use blurred cover art as header background instead of dominant color extraction - Removed `palette_generator` dependency +- App startup now renders immediately (`runApp`) while service initialization runs asynchronously in eager init +- Main shell provider subscriptions now use field-level `select(...)` to reduce unnecessary rebuilds +- Settings persistence now uses single-flight + queued save coalescing to avoid redundant disk writes +- Progress polling cadence adjusted to 800ms for download queue, local library scan progress, and Go log polling +- Android foreground download service progress updates are throttled (change-based updates + 5s heartbeat) +- SAF history repair is now batched (`20` items per batch) and capped per launch (`60`) to reduce startup I/O spikes +- Incremental library scan now builds final item list in-memory instead of reloading from database +- Local cover images in queue/library use direct `Image.file` with `errorBuilder` instead of `FutureBuilder` existence check +- CSV parser `_parseLine` rewritten: correct escaped-quote handling, no quote characters in output +- Removed unused legacy screen files (`home_screen.dart`, `queue_screen.dart`, `settings_screen.dart`, `settings_tab.dart`) +- Incremental local library scan now merges delta results in-memory and sorts once, avoiding full-state reload churn +- Queue local cover rendering now uses direct `Image.file` + `errorBuilder` (removed repeated async file-exists checks) + +### Added + +- Auto-cleanup orphaned downloads on history load (files that no longer exist are automatically removed from history) + +### Changed + +- Removed legacy screen files that were no longer used after the tab/part refactor: + - `lib/screens/home_screen.dart` + - `lib/screens/queue_screen.dart` + - `lib/screens/settings_screen.dart` + - `lib/screens/settings_tab.dart` + +### Fixed + +- CSV parser now correctly handles escaped quotes (`""`) inside quoted fields during import --- diff --git a/lib/main.dart b/lib/main.dart index 834632d4..c2226f63 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,21 +11,9 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - - await CoverCacheManager.initialize(); - debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}'); - - await Future.wait([ - NotificationService().initialize(), - ShareIntentService().initialize(), - ]); - + runApp( - ProviderScope( - child: const _EagerInitialization( - child: SpotiFLACApp(), - ), - ), + ProviderScope(child: const _EagerInitialization(child: SpotiFLACApp())), ); } @@ -35,27 +23,43 @@ class _EagerInitialization extends ConsumerStatefulWidget { final Widget child; @override - ConsumerState<_EagerInitialization> createState() => _EagerInitializationState(); + ConsumerState<_EagerInitialization> createState() => + _EagerInitializationState(); } class _EagerInitializationState extends ConsumerState<_EagerInitialization> { @override void initState() { super.initState(); + _initializeAppServices(); _initializeExtensions(); ref.read(downloadHistoryProvider); } + Future _initializeAppServices() async { + try { + await CoverCacheManager.initialize(); + await Future.wait([ + NotificationService().initialize(), + ShareIntentService().initialize(), + ]); + } catch (e) { + debugPrint('Failed to initialize app services: $e'); + } + } + Future _initializeExtensions() async { try { final appDir = await getApplicationDocumentsDirectory(); final extensionsDir = '${appDir.path}/extensions'; final dataDir = '${appDir.path}/extension_data'; - + await Directory(extensionsDir).create(recursive: true); await Directory(dataDir).create(recursive: true); - - await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir); + + await ref + .read(extensionProvider.notifier) + .initialize(extensionsDir, dataDir); } catch (e) { debugPrint('Failed to initialize extensions: $e'); } diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index b3f27dbb..e5386f13 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -226,8 +226,11 @@ class DownloadHistoryState { } class DownloadHistoryNotifier extends Notifier { + static const int _safRepairBatchSize = 20; + static const int _safRepairMaxPerLaunch = 60; final HistoryDatabase _db = HistoryDatabase.instance; bool _isLoaded = false; + bool _isSafRepairInProgress = false; @override DownloadHistoryState build() { @@ -267,8 +270,14 @@ class DownloadHistoryNotifier extends Notifier { if (Platform.isAndroid) { Future.microtask(() async { - await _repairMissingSafEntries(items); + await _repairMissingSafEntries( + items, + maxItems: _safRepairMaxPerLaunch, + ); + await cleanupOrphanedDownloads(); }); + } else { + Future.microtask(() => cleanupOrphanedDownloads()); } } catch (e, stack) { _historyLog.e('Failed to load history from database: $e', e, stack); @@ -285,10 +294,16 @@ class DownloadHistoryNotifier extends Notifier { return ''; } - Future _repairMissingSafEntries(List items) async { - final updatedItems = [...items]; - var changed = false; + Future _repairMissingSafEntries( + List items, { + required int maxItems, + }) async { + if (_isSafRepairInProgress || items.isEmpty) { + return; + } + _isSafRepairInProgress = true; + final candidateIndexes = []; for (var i = 0; i < items.length; i++) { final item = items[i]; if (item.storageMode != 'saf') continue; @@ -299,46 +314,85 @@ class DownloadHistoryNotifier extends Notifier { if (item.filePath.isEmpty || !isContentUri(item.filePath)) { continue; } - - final exists = await fileExists(item.filePath); - if (exists) continue; - - final fallbackName = item.safFileName ?? _fileNameFromUri(item.filePath); - if (fallbackName.isEmpty) { - _historyLog.w('Missing SAF filename for history item: ${item.id}'); - continue; - } - - try { - final resolved = await PlatformBridge.resolveSafFile( - treeUri: item.downloadTreeUri!, - relativeDir: item.safRelativeDir ?? '', - fileName: fallbackName, - ); - final newUri = resolved['uri'] as String? ?? ''; - if (newUri.isEmpty) continue; - - final newRelativeDir = resolved['relative_dir'] as String?; - final updated = item.copyWith( - filePath: newUri, - safRelativeDir: (newRelativeDir != null && newRelativeDir.isNotEmpty) - ? newRelativeDir - : item.safRelativeDir, - safFileName: fallbackName, - safRepaired: true, - ); - - updatedItems[i] = updated; - changed = true; - await _db.upsert(updated.toJson()); - _historyLog.i('Repaired SAF URI for history item: ${item.id}'); - } catch (e) { - _historyLog.w('Failed to repair SAF URI: $e'); - } + candidateIndexes.add(i); + if (candidateIndexes.length >= maxItems) break; } - if (changed) { - state = state.copyWith(items: updatedItems); + if (candidateIndexes.isEmpty) { + _isSafRepairInProgress = false; + return; + } + + final updatedItems = [...items]; + var changed = false; + var repairedCount = 0; + var verifiedCount = 0; + + try { + for (var c = 0; c < candidateIndexes.length; c++) { + final i = candidateIndexes[c]; + final item = items[i]; + + final exists = await fileExists(item.filePath); + if (exists) { + final verified = item.copyWith( + safRepaired: true, + safFileName: item.safFileName ?? _fileNameFromUri(item.filePath), + ); + updatedItems[i] = verified; + changed = true; + verifiedCount++; + await _db.upsert(verified.toJson()); + } else { + final fallbackName = + item.safFileName ?? _fileNameFromUri(item.filePath); + if (fallbackName.isEmpty) { + _historyLog.w('Missing SAF filename for history item: ${item.id}'); + continue; + } + + try { + final resolved = await PlatformBridge.resolveSafFile( + treeUri: item.downloadTreeUri!, + relativeDir: item.safRelativeDir ?? '', + fileName: fallbackName, + ); + final newUri = resolved['uri'] as String? ?? ''; + if (newUri.isEmpty) continue; + + final newRelativeDir = resolved['relative_dir'] as String?; + final updated = item.copyWith( + filePath: newUri, + safRelativeDir: + (newRelativeDir != null && newRelativeDir.isNotEmpty) + ? newRelativeDir + : item.safRelativeDir, + safFileName: fallbackName, + safRepaired: true, + ); + + updatedItems[i] = updated; + changed = true; + repairedCount++; + await _db.upsert(updated.toJson()); + } catch (e) { + _historyLog.w('Failed to repair SAF URI: $e'); + } + } + + if ((c + 1) % _safRepairBatchSize == 0) { + await Future.delayed(const Duration(milliseconds: 16)); + } + } + + if (changed) { + state = state.copyWith(items: updatedItems); + _historyLog.i( + 'SAF repair pass: verified=$verifiedCount, repaired=$repairedCount, checked=${candidateIndexes.length}', + ); + } + } finally { + _isSafRepairInProgress = false; } } @@ -412,18 +466,18 @@ class DownloadHistoryNotifier extends Notifier { /// Returns the number of orphaned entries removed Future cleanupOrphanedDownloads() async { _historyLog.i('Starting orphaned downloads cleanup...'); - + final entries = await _db.getAllEntriesWithPaths(); final orphanedIds = []; - + for (final entry in entries) { final id = entry['id'] as String; final filePath = entry['file_path'] as String?; - + if (filePath == null || filePath.isEmpty) continue; - + bool exists = false; - + if (filePath.startsWith('content://')) { // SAF path - check via platform bridge try { @@ -436,31 +490,33 @@ class DownloadHistoryNotifier extends Notifier { // Regular file path exists = File(filePath).existsSync(); } - + if (!exists) { orphanedIds.add(id); _historyLog.d('Found orphaned entry: $id ($filePath)'); } } - + if (orphanedIds.isEmpty) { _historyLog.i('No orphaned entries found'); return 0; } - + // Delete from database final deletedCount = await _db.deleteByIds(orphanedIds); - + // Update in-memory state final orphanedSet = orphanedIds.toSet(); state = state.copyWith( - items: state.items.where((item) => !orphanedSet.contains(item.id)).toList(), + items: state.items + .where((item) => !orphanedSet.contains(item.id)) + .toList(), ); - + _historyLog.i('Cleaned up $deletedCount orphaned entries'); return deletedCount; } - + void clearHistory() { state = DownloadHistoryState(); _db.clearAll().catchError((e) { @@ -557,6 +613,7 @@ class DownloadQueueNotifier extends Notifier { int _downloadCount = 0; static const _cleanupInterval = 50; static const _queueStorageKey = 'download_queue'; + static const _progressPollingInterval = Duration(milliseconds: 800); final NotificationService _notificationService = NotificationService(); final Future _prefs = SharedPreferences.getInstance(); int _totalQueuedAtStart = 0; @@ -564,6 +621,12 @@ class DownloadQueueNotifier extends Notifier { int _failedInSession = 0; bool _isLoaded = false; final Set _ensuredDirs = {}; + int _progressPollingErrorCount = 0; + String? _lastServiceTrackName; + String? _lastServiceArtistName; + int _lastServicePercent = -1; + int _lastServiceQueueCount = -1; + DateTime _lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0); @override DownloadQueueState build() { @@ -647,9 +710,7 @@ class DownloadQueueNotifier extends Notifier { void _startMultiProgressPolling() { _progressTimer?.cancel(); - _progressTimer = Timer.periodic(const Duration(milliseconds: 500), ( - timer, - ) async { + _progressTimer = Timer.periodic(_progressPollingInterval, (timer) async { try { final allProgress = await PlatformBridge.getAllDownloadProgress(); final items = allProgress['items'] as Map? ?? {}; @@ -818,23 +879,76 @@ class DownloadQueueNotifier extends Notifier { ); if (Platform.isAndroid) { - PlatformBridge.updateDownloadServiceProgress( + _maybeUpdateAndroidDownloadService( trackName: firstDownloading.track.name, artistName: firstDownloading.track.artistName, progress: notifProgress, total: notifTotal > 0 ? notifTotal : 1, queueCount: queuedCount, - ).catchError((_) {}); + ); } } } - } catch (_) {} + _progressPollingErrorCount = 0; + } catch (e) { + _progressPollingErrorCount++; + if (_progressPollingErrorCount <= 3) { + _log.w('Progress polling failed: $e'); + } + } }); } + void _maybeUpdateAndroidDownloadService({ + required String trackName, + required String artistName, + required int progress, + required int total, + required int queueCount, + }) { + final now = DateTime.now(); + final safeTotal = total > 0 ? total : 1; + final progressPercent = ((progress * 100) / safeTotal) + .round() + .clamp(0, 100) + .toInt(); + + final didContentChange = + trackName != _lastServiceTrackName || + artistName != _lastServiceArtistName || + queueCount != _lastServiceQueueCount || + progressPercent != _lastServicePercent; + final allowHeartbeat = + now.difference(_lastServiceUpdateAt) >= const Duration(seconds: 5); + + if (!didContentChange && !allowHeartbeat) { + return; + } + + _lastServiceTrackName = trackName; + _lastServiceArtistName = artistName; + _lastServicePercent = progressPercent; + _lastServiceQueueCount = queueCount; + _lastServiceUpdateAt = now; + + PlatformBridge.updateDownloadServiceProgress( + trackName: trackName, + artistName: artistName, + progress: progress, + total: safeTotal, + queueCount: queueCount, + ).catchError((_) {}); + } + void _stopProgressPolling() { _progressTimer?.cancel(); _progressTimer = null; + _progressPollingErrorCount = 0; + _lastServiceTrackName = null; + _lastServiceArtistName = null; + _lastServicePercent = -1; + _lastServiceQueueCount = -1; + _lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0); } Future _initOutputDir() async { @@ -2241,7 +2355,7 @@ class DownloadQueueNotifier extends Notifier { while (true) { if (state.isPaused) { _log.d('Queue is paused, waiting...'); - await Future.delayed(const Duration(milliseconds: 500)); + await Future.delayed(_progressPollingInterval); continue; } @@ -2280,7 +2394,7 @@ class DownloadQueueNotifier extends Notifier { if (activeDownloads.isNotEmpty) { await Future.any(activeDownloads.values); } else { - await Future.delayed(const Duration(milliseconds: 500)); + await Future.delayed(_progressPollingInterval); } continue; } diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index b5cc52bc..97da7ef9 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -36,16 +36,16 @@ class LocalLibraryState { this.scanErrorCount = 0, this.scanWasCancelled = false, this.lastScannedAt, - }) : _isrcSet = items - .where((item) => item.isrc != null && item.isrc!.isNotEmpty) - .map((item) => item.isrc!) - .toSet(), - _trackKeySet = items.map((item) => item.matchKey).toSet(), - _byIsrc = Map.fromEntries( - items - .where((item) => item.isrc != null && item.isrc!.isNotEmpty) - .map((item) => MapEntry(item.isrc!, item)), - ); + }) : _isrcSet = items + .where((item) => item.isrc != null && item.isrc!.isNotEmpty) + .map((item) => item.isrc!) + .toSet(), + _trackKeySet = items.map((item) => item.matchKey).toSet(), + _byIsrc = Map.fromEntries( + items + .where((item) => item.isrc != null && item.isrc!.isNotEmpty) + .map((item) => MapEntry(item.isrc!, item)), + ); bool hasIsrc(String isrc) => _isrcSet.contains(isrc); @@ -99,9 +99,11 @@ class LocalLibraryState { class LocalLibraryNotifier extends Notifier { final LibraryDatabase _db = LibraryDatabase.instance; final HistoryDatabase _historyDb = HistoryDatabase.instance; + static const _progressPollingInterval = Duration(milliseconds: 800); Timer? _progressTimer; bool _isLoaded = false; bool _scanCancelRequested = false; + int _progressPollingErrorCount = 0; @override LocalLibraryState build() { @@ -121,10 +123,8 @@ class LocalLibraryNotifier extends Notifier { try { final jsonList = await _db.getAll(); - final items = jsonList - .map((e) => LocalLibraryItem.fromJson(e)) - .toList(); - + final items = jsonList.map((e) => LocalLibraryItem.fromJson(e)).toList(); + DateTime? lastScannedAt; try { final prefs = await SharedPreferences.getInstance(); @@ -135,9 +135,11 @@ class LocalLibraryNotifier extends Notifier { } catch (e) { _log.w('Failed to load lastScannedAt: $e'); } - + state = state.copyWith(items: items, lastScannedAt: lastScannedAt); - _log.i('Loaded ${items.length} items from library database, lastScannedAt: $lastScannedAt'); + _log.i( + 'Loaded ${items.length} items from library database, lastScannedAt: $lastScannedAt', + ); } catch (e, stack) { _log.e('Failed to load library from database: $e', e, stack); } @@ -148,14 +150,19 @@ class LocalLibraryNotifier extends Notifier { await _loadFromDatabase(); } - Future startScan(String folderPath, {bool forceFullScan = false}) async { + Future startScan( + String folderPath, { + bool forceFullScan = false, + }) async { if (state.isScanning) { _log.w('Scan already in progress'); return; } _scanCancelRequested = false; - _log.i('Starting library scan: $folderPath (incremental: ${!forceFullScan})'); + _log.i( + 'Starting library scan: $folderPath (incremental: ${!forceFullScan})', + ); state = state.copyWith( isScanning: true, scanProgress: 0, @@ -179,11 +186,13 @@ class LocalLibraryNotifier extends Notifier { try { final isSaf = folderPath.startsWith('content://'); - + // Get all file paths from download history to exclude them final downloadedPaths = await _historyDb.getAllFilePaths(); - _log.i('Excluding ${downloadedPaths.length} downloaded files from library scan'); - + _log.i( + 'Excluding ${downloadedPaths.length} downloaded files from library scan', + ); + if (forceFullScan) { // Full scan path - ignores existing data final results = isSaf @@ -193,7 +202,7 @@ class LocalLibraryNotifier extends Notifier { state = state.copyWith(isScanning: false, scanWasCancelled: true); return; } - + final items = []; int skippedDownloads = 0; for (final json in results) { @@ -206,7 +215,7 @@ class LocalLibraryNotifier extends Notifier { final item = LocalLibraryItem.fromJson(json); items.add(item); } - + if (skippedDownloads > 0) { _log.i('Skipped $skippedDownloads files already in download history'); } @@ -234,7 +243,9 @@ class LocalLibraryNotifier extends Notifier { } else { // Incremental scan path - only scans new/modified files final existingFiles = await _db.getFileModTimes(); - _log.i('Incremental scan: ${existingFiles.length} existing files in database'); + _log.i( + 'Incremental scan: ${existingFiles.length} existing files in database', + ); final backfilledModTimes = await _backfillLegacyFileModTimes( isSaf: isSaf, @@ -245,7 +256,7 @@ class LocalLibraryNotifier extends Notifier { existingFiles.addAll(backfilledModTimes); _log.i('Backfilled ${backfilledModTimes.length} legacy mod times'); } - + // Use appropriate incremental scan method based on SAF or not final Map result; if (isSaf) { @@ -259,63 +270,76 @@ class LocalLibraryNotifier extends Notifier { existingFiles, ); } - + if (_scanCancelRequested) { state = state.copyWith(isScanning: false, scanWasCancelled: true); return; } - + // Parse incremental scan result // SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths' - final scannedList = (result['files'] as List?) - ?? (result['scanned'] as List?) - ?? []; - final deletedPaths = (result['removedUris'] as List?) - ?.map((e) => e as String) - .toList() - ?? (result['deletedPaths'] as List?) + final scannedList = + (result['files'] as List?) ?? + (result['scanned'] as List?) ?? + []; + final deletedPaths = + (result['removedUris'] as List?) ?.map((e) => e as String) - .toList() - ?? []; + .toList() ?? + (result['deletedPaths'] as List?) + ?.map((e) => e as String) + .toList() ?? + []; final skippedCount = result['skippedCount'] as int? ?? 0; final totalFiles = result['totalFiles'] as int? ?? 0; - - _log.i('Incremental result: ${scannedList.length} scanned, ' - '$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total'); - + + _log.i( + 'Incremental result: ${scannedList.length} scanned, ' + '$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total', + ); + + final currentByPath = { + for (final item in state.items) item.filePath: item, + }; + // Upsert new/modified items (excluding downloaded files) + final updatedItems = []; + int skippedDownloads = 0; if (scannedList.isNotEmpty) { - final items = []; - int skippedDownloads = 0; for (final json in scannedList) { final map = json as Map; final filePath = map['filePath'] as String?; - // Skip files that are already in download history if (filePath != null && downloadedPaths.contains(filePath)) { skippedDownloads++; continue; } - items.add(LocalLibraryItem.fromJson(map)); + final item = LocalLibraryItem.fromJson(map); + updatedItems.add(item); + currentByPath[item.filePath] = item; } - if (items.isNotEmpty) { - await _db.upsertBatch(items.map((e) => e.toJson()).toList()); - _log.i('Upserted ${items.length} items'); + if (updatedItems.isNotEmpty) { + await _db.upsertBatch(updatedItems.map((e) => e.toJson()).toList()); + _log.i('Upserted ${updatedItems.length} items'); } if (skippedDownloads > 0) { - _log.i('Skipped $skippedDownloads files already in download history'); + _log.i( + 'Skipped $skippedDownloads files already in download history', + ); } } - + // Delete removed items if (deletedPaths.isNotEmpty) { final deleteCount = await _db.deleteByPaths(deletedPaths); + for (final path in deletedPaths) { + currentByPath.remove(path); + } _log.i('Deleted $deleteCount items from database'); } - - // Reload all items from database to get complete list - final allItems = await _db.getAll(); - final items = allItems.map((e) => LocalLibraryItem.fromJson(e)).toList(); - + + final items = currentByPath.values.toList(growable: false) + ..sort(_compareLibraryItems); + final now = DateTime.now(); try { final prefs = await SharedPreferences.getInstance(); @@ -333,8 +357,10 @@ class LocalLibraryNotifier extends Notifier { scanWasCancelled: false, ); - _log.i('Incremental scan complete: ${items.length} total tracks ' - '(${scannedList.length} new/updated, $skippedCount unchanged, ${deletedPaths.length} removed)'); + _log.i( + 'Incremental scan complete: ${items.length} total tracks ' + '(${scannedList.length} new/updated, $skippedCount unchanged, ${deletedPaths.length} removed)', + ); } } catch (e, stack) { _log.e('Library scan failed: $e', e, stack); @@ -346,10 +372,10 @@ class LocalLibraryNotifier extends Notifier { void _startProgressPolling() { _progressTimer?.cancel(); - _progressTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async { + _progressTimer = Timer.periodic(_progressPollingInterval, (_) async { try { final progress = await PlatformBridge.getLibraryScanProgress(); - + state = state.copyWith( scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0, scanCurrentFile: progress['current_file'] as String?, @@ -361,18 +387,25 @@ class LocalLibraryNotifier extends Notifier { if (progress['is_complete'] == true) { _stopProgressPolling(); } - } catch (_) {} + _progressPollingErrorCount = 0; + } catch (e) { + _progressPollingErrorCount++; + if (_progressPollingErrorCount <= 3) { + _log.w('Library scan progress polling failed: $e'); + } + } }); } void _stopProgressPolling() { _progressTimer?.cancel(); _progressTimer = null; + _progressPollingErrorCount = 0; } Future cancelScan() async { if (!state.isScanning) return; - + _log.i('Cancelling library scan'); _scanCancelRequested = true; await PlatformBridge.cancelLibraryScan(); @@ -390,14 +423,14 @@ class LocalLibraryNotifier extends Notifier { Future clearLibrary() async { await _db.clearAll(); - + try { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_lastScannedAtKey); } catch (e) { _log.w('Failed to clear lastScannedAt: $e'); } - + state = LocalLibraryState(); _log.i('Library cleared'); } @@ -421,7 +454,11 @@ class LocalLibraryNotifier extends Notifier { return state.getByIsrc(isrc); } - LocalLibraryItem? findExisting({String? isrc, String? trackName, String? artistName}) { + LocalLibraryItem? findExisting({ + String? isrc, + String? trackName, + String? artistName, + }) { if (isrc != null && isrc.isNotEmpty) { final byIsrc = state.getByIsrc(isrc); if (byIsrc != null) return byIsrc; @@ -434,7 +471,7 @@ class LocalLibraryNotifier extends Notifier { Future> search(String query) async { if (query.isEmpty) return []; - + final results = await _db.search(query); return results.map((e) => LocalLibraryItem.fromJson(e)).toList(); } @@ -443,6 +480,23 @@ class LocalLibraryNotifier extends Notifier { return await _db.getCount(); } + int _compareLibraryItems(LocalLibraryItem a, LocalLibraryItem b) { + final artistA = (a.albumArtist ?? a.artistName).toLowerCase(); + final artistB = (b.albumArtist ?? b.artistName).toLowerCase(); + final artistCompare = artistA.compareTo(artistB); + if (artistCompare != 0) return artistCompare; + + final albumCompare = a.albumName.toLowerCase().compareTo( + b.albumName.toLowerCase(), + ); + if (albumCompare != 0) return albumCompare; + + final discCompare = (a.discNumber ?? 0).compareTo(b.discNumber ?? 0); + if (discCompare != 0) return discCompare; + + return (a.trackNumber ?? 0).compareTo(b.trackNumber ?? 0); + } + Future> _backfillLegacyFileModTimes({ required bool isSaf, required Map existingFiles, @@ -469,7 +523,9 @@ class LocalLibraryNotifier extends Notifier { if (_scanCancelRequested) { break; } - final end = (i + chunkSize < uris.length) ? i + chunkSize : uris.length; + final end = (i + chunkSize < uris.length) + ? i + chunkSize + : uris.length; final chunk = uris.sublist(i, end); final chunkResult = await PlatformBridge.getSafFileModTimes(chunk); backfilled.addAll(chunkResult); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 3dd3ae8c..83762706 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -10,10 +10,14 @@ const _settingsKey = 'app_settings'; const _migrationVersionKey = 'settings_migration_version'; const _currentMigrationVersion = 2; const _spotifyClientSecretKey = 'spotify_client_secret'; +final _log = AppLogger('SettingsProvider'); class SettingsNotifier extends Notifier { final Future _prefs = SharedPreferences.getInstance(); final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); + bool _isSavingSettings = false; + bool _saveQueued = false; + String? _pendingSettingsJson; @override AppSettings build() { @@ -26,27 +30,27 @@ class SettingsNotifier extends Notifier { final json = prefs.getString(_settingsKey); if (json != null) { state = AppSettings.fromJson(jsonDecode(json)); - + await _runMigrations(prefs); } await _loadSpotifyClientSecret(prefs); _applySpotifyCredentials(); - + LogBuffer.loggingEnabled = state.enableLogging; } Future _runMigrations(SharedPreferences prefs) async { final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0; - + if (lastMigration < 1) { if (!state.useCustomSpotifyCredentials) { state = state.copyWith(metadataSource: 'deezer'); await _saveSettings(); } } - + if (lastMigration < _currentMigrationVersion) { if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') { state = state.copyWith(storageMode: 'saf'); @@ -61,20 +65,43 @@ class SettingsNotifier extends Notifier { } Future _saveSettings() async { - final prefs = await _prefs; - final settingsToSave = state.copyWith( - spotifyClientSecret: '', - ); - await prefs.setString(_settingsKey, jsonEncode(settingsToSave.toJson())); + final settingsToSave = state.copyWith(spotifyClientSecret: ''); + _pendingSettingsJson = jsonEncode(settingsToSave.toJson()); + + if (_isSavingSettings) { + _saveQueued = true; + return; + } + + _isSavingSettings = true; + try { + final prefs = await _prefs; + do { + final jsonToWrite = _pendingSettingsJson; + _saveQueued = false; + if (jsonToWrite != null) { + await prefs.setString(_settingsKey, jsonToWrite); + } + } while (_saveQueued); + } catch (e) { + _log.e('Failed to save settings: $e'); + } finally { + _isSavingSettings = false; + } } Future _loadSpotifyClientSecret(SharedPreferences prefs) async { - final storedSecret = await _secureStorage.read(key: _spotifyClientSecretKey); + final storedSecret = await _secureStorage.read( + key: _spotifyClientSecretKey, + ); final prefsSecret = state.spotifyClientSecret; if ((storedSecret == null || storedSecret.isEmpty) && prefsSecret.isNotEmpty) { - await _secureStorage.write(key: _spotifyClientSecretKey, value: prefsSecret); + await _secureStorage.write( + key: _spotifyClientSecretKey, + value: prefsSecret, + ); } final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty) @@ -99,7 +126,7 @@ class SettingsNotifier extends Notifier { } Future _applySpotifyCredentials() async { - if (state.spotifyClientId.isNotEmpty && + if (state.spotifyClientId.isNotEmpty && state.spotifyClientSecret.isNotEmpty) { await PlatformBridge.setSpotifyCredentials( state.spotifyClientId, @@ -225,7 +252,10 @@ class SettingsNotifier extends Notifier { _saveSettings(); } - Future setSpotifyCredentials(String clientId, String clientSecret) async { + Future setSpotifyCredentials( + String clientId, + String clientSecret, + ) async { state = state.copyWith( spotifyClientId: clientId, spotifyClientSecret: clientSecret, @@ -236,10 +266,7 @@ class SettingsNotifier extends Notifier { } Future clearSpotifyCredentials() async { - state = state.copyWith( - spotifyClientId: '', - spotifyClientSecret: '', - ); + state = state.copyWith(spotifyClientId: '', spotifyClientSecret: ''); await _storeSpotifyClientSecret(''); _saveSettings(); _applySpotifyCredentials(); @@ -301,7 +328,7 @@ class SettingsNotifier extends Notifier { _saveSettings(); } -void setUseAllFilesAccess(bool enabled) { + void setUseAllFilesAccess(bool enabled) { state = state.copyWith(useAllFilesAccess: enabled); _saveSettings(); } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart deleted file mode 100644 index 2d89bc5e..00000000 --- a/lib/screens/home_screen.dart +++ /dev/null @@ -1,413 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:spotiflac_android/services/cover_cache_manager.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'; -import 'package:spotiflac_android/models/track.dart'; -import 'package:spotiflac_android/services/platform_bridge.dart'; - -class HomeScreen extends ConsumerStatefulWidget { - const HomeScreen({super.key}); - - @override - ConsumerState createState() => _HomeScreenState(); -} - -class _HomeScreenState extends ConsumerState { - final _urlController = TextEditingController(); - int _currentIndex = 0; - - @override - void dispose() { - _urlController.dispose(); - super.dispose(); - } - - Future _pasteFromClipboard() async { - final data = await Clipboard.getData(Clipboard.kTextPlain); - if (data?.text != null) { - _urlController.text = data!.text!; - } - } - - Future _fetchMetadata() async { - final url = _urlController.text.trim(); - if (url.isEmpty) return; - - if (url.startsWith('http') || url.startsWith('spotify:')) { - await ref.read(trackProvider.notifier).fetchFromUrl(url); - } else { - final settings = ref.read(settingsProvider); - await ref.read(trackProvider.notifier).search(url, metadataSource: settings.metadataSource); - } - } - - void _downloadTrack(Track track) { - final settings = ref.read(settingsProvider); - ref.read(downloadQueueProvider.notifier).addToQueue( - track, - settings.defaultService, - ); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Added "${track.name}" to queue')), - ); - } - - void _downloadAll() { - final trackState = ref.read(trackProvider); - if (trackState.tracks.isEmpty) return; - - final settings = ref.read(settingsProvider); - ref.read(downloadQueueProvider.notifier).addMultipleToQueue( - trackState.tracks, - settings.defaultService, - ); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')), - ); - } - - void _onNavTap(int index) { - setState(() => _currentIndex = index); - switch (index) { - case 0: - break; - case 1: - context.push('/queue'); - break; - case 2: - context.push('/history'); - break; - } - } - - @override - Widget build(BuildContext context) { - final trackState = ref.watch(trackProvider); - final queuedCount = - ref.watch(downloadQueueProvider.select((s) => s.queuedCount)); - final colorScheme = Theme.of(context).colorScheme; - final tracks = trackState.tracks; - - return Scaffold( - appBar: AppBar( - leading: Padding( - padding: const EdgeInsets.all(8.0), - child: CircleAvatar( - backgroundColor: colorScheme.primaryContainer, - child: Icon(Icons.music_note, color: colorScheme.onPrimaryContainer, size: 20), - ), - ), - title: const Text('SpotiFLAC'), - actions: [ - IconButton( - icon: const Icon(Icons.settings_outlined), - onPressed: () => context.push('/settings'), - ), - ], - ), - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: TextField( - controller: _urlController, - decoration: InputDecoration( - hintText: 'Paste Spotify URL or search...', - prefixIcon: const Icon(Icons.link), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton(icon: const Icon(Icons.paste), onPressed: _pasteFromClipboard), - IconButton(icon: const Icon(Icons.search), onPressed: _fetchMetadata), - ], - ), - ), - onSubmitted: (_) => _fetchMetadata(), - ), - ), - - if (trackState.error != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - trackState.error!, - style: TextStyle(color: colorScheme.error), - ), - ), - - if (trackState.isLoading) - LinearProgressIndicator(color: colorScheme.primary), - - if (trackState.albumName != null || trackState.playlistName != null) - _buildHeader(trackState, colorScheme), - - if (tracks.length > 1) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: FilledButton.icon( - onPressed: _downloadAll, - icon: const Icon(Icons.download), - label: Text('Download All (${tracks.length})'), - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(48), - ), - ), - ), - - Expanded( - child: tracks.isEmpty - ? _buildEmptyState(colorScheme) - : ListView.builder( - itemCount: tracks.length, - itemBuilder: (context, index) => - _buildTrackTile(tracks[index], colorScheme), - ), - ), - ], - ), - bottomNavigationBar: NavigationBar( - selectedIndex: _currentIndex, - onDestinationSelected: _onNavTap, - destinations: [ - const NavigationDestination( - icon: Icon(Icons.search_outlined), - selectedIcon: Icon(Icons.search), - label: 'Search', - ), - NavigationDestination( - icon: Badge( - isLabelVisible: queuedCount > 0, - label: Text('$queuedCount'), - child: const Icon(Icons.queue_music_outlined), - ), - selectedIcon: Badge( - isLabelVisible: queuedCount > 0, - label: Text('$queuedCount'), - child: const Icon(Icons.queue_music), - ), - label: 'Queue', - ), - const NavigationDestination( - icon: Icon(Icons.history_outlined), - selectedIcon: Icon(Icons.history), - label: 'History', - ), - ], - ), - ); - } - - 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(8), -child: CachedNetworkImage( - imageUrl: state.coverUrl!, - width: 80, - height: 80, - fit: BoxFit.cover, - cacheManager: CoverCacheManager.instance, - 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 _buildTrackTile(Track track, ColorScheme colorScheme) { - final isCollection = track.isCollection; - - String subtitleText; - if (isCollection) { - final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album'); - final capitalizedType = typeLabel.isNotEmpty - ? '${typeLabel[0].toUpperCase()}${typeLabel.substring(1)}' - : 'Album'; - final year = track.releaseDate != null && track.releaseDate!.length >= 4 - ? track.releaseDate!.substring(0, 4) - : ''; - subtitleText = '$capitalizedType • ${track.artistName}${year.isNotEmpty ? ' • $year' : ''}'; - } else { - subtitleText = track.artistName; - } - - return ListTile( - leading: track.coverUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(8), -child: CachedNetworkImage( - imageUrl: track.coverUrl!, - width: 48, - height: 48, - fit: BoxFit.cover, - cacheManager: CoverCacheManager.instance, - ), - ) - : Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - isCollection ? Icons.album : Icons.music_note, - color: colorScheme.onSurfaceVariant, - ), - ), - title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text( - subtitleText, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - trailing: isCollection - ? Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant) - : Text( - _formatDuration(track.duration), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - onTap: () => isCollection ? _openCollection(track) : _downloadTrack(track), - ); - } - - Future _openCollection(Track track) async { - final extensionId = track.source; - if (extensionId == null) return; - - try { - if (track.isAlbumItem) { - final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id); - if (albumData != null && mounted) { - final trackList = albumData['tracks'] as List? ?? []; - final tracks = trackList.map((t) => _parseExtensionTrack(t as Map, extensionId)).toList(); - ref.read(trackProvider.notifier).setTracksFromCollection( - tracks: tracks, - albumName: albumData['name'] as String? ?? track.name, - coverUrl: albumData['cover_url'] as String? ?? track.coverUrl, - ); - } - } else if (track.isPlaylistItem) { - final playlistData = await PlatformBridge.getPlaylistWithExtension(extensionId, track.id); - if (playlistData != null && mounted) { - final trackList = playlistData['tracks'] as List? ?? []; - final tracks = trackList.map((t) => _parseExtensionTrack(t as Map, extensionId)).toList(); - ref.read(trackProvider.notifier).setTracksFromCollection( - tracks: tracks, - playlistName: playlistData['name'] as String? ?? track.name, - coverUrl: playlistData['cover_url'] as String? ?? track.coverUrl, - ); - } - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to load: $e')), - ); - } - } - } - - Track _parseExtensionTrack(Map data, String source) { - int durationMs = 0; - final durationValue = data['duration_ms']; - if (durationValue is int) { - durationMs = durationValue; - } else if (durationValue is double) { - durationMs = durationValue.toInt(); - } - - return Track( - id: (data['id'] ?? '').toString(), - name: (data['name'] ?? '').toString(), - artistName: (data['artists'] ?? '').toString(), - albumName: (data['album_name'] ?? '').toString(), - coverUrl: (data['cover_url'] ?? data['images'])?.toString(), - duration: (durationMs / 1000).round(), - releaseDate: data['release_date']?.toString(), - source: source, - ); - } - - String _formatDuration(int ms) { - if (ms == 0) return ''; - final duration = Duration(milliseconds: ms); - final minutes = duration.inMinutes; - final seconds = duration.inSeconds % 60; - return '$minutes:${seconds.toString().padLeft(2, '0')}'; - } - - Widget _buildEmptyState(ColorScheme colorScheme) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.music_note, - size: 64, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 16), - Text( - 'Paste a Spotify URL to get started', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ); - } -} diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index e0112cc6..1d89264b 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -82,9 +82,9 @@ class _MainShellState extends ConsumerState { } } } - + if (!mounted) return; - + Navigator.of(context).popUntil((route) => route.isFirst); if (_currentIndex != 0) { @@ -181,10 +181,14 @@ class _MainShellState extends ConsumerState { final treeUri = result['tree_uri'] as String? ?? ''; final displayName = result['display_name'] as String? ?? ''; if (treeUri.isNotEmpty) { - ref.read(settingsProvider.notifier).setDownloadTreeUri( - treeUri, - displayName: displayName.isNotEmpty ? displayName : treeUri, - ); + ref + .read(settingsProvider.notifier) + .setDownloadTreeUri( + treeUri, + displayName: displayName.isNotEmpty + ? displayName + : treeUri, + ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -280,7 +284,16 @@ class _MainShellState extends ConsumerState { final queueState = ref.watch( downloadQueueProvider.select((s) => s.queuedCount), ); - final trackState = ref.watch(trackProvider); + final trackHasSearchText = ref.watch( + trackProvider.select((s) => s.hasSearchText), + ); + final trackHasContent = ref.watch( + trackProvider.select((s) => s.hasContent), + ); + final trackIsLoading = ref.watch(trackProvider.select((s) => s.isLoading)); + final trackIsShowingRecentAccess = ref.watch( + trackProvider.select((s) => s.isShowingRecentAccess), + ); final showStore = ref.watch( settingsProvider.select((s) => s.showExtensionStore), ); @@ -292,10 +305,10 @@ class _MainShellState extends ConsumerState { final canPop = _currentIndex == 0 && - !trackState.hasSearchText && - !trackState.hasContent && - !trackState.isLoading && - !trackState.isShowingRecentAccess && + !trackHasSearchText && + !trackHasContent && + !trackIsLoading && + !trackIsShowingRecentAccess && !isKeyboardVisible; final tabs = [ diff --git a/lib/screens/queue_screen.dart b/lib/screens/queue_screen.dart deleted file mode 100644 index b649d496..00000000 --- a/lib/screens/queue_screen.dart +++ /dev/null @@ -1,248 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:spotiflac_android/services/cover_cache_manager.dart'; -import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/models/download_item.dart'; -import 'package:spotiflac_android/providers/download_queue_provider.dart'; - -class QueueScreen extends ConsumerWidget { - const QueueScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final items = ref.watch(downloadQueueProvider.select((s) => s.items)); - final colorScheme = Theme.of(context).colorScheme; - - return Scaffold( - appBar: AppBar( - title: Text(context.l10n.queueTitle), - actions: [ - if (items.isNotEmpty) - IconButton( - icon: const Icon(Icons.delete_sweep), - onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(), - tooltip: context.l10n.queueClearCompleted, - ), - if (items.isNotEmpty) - IconButton( - icon: const Icon(Icons.clear_all), - onPressed: () => _showClearAllDialog(context, ref), - tooltip: context.l10n.queueClearAll, - ), - ], - ), - body: items.isEmpty - ? _buildEmptyState(context, colorScheme) - : ListView.builder( - itemCount: items.length, - itemBuilder: (context, index) => - _buildQueueItem(context, ref, items[index], colorScheme), - ), - ); - } - - Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.queue, - size: 64, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 16), - Text( - context.l10n.queueEmpty, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Text( - context.l10n.queueEmptySubtitle, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), - ), - ), - ], - ), - ); - } - - Widget _buildQueueItem(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) { - return ListTile( - leading: item.track.coverUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(8), -child: CachedNetworkImage( - imageUrl: item.track.coverUrl!, - width: 48, - height: 48, - fit: BoxFit.cover, - cacheManager: CoverCacheManager.instance, - ), - ) - : Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), - ), - title: Text(item.track.name, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.track.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - if (item.status == DownloadStatus.downloading) ...[ - const SizedBox(height: 4), - Row( - children: [ - Expanded( - child: LinearProgressIndicator( - value: item.progress > 0 ? item.progress : null, - backgroundColor: colorScheme.surfaceContainerHighest, - color: colorScheme.primary, - ), - ), - const SizedBox(width: 8), - Text( - '${(item.progress * 100).toStringAsFixed(0)}%', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ], - ], - ), - trailing: _buildStatusIcon(context, item, colorScheme), - onTap: item.status == DownloadStatus.queued - ? () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id) - : null, - ); - } - - Widget _buildStatusIcon(BuildContext context, DownloadItem item, ColorScheme colorScheme) { - switch (item.status) { - case DownloadStatus.queued: - return Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant); - case DownloadStatus.downloading: - return SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - value: item.progress, - strokeWidth: 2, - color: colorScheme.primary, - ), - ); - case DownloadStatus.finalizing: - return SizedBox( - width: 24, - height: 24, - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator(strokeWidth: 2, color: colorScheme.tertiary), - Icon(Icons.edit_note, color: colorScheme.tertiary, size: 12), - ], - ), - ); - case DownloadStatus.completed: - return Icon(Icons.check_circle, color: colorScheme.primary); - case DownloadStatus.failed: - return IconButton( - icon: Icon(Icons.error, color: colorScheme.error), - onPressed: () => _showErrorDialog(context, item, colorScheme), - tooltip: 'Tap to see error details', - ); - case DownloadStatus.skipped: - return Icon(Icons.skip_next, color: colorScheme.primary); - } - } - - void _showErrorDialog(BuildContext context, DownloadItem item, ColorScheme colorScheme) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Row( - children: [ - Icon(Icons.error, color: colorScheme.error), - const SizedBox(width: 8), - Text(context.l10n.queueDownloadFailed), - ], - ), - content: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text('${context.l10n.queueTrackLabel} ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)), - Text('${context.l10n.queueArtistLabel} ${item.track.artistName}'), - const SizedBox(height: 16), - Text(context.l10n.queueErrorLabel, style: const TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(height: 4), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: colorScheme.errorContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - item.error ?? context.l10n.queueUnknownError, - style: TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: colorScheme.onErrorContainer, - ), - ), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.dialogClose), - ), - ], - ), - ); - } - - void _showClearAllDialog(BuildContext context, WidgetRef ref) { - final colorScheme = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(context.l10n.queueClearAll), - content: Text(context.l10n.queueClearAllMessage), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.dialogCancel), - ), - TextButton( - onPressed: () { - ref.read(downloadQueueProvider.notifier).clearAll(); - Navigator.pop(context); - }, - child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)), - ), - ], - ), - ); - } -} diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 5b689a38..cb02ae5b 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -32,7 +32,7 @@ class UnifiedLibraryItem { final String? quality; final DateTime addedAt; final LibraryItemSource source; - + final DownloadHistoryItem? historyItem; final LocalLibraryItem? localItem; @@ -69,7 +69,8 @@ class UnifiedLibraryItem { factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) { String? quality; if (item.bitDepth != null && item.sampleRate != null) { - quality = '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; + quality = + '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; } return UnifiedLibraryItem( id: 'local_${item.id}', @@ -87,10 +88,14 @@ class UnifiedLibraryItem { } /// Returns true if this item has a cover (either URL or local path) - bool get hasCover => coverUrl != null || (localCoverPath != null && localCoverPath!.isNotEmpty); + bool get hasCover => + coverUrl != null || + (localCoverPath != null && localCoverPath!.isNotEmpty); - String get searchKey => '${trackName.toLowerCase()}|${artistName.toLowerCase()}|${albumName.toLowerCase()}'; - String get albumKey => '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; + String get searchKey => + '${trackName.toLowerCase()}|${artistName.toLowerCase()}|${albumName.toLowerCase()}'; + String get albumKey => + '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; } class _GroupedAlbum { @@ -156,7 +161,7 @@ class _HistoryStats { /// Total album count including local library int get totalAlbumCount => albumCount + localAlbumCount; - + /// Total singles count including local library int get totalSingleTracks => singleTracks + localSingleTracks; } @@ -177,9 +182,7 @@ class _UnifiedCacheEntry { }); } -Map> _filterHistoryInIsolate( - Map payload, -) { +Map> _filterHistoryInIsolate(Map payload) { final entries = (payload['entries'] as List).cast(); final albumCounts = (payload['albumCounts'] as Map).cast(); final query = (payload['query'] as String?) ?? ''; @@ -206,11 +209,7 @@ Map> _filterHistoryInIsolate( } } - return { - 'all': allIds, - 'albums': albumIds, - 'singles': singleIds, - }; + return {'all': allIds, 'albums': albumIds, 'singles': singleIds}; } class QueueTab extends ConsumerStatefulWidget { @@ -278,16 +277,12 @@ class _QueueTabState extends ConsumerState { String _localFilterQueryCache = ''; List _filteredLocalItemsCache = const []; final Map _unifiedItemsCache = {}; - bool _showSafRepairedBadge = false; - // Advanced filters String? _filterSource; // null = all, 'downloaded', 'local' String? _filterQuality; // null = all, 'hires', 'cd', 'lossy' String? _filterFormat; // null = all, 'flac', 'mp3', 'm4a', 'opus', 'ogg' String? _filterDateRange; // null = all, 'today', 'week', 'month', 'year' - - @override void initState() { super.initState(); @@ -301,7 +296,7 @@ class _QueueTabState extends ConsumerState { _filterPageController = PageController(initialPage: initialPage); } -@override + @override void dispose() { _filterPageController?.dispose(); _searchController.dispose(); @@ -327,12 +322,15 @@ class _QueueTabState extends ConsumerState { _requestFilterRefresh(); } - void _ensureHistoryCaches(List items, List localItems) { + void _ensureHistoryCaches( + List items, + List localItems, + ) { final historyChanged = !identical(items, _historyItemsCache); final localChanged = !identical(localItems, _localLibraryItemsCache); - + if (!historyChanged && !localChanged) return; - + _historyItemsCache = items; _localLibraryItemsCache = localItems; _historyStatsCache = _buildHistoryStats(items, localItems); @@ -345,7 +343,9 @@ class _QueueTabState extends ConsumerState { _localSearchIndexCache ..clear() ..addEntries( - localItems.map((item) => MapEntry(item.id, _buildLocalSearchKey(item))), + localItems.map( + (item) => MapEntry(item.id, _buildLocalSearchKey(item)), + ), ); _localFilterItemsCache = null; _localFilterQueryCache = ''; @@ -353,18 +353,13 @@ class _QueueTabState extends ConsumerState { } _unifiedItemsCache.clear(); _historyItemsById = {for (final item in items) item.id: item}; - _historyFilterEntries = List>.generate( - items.length, - (index) { - final item = items[index]; - final searchKey = - _searchIndexCache[item.id] ?? _buildSearchKey(item); - final albumKey = - '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; - return [item.id, albumKey, searchKey]; - }, - growable: false, - ); + _historyFilterEntries = List>.generate(items.length, (index) { + final item = items[index]; + final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item); + final albumKey = + '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; + return [item.id, albumKey, searchKey]; + }, growable: false); _requestFilterRefresh(); } @@ -388,14 +383,16 @@ class _QueueTabState extends ConsumerState { return _filteredLocalItemsCache; } - final filtered = items.where((item) { - final searchKey = - _localSearchIndexCache[item.id] ?? _buildLocalSearchKey(item); - if (!_localSearchIndexCache.containsKey(item.id)) { - _localSearchIndexCache[item.id] = searchKey; - } - return searchKey.contains(query); - }).toList(growable: false); + final filtered = items + .where((item) { + final searchKey = + _localSearchIndexCache[item.id] ?? _buildLocalSearchKey(item); + if (!_localSearchIndexCache.containsKey(item.id)) { + _localSearchIndexCache[item.id] = searchKey; + } + return searchKey.contains(query); + }) + .toList(growable: false); _localFilterItemsCache = items; _localFilterQueryCache = query; @@ -437,10 +434,16 @@ class _QueueTabState extends ConsumerState { if (items.length <= _filterIsolateThreshold) { final filteredAll = _applyHistorySearchFilter(items, query); - final filteredAlbums = - _filterHistoryByAlbumCount(filteredAll, albumCounts, 2); - final filteredSingles = - _filterHistoryByAlbumCount(filteredAll, albumCounts, 1); + final filteredAlbums = _filterHistoryByAlbumCount( + filteredAll, + albumCounts, + 2, + ); + final filteredSingles = _filterHistoryByAlbumCount( + filteredAll, + albumCounts, + 1, + ); setState(() { _filteredHistoryCache = { 'all': filteredAll, @@ -513,13 +516,15 @@ class _QueueTabState extends ConsumerState { ) { if (searchQuery.isEmpty) return items; final query = searchQuery; - return items.where((item) { - final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item); - if (!_searchIndexCache.containsKey(item.id)) { - _searchIndexCache[item.id] = searchKey; - } - return searchKey.contains(query); - }).toList(growable: false); + return items + .where((item) { + final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item); + if (!_searchIndexCache.containsKey(item.id)) { + _searchIndexCache[item.id] = searchKey; + } + return searchKey.contains(query); + }) + .toList(growable: false); } List _filterHistoryByAlbumCount( @@ -527,12 +532,14 @@ class _QueueTabState extends ConsumerState { Map albumCounts, int targetCount, ) { - return items.where((item) { - final key = - '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; - final count = albumCounts[key] ?? 0; - return targetCount == 1 ? count == 1 : count >= targetCount; - }).toList(growable: false); + return items + .where((item) { + final key = + '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; + final count = albumCounts[key] ?? 0; + return targetCount == 1 ? count == 1 : count >= targetCount; + }) + .toList(growable: false); } bool _shouldShowFilteringIndicator({ @@ -639,7 +646,7 @@ class _QueueTabState extends ConsumerState { final cleanPath = _cleanFilePath(item.filePath); await deleteFile(cleanPath); } catch (_) {} - + // Remove from appropriate database if (item.source == LibraryItemSource.downloaded) { historyNotifier.removeFromHistory(item.historyItem!.id); @@ -652,7 +659,10 @@ class _QueueTabState extends ConsumerState { } // Reload local library if we deleted any local items - if (allItems.any((i) => _selectedIds.contains(i.id) && i.source == LibraryItemSource.local)) { + if (allItems.any( + (i) => + _selectedIds.contains(i.id) && i.source == LibraryItemSource.local, + )) { ref.read(localLibraryProvider.notifier).reloadFromStorage(); } @@ -735,59 +745,69 @@ class _QueueTabState extends ConsumerState { }); } - List _applyAdvancedFilters(List items) { + List _applyAdvancedFilters( + List items, + ) { if (_activeFilterCount == 0) return items; - return items.where((item) { - if (_filterSource != null) { - if (_filterSource == 'downloaded' && item.source != LibraryItemSource.downloaded) { - return false; - } - if (_filterSource == 'local' && item.source != LibraryItemSource.local) { - return false; - } - } - - if (_filterQuality != null && item.quality != null) { - final quality = item.quality!.toLowerCase(); - switch (_filterQuality) { - case 'hires': - if (!quality.startsWith('24')) return false; - case 'cd': - if (!quality.startsWith('16')) return false; - case 'lossy': - if (quality.startsWith('24') || quality.startsWith('16')) return false; - } - } else if (_filterQuality != null && item.quality == null) { - if (_filterQuality != 'lossy') return false; - } - - if (_filterFormat != null) { - final ext = item.filePath.split('.').last.toLowerCase(); - if (ext != _filterFormat) return false; - } - - if (_filterDateRange != null) { - final now = DateTime.now(); - final itemDate = item.addedAt; - switch (_filterDateRange) { - case 'today': - if (itemDate.year != now.year || itemDate.month != now.month || itemDate.day != now.day) { + return items + .where((item) { + if (_filterSource != null) { + if (_filterSource == 'downloaded' && + item.source != LibraryItemSource.downloaded) { return false; } - case 'week': - final weekAgo = now.subtract(const Duration(days: 7)); - if (itemDate.isBefore(weekAgo)) return false; - case 'month': - final monthAgo = DateTime(now.year, now.month - 1, now.day); - if (itemDate.isBefore(monthAgo)) return false; - case 'year': - if (itemDate.year != now.year) return false; - } - } + if (_filterSource == 'local' && + item.source != LibraryItemSource.local) { + return false; + } + } - return true; - }).toList(growable: false); + if (_filterQuality != null && item.quality != null) { + final quality = item.quality!.toLowerCase(); + switch (_filterQuality) { + case 'hires': + if (!quality.startsWith('24')) return false; + case 'cd': + if (!quality.startsWith('16')) return false; + case 'lossy': + if (quality.startsWith('24') || quality.startsWith('16')) { + return false; + } + } + } else if (_filterQuality != null && item.quality == null) { + if (_filterQuality != 'lossy') return false; + } + + if (_filterFormat != null) { + final ext = item.filePath.split('.').last.toLowerCase(); + if (ext != _filterFormat) return false; + } + + if (_filterDateRange != null) { + final now = DateTime.now(); + final itemDate = item.addedAt; + switch (_filterDateRange) { + case 'today': + if (itemDate.year != now.year || + itemDate.month != now.month || + itemDate.day != now.day) { + return false; + } + case 'week': + final weekAgo = now.subtract(const Duration(days: 7)); + if (itemDate.isBefore(weekAgo)) return false; + case 'month': + final monthAgo = DateTime(now.year, now.month - 1, now.day); + if (itemDate.isBefore(monthAgo)) return false; + case 'year': + if (itemDate.year != now.year) return false; + } + } + + return true; + }) + .toList(growable: false); } Set _getAvailableFormats(List items) { @@ -801,10 +821,13 @@ class _QueueTabState extends ConsumerState { return formats; } - void _showFilterSheet(BuildContext context, List allItems) { + void _showFilterSheet( + BuildContext context, + List allItems, + ) { final colorScheme = Theme.of(context).colorScheme; final availableFormats = _getAvailableFormats(allItems); - + String? tempSource = _filterSource; String? tempQuality = _filterQuality; String? tempFormat = _filterFormat; @@ -837,7 +860,7 @@ class _QueueTabState extends ConsumerState { ), ), ), - + Row( children: [ Text( @@ -875,17 +898,20 @@ class _QueueTabState extends ConsumerState { FilterChip( label: Text(context.l10n.libraryFilterAll), selected: tempSource == null, - onSelected: (_) => setSheetState(() => tempSource = null), + onSelected: (_) => + setSheetState(() => tempSource = null), ), FilterChip( label: Text(context.l10n.libraryFilterDownloaded), selected: tempSource == 'downloaded', - onSelected: (_) => setSheetState(() => tempSource = 'downloaded'), + onSelected: (_) => + setSheetState(() => tempSource = 'downloaded'), ), FilterChip( label: Text(context.l10n.libraryFilterLocal), selected: tempSource == 'local', - onSelected: (_) => setSheetState(() => tempSource = 'local'), + onSelected: (_) => + setSheetState(() => tempSource = 'local'), ), ], ), @@ -904,22 +930,26 @@ class _QueueTabState extends ConsumerState { FilterChip( label: Text(context.l10n.libraryFilterAll), selected: tempQuality == null, - onSelected: (_) => setSheetState(() => tempQuality = null), + onSelected: (_) => + setSheetState(() => tempQuality = null), ), FilterChip( label: Text(context.l10n.libraryFilterQualityHiRes), selected: tempQuality == 'hires', - onSelected: (_) => setSheetState(() => tempQuality = 'hires'), + onSelected: (_) => + setSheetState(() => tempQuality = 'hires'), ), FilterChip( label: Text(context.l10n.libraryFilterQualityCD), selected: tempQuality == 'cd', - onSelected: (_) => setSheetState(() => tempQuality = 'cd'), + onSelected: (_) => + setSheetState(() => tempQuality = 'cd'), ), FilterChip( label: Text(context.l10n.libraryFilterQualityLossy), selected: tempQuality == 'lossy', - onSelected: (_) => setSheetState(() => tempQuality = 'lossy'), + onSelected: (_) => + setSheetState(() => tempQuality = 'lossy'), ), ], ), @@ -938,13 +968,15 @@ class _QueueTabState extends ConsumerState { FilterChip( label: Text(context.l10n.libraryFilterAll), selected: tempFormat == null, - onSelected: (_) => setSheetState(() => tempFormat = null), + onSelected: (_) => + setSheetState(() => tempFormat = null), ), for (final format in availableFormats.toList()..sort()) FilterChip( label: Text(format.toUpperCase()), selected: tempFormat == format, - onSelected: (_) => setSheetState(() => tempFormat = format), + onSelected: (_) => + setSheetState(() => tempFormat = format), ), ], ), @@ -963,27 +995,32 @@ class _QueueTabState extends ConsumerState { FilterChip( label: Text(context.l10n.libraryFilterAll), selected: tempDateRange == null, - onSelected: (_) => setSheetState(() => tempDateRange = null), + onSelected: (_) => + setSheetState(() => tempDateRange = null), ), FilterChip( label: Text(context.l10n.libraryFilterDateToday), selected: tempDateRange == 'today', - onSelected: (_) => setSheetState(() => tempDateRange = 'today'), + onSelected: (_) => + setSheetState(() => tempDateRange = 'today'), ), FilterChip( label: Text(context.l10n.libraryFilterDateWeek), selected: tempDateRange == 'week', - onSelected: (_) => setSheetState(() => tempDateRange = 'week'), + onSelected: (_) => + setSheetState(() => tempDateRange = 'week'), ), FilterChip( label: Text(context.l10n.libraryFilterDateMonth), selected: tempDateRange == 'month', - onSelected: (_) => setSheetState(() => tempDateRange = 'month'), + onSelected: (_) => + setSheetState(() => tempDateRange = 'month'), ), FilterChip( label: Text(context.l10n.libraryFilterDateYear), selected: tempDateRange == 'year', - onSelected: (_) => setSheetState(() => tempDateRange = 'year'), + onSelected: (_) => + setSheetState(() => tempDateRange = 'year'), ), ], ), @@ -1020,9 +1057,11 @@ class _QueueTabState extends ConsumerState { await openFile(cleanPath); } catch (e) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarCannotOpenFile(e.toString())), + ), + ); } } } @@ -1056,7 +1095,7 @@ class _QueueTabState extends ConsumerState { ), ); -_precacheCover(historyItem.coverUrl); + _precacheCover(historyItem.coverUrl); _searchFocusNode.unfocus(); Navigator.push( context, @@ -1102,7 +1141,7 @@ _precacheCover(historyItem.coverUrl); ).then((_) => _searchFocusNode.unfocus()); } -List _filterHistoryItems( + List _filterHistoryItems( List items, String filterMode, Map albumCounts, [ @@ -1113,8 +1152,7 @@ List _filterHistoryItems( if (searchQuery.isNotEmpty) { final query = searchQuery; filteredItems = items.where((item) { - final searchKey = - _searchIndexCache[item.id] ?? _buildSearchKey(item); + final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item); if (!_searchIndexCache.containsKey(item.id)) { _searchIndexCache[item.id] = searchKey; } @@ -1125,7 +1163,7 @@ List _filterHistoryItems( // Then apply filter mode if (filterMode == 'all') return filteredItems; -switch (filterMode) { + switch (filterMode) { case 'albums': return filteredItems.where((item) { final key = @@ -1143,19 +1181,24 @@ switch (filterMode) { } } -_HistoryStats _buildHistoryStats(List items, [List localItems = const []]) { + _HistoryStats _buildHistoryStats( + List items, [ + List localItems = const [], + ]) { final albumCounts = {}; final albumMap = >{}; for (final item in items) { // Use lowercase key for case-insensitive grouping - final key = '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; + final key = + '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; albumCounts[key] = (albumCounts[key] ?? 0) + 1; albumMap.putIfAbsent(key, () => []).add(item); } int singleTracks = 0; for (final item in items) { - final key = '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; + final key = + '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; if ((albumCounts[key] ?? 0) <= 1) { singleTracks++; } @@ -1170,15 +1213,17 @@ _HistoryStats _buildHistoryStats(List items, [List t.downloadedAt) - .reduce((a, b) => a.isAfter(b) ? a : b), - )); + groupedAlbums.add( + _GroupedAlbum( + albumName: tracks.first.albumName, + artistName: tracks.first.albumArtist ?? tracks.first.artistName, + coverUrl: tracks.first.coverUrl, + tracks: tracks, + latestDownload: tracks + .map((t) => t.downloadedAt) + .reduce((a, b) => a.isAfter(b) ? a : b), + ), + ); }); groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload)); @@ -1192,7 +1237,8 @@ _HistoryStats _buildHistoryStats(List items, [List{}; final localAlbumMap = >{}; for (final item in localItems) { - final key = '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; + final key = + '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1; localAlbumMap.putIfAbsent(key, () => []).add(item); } @@ -1217,18 +1263,27 @@ _HistoryStats _buildHistoryStats(List items, [List t.coverPath != null && t.coverPath!.isNotEmpty, orElse: () => tracks.first).coverPath, - tracks: tracks, - latestScanned: tracks - .map((t) => t.scannedAt) - .reduce((a, b) => a.isAfter(b) ? a : b), - )); + groupedLocalAlbums.add( + _GroupedLocalAlbum( + albumName: tracks.first.albumName, + artistName: tracks.first.albumArtist ?? tracks.first.artistName, + coverPath: tracks + .firstWhere( + (t) => t.coverPath != null && t.coverPath!.isNotEmpty, + orElse: () => tracks.first, + ) + .coverPath, + tracks: tracks, + latestScanned: tracks + .map((t) => t.scannedAt) + .reduce((a, b) => a.isAfter(b) ? a : b), + ), + ); }); - groupedLocalAlbums.sort((a, b) => b.latestScanned.compareTo(a.latestScanned)); + groupedLocalAlbums.sort( + (a, b) => b.latestScanned.compareTo(a.latestScanned), + ); return _HistoryStats( albumCounts: albumCounts, @@ -1242,7 +1297,7 @@ _HistoryStats _buildHistoryStats(List items, [List s.items)); + final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items)); final allHistoryItems = ref.watch( downloadHistoryProvider.select((s) => s.items), ); // Watch local library items - final localLibraryEnabled = ref.watch(settingsProvider.select((s) => s.localLibraryEnabled)); + final localLibraryEnabled = ref.watch( + settingsProvider.select((s) => s.localLibraryEnabled), + ); final localLibraryItems = localLibraryEnabled ? ref.watch(localLibraryProvider.select((s) => s.items)) : const []; - + _ensureHistoryCaches(allHistoryItems, localLibraryItems); final historyViewMode = ref.watch( settingsProvider.select((s) => s.historyViewMode), @@ -1306,12 +1363,12 @@ final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items)); final topPadding = MediaQuery.of(context).padding.top; final historyStats = - _historyStatsCache ?? _buildHistoryStats(allHistoryItems, localLibraryItems); + _historyStatsCache ?? + _buildHistoryStats(allHistoryItems, localLibraryItems); final groupedAlbums = historyStats.groupedAlbums; final groupedLocalAlbums = historyStats.groupedLocalAlbums; final albumCount = historyStats.totalAlbumCount; final singleCount = historyStats.totalSingleTracks; - final hasSafRepairedItems = allHistoryItems.any((item) => item.safRepaired); final bottomPadding = MediaQuery.of(context).padding.bottom; @@ -1327,266 +1384,273 @@ final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items)); // ScrollConfiguration disables stretch overscroll to fix _StretchController exception // This is a known Flutter issue with NestedScrollView + Material 3 stretch indicator ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - overscroll: false, - ), + behavior: ScrollConfiguration.of( + context, + ).copyWith(overscroll: false), child: NestedScrollView( headerSliverBuilder: (context, innerBoxIsScrolled) => [ - SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - automaticallyImplyLeading: false, - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final maxHeight = 120 + topPadding; - final minHeight = kToolbarHeight + topPadding; - final expandRatio = - ((constraints.maxHeight - minHeight) / - (maxHeight - minHeight)) - .clamp(0.0, 1.0); + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + automaticallyImplyLeading: false, + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: const EdgeInsets.only(left: 24, bottom: 16), - title: Text( - context.l10n.navLibrary, - style: TextStyle( - fontSize: 20 + (14 * expandRatio), - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: const EdgeInsets.only( + left: 24, + bottom: 16, ), - ), - ); - }, - ), -), - - // Search bar - always at top - if (allHistoryItems.isNotEmpty || queueItems.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: GestureDetector( - onTap: () {}, - child: TextField( - controller: _searchController, - focusNode: _searchFocusNode, - autofocus: false, - canRequestFocus: true, - decoration: InputDecoration( - hintText: context.l10n.historySearchHint, - prefixIcon: const Icon(Icons.search), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - _clearSearch(); - FocusScope.of(context).unfocus(); - }, - ) - : null, - filled: true, - fillColor: colorScheme.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: colorScheme.outlineVariant, - width: 1, - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: colorScheme.outlineVariant, - width: 1.5, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: colorScheme.primary, - width: 2.5, - ), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, - ), - ), - onChanged: _onSearchChanged, - onTapOutside: (_) { - FocusScope.of(context).unfocus(); - }, - ), - ), - ), - ), - - if (queueItems.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), - child: Row( - children: [ - Text( - 'Downloading (${queueItems.length})', - style: Theme.of(context).textTheme.titleMedium?.copyWith( + title: Text( + context.l10n.navLibrary, + style: TextStyle( + fontSize: 20 + (14 * expandRatio), fontWeight: FontWeight.bold, + color: colorScheme.onSurface, ), ), -const Spacer(), - _buildExportFailedButton(context, ref, colorScheme), - const SizedBox(width: 4), - _buildPauseResumeButton(context, ref, colorScheme), - const SizedBox(width: 4), - _buildClearAllButton(context, ref, colorScheme), - ], - ), + ); + }, ), ), - if (queueItems.isNotEmpty) - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final item = queueItems[index]; - return KeyedSubtree( - key: ValueKey(item.id), - child: _buildQueueItem(context, item, colorScheme), - ); -}, childCount: queueItems.length), - ), + // Search bar - always at top + if (allHistoryItems.isNotEmpty || queueItems.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: GestureDetector( + onTap: () {}, + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + autofocus: false, + canRequestFocus: true, + decoration: InputDecoration( + hintText: context.l10n.historySearchHint, + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _clearSearch(); + FocusScope.of(context).unfocus(); + }, + ) + : null, + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: colorScheme.outlineVariant, + width: 1, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: colorScheme.outlineVariant, + width: 1.5, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: colorScheme.primary, + width: 2.5, + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + ), + onChanged: _onSearchChanged, + onTapOutside: (_) { + FocusScope.of(context).unfocus(); + }, + ), + ), + ), + ), - if (allHistoryItems.isNotEmpty || localLibraryItems.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, + if (queueItems.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Row( children: [ - _FilterChip( - label: context.l10n.historyFilterAll, - count: allHistoryItems.length + localLibraryItems.length, - isSelected: historyFilterMode == 'all', - onTap: () { - _animateToFilterPage(0); - }, - ), - const SizedBox(width: 8), - _FilterChip( - label: context.l10n.historyFilterAlbums, - count: albumCount, - isSelected: historyFilterMode == 'albums', - onTap: () { - _animateToFilterPage(1); - }, - ), - const SizedBox(width: 8), - _FilterChip( - label: context.l10n.historyFilterSingles, - count: singleCount, - isSelected: historyFilterMode == 'singles', - onTap: () { - _animateToFilterPage(2); - }, + Text( + 'Downloading (${queueItems.length})', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), ), + const Spacer(), + _buildExportFailedButton(context, ref, colorScheme), + const SizedBox(width: 4), + _buildPauseResumeButton(context, ref, colorScheme), + const SizedBox(width: 4), + _buildClearAllButton(context, ref, colorScheme), ], ), ), ), - ), - ], - body: NotificationListener( - onNotification: (notification) { - final parentController = widget.parentPageController; - if (parentController == null || !parentController.hasClients) { - return false; - } - final page = _filterPageController!.page?.round() ?? 0; + if (queueItems.isNotEmpty) + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final item = queueItems[index]; + return KeyedSubtree( + key: ValueKey(item.id), + child: _buildQueueItem(context, item, colorScheme), + ); + }, childCount: queueItems.length), + ), - if (notification is OverscrollNotification) { - final overscroll = notification.overscroll; - - if (page == 0 && overscroll < 0) { - final currentOffset = parentController.offset; - final targetOffset = (currentOffset + overscroll).clamp( - 0.0, - parentController.position.maxScrollExtent, - ); - parentController.jumpTo(targetOffset); - return true; + if (allHistoryItems.isNotEmpty || localLibraryItems.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _FilterChip( + label: context.l10n.historyFilterAll, + count: + allHistoryItems.length + + localLibraryItems.length, + isSelected: historyFilterMode == 'all', + onTap: () { + _animateToFilterPage(0); + }, + ), + const SizedBox(width: 8), + _FilterChip( + label: context.l10n.historyFilterAlbums, + count: albumCount, + isSelected: historyFilterMode == 'albums', + onTap: () { + _animateToFilterPage(1); + }, + ), + const SizedBox(width: 8), + _FilterChip( + label: context.l10n.historyFilterSingles, + count: singleCount, + isSelected: historyFilterMode == 'singles', + onTap: () { + _animateToFilterPage(2); + }, + ), + ], + ), + ), + ), + ), + ], + body: NotificationListener( + onNotification: (notification) { + final parentController = widget.parentPageController; + if (parentController == null || + !parentController.hasClients) { + return false; } - - if (page == 2 && overscroll > 0) { - final currentOffset = parentController.offset; - final targetOffset = (currentOffset + overscroll).clamp( - 0.0, - parentController.position.maxScrollExtent, - ); - parentController.jumpTo(targetOffset); - return true; - } - } - if (notification is ScrollEndNotification) { - if (page == 0 || page == 2) { - final currentPage = parentController.page ?? widget.parentPageIndex.toDouble(); - final historyPage = widget.parentPageIndex.toDouble(); - final offset = currentPage - historyPage; - - if (offset.abs() > 0.01) { - if (offset < -0.3) { - parentController.animateToPage( - widget.parentPageIndex - 1, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - ); - } else if (offset > 0.3) { - parentController.animateToPage( - widget.nextPageIndex ?? (widget.parentPageIndex + 1), - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - ); - } else { - parentController.jumpToPage(widget.parentPageIndex); + final page = _filterPageController!.page?.round() ?? 0; + + if (notification is OverscrollNotification) { + final overscroll = notification.overscroll; + + if (page == 0 && overscroll < 0) { + final currentOffset = parentController.offset; + final targetOffset = (currentOffset + overscroll).clamp( + 0.0, + parentController.position.maxScrollExtent, + ); + parentController.jumpTo(targetOffset); + return true; + } + + if (page == 2 && overscroll > 0) { + final currentOffset = parentController.offset; + final targetOffset = (currentOffset + overscroll).clamp( + 0.0, + parentController.position.maxScrollExtent, + ); + parentController.jumpTo(targetOffset); + return true; + } + } + + if (notification is ScrollEndNotification) { + if (page == 0 || page == 2) { + final currentPage = + parentController.page ?? + widget.parentPageIndex.toDouble(); + final historyPage = widget.parentPageIndex.toDouble(); + final offset = currentPage - historyPage; + + if (offset.abs() > 0.01) { + if (offset < -0.3) { + parentController.animateToPage( + widget.parentPageIndex - 1, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } else if (offset > 0.3) { + parentController.animateToPage( + widget.nextPageIndex ?? + (widget.parentPageIndex + 1), + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } else { + parentController.jumpToPage(widget.parentPageIndex); + } } } } - } - return false; - }, - child: PageView.builder( - controller: _filterPageController!, - physics: const ClampingScrollPhysics(), - onPageChanged: _onFilterPageChanged, - itemCount: _filterModes.length, - itemBuilder: (context, index) { - final filterMode = _filterModes[index]; - return _buildFilterContent( - context: context, - colorScheme: colorScheme, - filterMode: filterMode, - allHistoryItems: allHistoryItems, - historyViewMode: historyViewMode, - queueItems: queueItems, - groupedAlbums: groupedAlbums, - groupedLocalAlbums: groupedLocalAlbums, - albumCounts: historyStats.albumCounts, - localAlbumCounts: historyStats.localAlbumCounts, - localLibraryItems: localLibraryItems, - hasSafRepairedItems: hasSafRepairedItems, - ); + return false; }, + child: PageView.builder( + controller: _filterPageController!, + physics: const ClampingScrollPhysics(), + onPageChanged: _onFilterPageChanged, + itemCount: _filterModes.length, + itemBuilder: (context, index) { + final filterMode = _filterModes[index]; + return _buildFilterContent( + context: context, + colorScheme: colorScheme, + filterMode: filterMode, + allHistoryItems: allHistoryItems, + historyViewMode: historyViewMode, + queueItems: queueItems, + groupedAlbums: groupedAlbums, + groupedLocalAlbums: groupedLocalAlbums, + albumCounts: historyStats.albumCounts, + localAlbumCounts: historyStats.localAlbumCounts, + localLibraryItems: localLibraryItems, + ); + }, + ), ), ), - ), ), // ScrollConfiguration AnimatedPositioned( @@ -1595,7 +1659,7 @@ const Spacer(), left: 0, right: 0, bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), -child: _buildSelectionBottomBar( + child: _buildSelectionBottomBar( context, colorScheme, _buildUnifiedItemsForSelection( @@ -1664,10 +1728,12 @@ child: _buildSelectionBottomBar( if (filterMode == 'all') { localItemsForMerge = _filterLocalItems(localLibraryItems, query); } else { - final localSingles = localLibraryItems.where((item) { - final count = localAlbumCounts[item.albumKey] ?? 0; - return count == 1; - }).toList(growable: false); + final localSingles = localLibraryItems + .where((item) { + final count = localAlbumCounts[item.albumKey] ?? 0; + return count == 1; + }) + .toList(growable: false); localItemsForMerge = _filterLocalItems(localSingles, query); } @@ -1675,10 +1741,8 @@ child: _buildSelectionBottomBar( .map((item) => UnifiedLibraryItem.fromLocalLibrary(item)) .toList(growable: false); - final merged = [ - ...unifiedDownloaded, - ...unifiedLocal, - ]..sort((a, b) => b.addedAt.compareTo(a.addedAt)); + final merged = [...unifiedDownloaded, ...unifiedLocal] + ..sort((a, b) => b.addedAt.compareTo(a.addedAt)); _unifiedItemsCache[filterMode] = _UnifiedCacheEntry( historyItems: historyItems, @@ -1703,7 +1767,6 @@ child: _buildSelectionBottomBar( required Map albumCounts, required Map localAlbumCounts, required List localLibraryItems, - required bool hasSafRepairedItems, }) { final historyItems = _resolveHistoryItems( filterMode: filterMode, @@ -1720,18 +1783,19 @@ child: _buildSelectionBottomBar( final filteredGroupedAlbums = searchQuery.isEmpty ? groupedAlbums : groupedAlbums - .where((album) => album.searchKey.contains(searchQuery)) - .toList(); + .where((album) => album.searchKey.contains(searchQuery)) + .toList(); // Filter local library albums based on search query final filteredGroupedLocalAlbums = searchQuery.isEmpty ? groupedLocalAlbums : groupedLocalAlbums - .where((album) => album.searchKey.contains(searchQuery)) - .toList(); + .where((album) => album.searchKey.contains(searchQuery)) + .toList(); // Total album count for display - final totalAlbumCount = filteredGroupedAlbums.length + filteredGroupedLocalAlbums.length; + final totalAlbumCount = + filteredGroupedAlbums.length + filteredGroupedLocalAlbums.length; final unifiedItems = _getUnifiedItems( filterMode: filterMode, @@ -1748,9 +1812,7 @@ child: _buildSelectionBottomBar( return CustomScrollView( slivers: [ - if (totalTrackCount > 0 && - queueItems.isEmpty && - filterMode != 'albums') + if (totalTrackCount > 0 && queueItems.isEmpty && filterMode != 'albums') SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), @@ -1758,16 +1820,20 @@ child: _buildSelectionBottomBar( children: [ Text( '$totalTrackCount ${totalTrackCount == 1 ? 'track' : 'tracks'}', - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: colorScheme.onSurfaceVariant), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), const Spacer(), // Filter button with long-press to reset if (!_isSelectionMode) GestureDetector( - onLongPress: _activeFilterCount > 0 ? _resetFilters : null, + onLongPress: _activeFilterCount > 0 + ? _resetFilters + : null, child: TextButton.icon( - onPressed: () => _showFilterSheet(context, unifiedItems), + onPressed: () => + _showFilterSheet(context, unifiedItems), icon: Badge( isLabelVisible: _activeFilterCount > 0, label: Text('$_activeFilterCount'), @@ -1779,30 +1845,10 @@ child: _buildSelectionBottomBar( ), ), ), - if (!_isSelectionMode && hasSafRepairedItems) - IconButton( - onPressed: () { - setState(() { - _showSafRepairedBadge = !_showSafRepairedBadge; - }); - }, - icon: Icon( - _showSafRepairedBadge ? Icons.build : Icons.build_outlined, - size: 18, - ), - tooltip: _showSafRepairedBadge - ? 'Hide SAF repaired badge' - : 'Show SAF repaired badge', - style: IconButton.styleFrom( - backgroundColor: _showSafRepairedBadge - ? colorScheme.tertiaryContainer.withValues(alpha: 0.6) - : colorScheme.surfaceContainerHighest, - visualDensity: VisualDensity.compact, - ), - ), if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty) TextButton.icon( - onPressed: () => _enterSelectionMode(filteredUnifiedItems.first.id), + onPressed: () => + _enterSelectionMode(filteredUnifiedItems.first.id), icon: const Icon(Icons.checklist, size: 18), label: Text(context.l10n.actionSelect), style: TextButton.styleFrom( @@ -1814,7 +1860,8 @@ child: _buildSelectionBottomBar( ), ), - if ((filteredGroupedAlbums.isNotEmpty || filteredGroupedLocalAlbums.isNotEmpty) && + if ((filteredGroupedAlbums.isNotEmpty || + filteredGroupedLocalAlbums.isNotEmpty) && queueItems.isEmpty && filterMode == 'albums') SliverToBoxAdapter( @@ -1835,9 +1882,9 @@ child: _buildSelectionBottomBar( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( 'Downloaded', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), ), @@ -1869,34 +1916,44 @@ child: _buildSelectionBottomBar( ), // Combined albums grid (downloaded + local in single grid) - if (filterMode == 'albums' && (filteredGroupedAlbums.isNotEmpty || filteredGroupedLocalAlbums.isNotEmpty)) + if (filterMode == 'albums' && + (filteredGroupedAlbums.isNotEmpty || + filteredGroupedLocalAlbums.isNotEmpty)) SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), sliver: SliverGrid( - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 0.75, - ), - delegate: SliverChildBuilderDelegate((context, index) { - // First render downloaded albums, then local albums - if (index < filteredGroupedAlbums.length) { - final album = filteredGroupedAlbums[index]; - return KeyedSubtree( - key: ValueKey(album.key), - child: _buildAlbumGridItem(context, album, colorScheme), - ); - } else { - final localIndex = index - filteredGroupedAlbums.length; - final album = filteredGroupedLocalAlbums[localIndex]; - return KeyedSubtree( - key: ValueKey('local_${album.key}'), - child: _buildLocalAlbumGridItem(context, album, colorScheme), - ); - } - }, childCount: filteredGroupedAlbums.length + filteredGroupedLocalAlbums.length), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.75, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + // First render downloaded albums, then local albums + if (index < filteredGroupedAlbums.length) { + final album = filteredGroupedAlbums[index]; + return KeyedSubtree( + key: ValueKey(album.key), + child: _buildAlbumGridItem(context, album, colorScheme), + ); + } else { + final localIndex = index - filteredGroupedAlbums.length; + final album = filteredGroupedLocalAlbums[localIndex]; + return KeyedSubtree( + key: ValueKey('local_${album.key}'), + child: _buildLocalAlbumGridItem( + context, + album, + colorScheme, + ), + ); + } + }, + childCount: + filteredGroupedAlbums.length + + filteredGroupedLocalAlbums.length, + ), ), ), @@ -1913,10 +1970,7 @@ child: _buildSelectionBottomBar( crossAxisSpacing: 8, childAspectRatio: 0.75, ), - delegate: SliverChildBuilderDelegate(( - context, - index, - ) { + delegate: SliverChildBuilderDelegate((context, index) { final item = filteredUnifiedItems[index]; return KeyedSubtree( key: ValueKey(item.id), @@ -1956,10 +2010,7 @@ child: _buildSelectionBottomBar( crossAxisSpacing: 8, childAspectRatio: 0.75, ), - delegate: SliverChildBuilderDelegate(( - context, - index, - ) { + delegate: SliverChildBuilderDelegate((context, index) { final item = filteredUnifiedItems[index]; return KeyedSubtree( key: ValueKey(item.id), @@ -1994,11 +2045,7 @@ child: _buildSelectionBottomBar( !showFilteringIndicator) SliverFillRemaining( hasScrollBody: false, - child: _buildEmptyState( - context, - colorScheme, - filterMode, - ), + child: _buildEmptyState(context, colorScheme, filterMode), ) else SliverToBoxAdapter( @@ -2014,26 +2061,25 @@ child: _buildSelectionBottomBar( ColorScheme colorScheme, ) { final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused)); - + return TextButton.icon( onPressed: () { ref.read(downloadQueueProvider.notifier).togglePause(); }, - icon: Icon( - isPaused ? Icons.play_arrow : Icons.pause, - size: 18, - ), + icon: Icon(isPaused ? Icons.play_arrow : Icons.pause, size: 18), label: Text( isPaused ? context.l10n.actionResume : context.l10n.actionPause, ), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, - foregroundColor: isPaused ? colorScheme.primary : colorScheme.onSurfaceVariant, + foregroundColor: isPaused + ? colorScheme.primary + : colorScheme.onSurfaceVariant, ), ); } -Widget _buildClearAllButton( + Widget _buildClearAllButton( BuildContext context, WidgetRef ref, ColorScheme colorScheme, @@ -2076,10 +2122,12 @@ Widget _buildClearAllButton( BuildContext context, WidgetRef ref, ) async { - final filePath = await ref.read(downloadQueueProvider.notifier).exportFailedDownloads(); - + final filePath = await ref + .read(downloadQueueProvider.notifier) + .exportFailedDownloads(); + if (!context.mounted) return; - + if (filePath != null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -2120,9 +2168,7 @@ Widget _buildClearAllButton( ), FilledButton( onPressed: () => Navigator.pop(ctx, true), - style: FilledButton.styleFrom( - backgroundColor: colorScheme.error, - ), + style: FilledButton.styleFrom(backgroundColor: colorScheme.error), child: Text(context.l10n.dialogClear), ), ], @@ -2200,7 +2246,7 @@ Widget _buildClearAllButton( ClipRRect( borderRadius: BorderRadius.circular(12), child: album.coverUrl != null -? CachedNetworkImage( + ? CachedNetworkImage( imageUrl: album.coverUrl!, fit: BoxFit.cover, width: double.infinity, @@ -2263,7 +2309,7 @@ Widget _buildClearAllButton( overflow: TextOverflow.ellipsis, style: Theme.of( context, - ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600 ), + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), Text( album.artistName, @@ -2367,9 +2413,9 @@ Widget _buildClearAllButton( album.albumName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), Text( album.artistName, @@ -2480,7 +2526,9 @@ Widget _buildClearAllButton( SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: selectedCount > 0 ? () => _deleteSelected(unifiedItems) : null, + onPressed: selectedCount > 0 + ? () => _deleteSelected(unifiedItems) + : null, icon: const Icon(Icons.delete_outline), label: Text( selectedCount > 0 @@ -2609,7 +2657,7 @@ Widget _buildClearAllButton( return item.track.coverUrl != null ? ClipRRect( borderRadius: BorderRadius.circular(8), -child: CachedNetworkImage( + child: CachedNetworkImage( imageUrl: item.track.coverUrl!, width: 56, height: 56, @@ -2783,27 +2831,17 @@ child: CachedNetworkImage( // Local file cover (from library scan) if (item.localCoverPath != null && item.localCoverPath!.isNotEmpty) { - final coverFile = File(item.localCoverPath!); return ClipRRect( borderRadius: BorderRadius.circular(8), - child: FutureBuilder( - future: coverFile.exists(), - builder: (context, snapshot) { - if (snapshot.data == true) { - return Image.file( - coverFile, - width: size, - height: size, - fit: BoxFit.cover, - cacheWidth: (size * 2).toInt(), - cacheHeight: (size * 2).toInt(), - errorBuilder: (context, error, stackTrace) => _buildPlaceholderCover( - colorScheme, size, isDownloaded, - ), - ); - } - return _buildPlaceholderCover(colorScheme, size, isDownloaded); - }, + child: Image.file( + File(item.localCoverPath!), + width: size, + height: size, + fit: BoxFit.cover, + cacheWidth: (size * 2).toInt(), + cacheHeight: (size * 2).toInt(), + errorBuilder: (context, error, stackTrace) => + _buildPlaceholderCover(colorScheme, size, isDownloaded), ), ); } @@ -2813,7 +2851,11 @@ child: CachedNetworkImage( } /// Build placeholder cover image - Widget _buildPlaceholderCover(ColorScheme colorScheme, double size, bool isDownloaded) { + Widget _buildPlaceholderCover( + ColorScheme colorScheme, + double size, + bool isDownloaded, + ) { return Container( width: size, height: size, @@ -2852,11 +2894,19 @@ child: CachedNetworkImage( cacheManager: CoverCacheManager.instance, placeholder: (context, url) => Container( color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 32), + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + size: 32, + ), ), errorWidget: (context, url, error) => Container( color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 32), + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + size: 32, + ), ), ), ); @@ -2864,29 +2914,21 @@ child: CachedNetworkImage( // Local file cover (from library scan) if (item.localCoverPath != null && item.localCoverPath!.isNotEmpty) { - final coverFile = File(item.localCoverPath!); return ClipRRect( borderRadius: BorderRadius.circular(8), - child: FutureBuilder( - future: coverFile.exists(), - builder: (context, snapshot) { - if (snapshot.data == true) { - return Image.file( - coverFile, - fit: BoxFit.cover, - cacheWidth: 200, - cacheHeight: 200, - errorBuilder: (context, error, stackTrace) => Container( - color: colorScheme.secondaryContainer, - child: Icon(Icons.music_note, color: colorScheme.onSecondaryContainer, size: 32), - ), - ); - } - return Container( - color: colorScheme.secondaryContainer, - child: Icon(Icons.music_note, color: colorScheme.onSecondaryContainer, size: 32), - ); - }, + child: Image.file( + File(item.localCoverPath!), + fit: BoxFit.cover, + cacheWidth: 200, + cacheHeight: 200, + errorBuilder: (context, error, stackTrace) => Container( + color: colorScheme.secondaryContainer, + child: Icon( + Icons.music_note, + color: colorScheme.onSecondaryContainer, + size: 32, + ), + ), ), ); } @@ -2931,10 +2973,6 @@ child: CachedNetworkImage( final sourceTextColor = isDownloaded ? colorScheme.onPrimaryContainer : colorScheme.onSecondaryContainer; - final showSafRepaired = _showSafRepairedBadge && - isDownloaded && - item.historyItem != null && - item.historyItem!.safRepaired; return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), @@ -2945,10 +2983,10 @@ child: CachedNetworkImage( onTap: _isSelectionMode ? () => _toggleSelection(item.id) : isDownloaded - ? () => _navigateToHistoryMetadataScreen(item.historyItem!) - : item.localItem != null - ? () => _navigateToLocalMetadataScreen(item.localItem!) - : () => _openFile(item.filePath), + ? () => _navigateToHistoryMetadataScreen(item.historyItem!) + : item.localItem != null + ? () => _navigateToLocalMetadataScreen(item.localItem!) + : () => _openFile(item.filePath), onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id), @@ -3031,38 +3069,6 @@ child: CachedNetworkImage( ), ), ), - if (showSafRepaired) ...[ - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.build, - size: 10, - color: colorScheme.onTertiaryContainer, - ), - const SizedBox(width: 4), - Text( - 'SAF repaired', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onTertiaryContainer, - fontSize: 10, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ], const SizedBox(width: 8), Text( dateStr, @@ -3148,22 +3154,16 @@ child: CachedNetworkImage( final fileExists = _checkFileExists(item.filePath); final isSelected = _selectedIds.contains(item.id); final isDownloaded = item.source == LibraryItemSource.downloaded; - final showSafRepaired = _showSafRepairedBadge && - isDownloaded && - item.historyItem != null && - item.historyItem!.safRepaired; return GestureDetector( onTap: _isSelectionMode ? () => _toggleSelection(item.id) : isDownloaded - ? () => _navigateToHistoryMetadataScreen(item.historyItem!) - : item.localItem != null - ? () => _navigateToLocalMetadataScreen(item.localItem!) - : () => _openFile(item.filePath), - onLongPress: _isSelectionMode - ? null - : () => _enterSelectionMode(item.id), + ? () => _navigateToHistoryMetadataScreen(item.historyItem!) + : item.localItem != null + ? () => _navigateToLocalMetadataScreen(item.localItem!) + : () => _openFile(item.filePath), + onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id), child: Stack( children: [ Column( @@ -3228,23 +3228,6 @@ child: CachedNetworkImage( ), ), ), - if (showSafRepaired) - Positioned( - left: 4, - bottom: 4, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - Icons.build, - size: 12, - color: colorScheme.onTertiaryContainer, - ), - ), - ), if (fileExists && !_isSelectionMode) Positioned( right: 4, @@ -3338,7 +3321,6 @@ child: CachedNetworkImage( ), ); } - } class _FilterChip extends StatelessWidget { diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart deleted file mode 100644 index 7b0e3fdf..00000000 --- a/lib/screens/settings_screen.dart +++ /dev/null @@ -1,540 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:spotiflac_android/constants/app_info.dart'; -import 'package:spotiflac_android/providers/settings_provider.dart'; -import 'package:spotiflac_android/providers/theme_provider.dart'; - -class SettingsScreen extends ConsumerWidget { - const SettingsScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final settings = ref.watch(settingsProvider); - final themeSettings = ref.watch(themeProvider); - final colorScheme = Theme.of(context).colorScheme; - - return Scaffold( - appBar: AppBar(title: const Text('Settings')), - body: ListView( - children: [ - // Theme Section - _buildSectionHeader(context, 'Appearance', colorScheme), - - // Theme Mode - ListTile( - leading: Icon(Icons.brightness_6, color: colorScheme.primary), - title: const Text('Theme Mode'), - subtitle: Text(_getThemeModeName(themeSettings.themeMode)), - onTap: () => _showThemeModePicker(context, ref, themeSettings.themeMode), - ), - - // Dynamic Color Toggle - SwitchListTile( - secondary: Icon(Icons.palette, color: colorScheme.primary), - title: const Text('Dynamic Color'), - subtitle: const Text('Use colors from your wallpaper'), - value: themeSettings.useDynamicColor, - onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value), - ), - - // Seed Color Picker (only when dynamic color is disabled) - if (!themeSettings.useDynamicColor) - ListTile( - leading: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: Color(themeSettings.seedColorValue), - shape: BoxShape.circle, - border: Border.all(color: colorScheme.outline), - ), - ), - title: const Text('Accent Color'), - subtitle: const Text('Choose your preferred color'), - onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue), - ), - - const Divider(), - - // Download Section - _buildSectionHeader(context, 'Download', colorScheme), - - // Download Service - ListTile( - leading: Icon(Icons.cloud_download, color: colorScheme.primary), - title: const Text('Default Service'), - subtitle: Text(_getServiceName(settings.defaultService)), - onTap: () => _showServicePicker(context, ref, settings.defaultService), - ), - - // Audio Quality - ListTile( - leading: Icon(Icons.high_quality, color: colorScheme.primary), - title: const Text('Audio Quality'), - subtitle: Text(_getQualityName(settings.audioQuality)), - onTap: () => _showQualityPicker(context, ref, settings.audioQuality), - ), - - // Filename Format - ListTile( - leading: Icon(Icons.text_fields, color: colorScheme.primary), - title: const Text('Filename Format'), - subtitle: Text(settings.filenameFormat), - onTap: () => _showFormatEditor(context, ref, settings.filenameFormat), - ), - - // Download Directory - ListTile( - leading: Icon(Icons.folder, color: colorScheme.primary), - title: const Text('Download Directory'), - subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory), - onTap: () => _pickDirectory(context, ref), - ), - - const Divider(), - - // Options Section - _buildSectionHeader(context, 'Options', colorScheme), - - // Auto Fallback - SwitchListTile( - secondary: Icon(Icons.sync, color: colorScheme.primary), - title: const Text('Auto Fallback'), - subtitle: const Text('Try other services if download fails'), - value: settings.autoFallback, - onChanged: (value) => ref.read(settingsProvider.notifier).setAutoFallback(value), - ), - - // Embed Lyrics - SwitchListTile( - secondary: Icon(Icons.lyrics, color: colorScheme.primary), - title: const Text('Embed Lyrics'), - subtitle: const Text('Embed synced lyrics into FLAC files'), - value: settings.embedLyrics, - onChanged: (value) => ref.read(settingsProvider.notifier).setEmbedLyrics(value), - ), - - // Max Quality Cover - SwitchListTile( - secondary: Icon(Icons.image, color: colorScheme.primary), - title: const Text('Max Quality Cover'), - subtitle: const Text('Download highest resolution cover art'), - value: settings.maxQualityCover, - onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value), - ), - - // Concurrent Downloads - ListTile( - leading: Icon(Icons.download_for_offline, color: colorScheme.primary), - title: const Text('Concurrent Downloads'), - subtitle: Text(settings.concurrentDownloads == 1 - ? 'Sequential (1 at a time)' - : '${settings.concurrentDownloads} parallel downloads'), - onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads), - ), - - // Check for Updates - SwitchListTile( - secondary: Icon(Icons.system_update, color: colorScheme.primary), - title: const Text('Check for Updates'), - subtitle: const Text('Notify when new version is available'), - value: settings.checkForUpdates, - onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value), - ), - - const Divider(), - - // GitHub & Credits Section - _buildSectionHeader(context, 'GitHub & Credits', colorScheme), - - ListTile( - leading: Icon(Icons.code, color: colorScheme.primary), - title: Text('${AppInfo.appName} Mobile'), - subtitle: Text('github.com/${AppInfo.githubRepo}'), - onTap: () => _launchUrl(AppInfo.githubUrl), - ), - - ListTile( - leading: Icon(Icons.computer, color: colorScheme.primary), - title: Text('Original ${AppInfo.appName} (Desktop)'), - subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'), - onTap: () => _launchUrl(AppInfo.originalGithubUrl), - ), - - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Text( - 'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - - const Divider(), - - // About - ListTile( - leading: Icon(Icons.info, color: colorScheme.primary), - title: const Text('About'), - subtitle: Text('${AppInfo.appName} v${AppInfo.version}'), - onTap: () => _showAboutDialog(context), - ), - ], - ), - ); - } - - void _showAboutDialog(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Row( - children: [ - Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)), - const SizedBox(width: 12), - Text(AppInfo.appName), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildAboutRow('Version', AppInfo.version, colorScheme), - const SizedBox(height: 8), - _buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme), - const SizedBox(height: 8), - _buildAboutRow('Original', AppInfo.originalAuthor, colorScheme), - const SizedBox(height: 16), - Text( - AppInfo.copyright, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ), - ], - ), - ); - } - - Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)), - Text(value, style: const TextStyle(fontWeight: FontWeight.w500)), - ], - ); - } - - Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - ); - } - - String _getThemeModeName(ThemeMode mode) { - switch (mode) { - case ThemeMode.light: return 'Light'; - case ThemeMode.dark: return 'Dark'; - case ThemeMode.system: return 'System'; - } - } - - String _getServiceName(String service) { - switch (service) { - case 'tidal': return 'Tidal'; - case 'qobuz': return 'Qobuz'; - case 'amazon': return 'Amazon Music'; - default: return service; - } - } - - String _getQualityName(String quality) { - switch (quality) { - case 'LOSSLESS': return 'FLAC (Lossless)'; - case 'HI_RES': return 'Hi-Res FLAC (24-bit)'; - default: return quality; - } - } - - void _showThemeModePicker(BuildContext context, WidgetRef ref, ThemeMode current) { - final colorScheme = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Theme Mode'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildThemeModeOption(context, ref, ThemeMode.system, 'System', Icons.brightness_auto, current, colorScheme), - _buildThemeModeOption(context, ref, ThemeMode.light, 'Light', Icons.light_mode, current, colorScheme), - _buildThemeModeOption(context, ref, ThemeMode.dark, 'Dark', Icons.dark_mode, current, colorScheme), - ], - ), - ), - ); - } - - Widget _buildThemeModeOption(BuildContext context, WidgetRef ref, ThemeMode mode, String label, IconData icon, ThemeMode current, ColorScheme colorScheme) { - final isSelected = mode == current; - return ListTile( - leading: Icon(icon, color: isSelected ? colorScheme.primary : null), - title: Text(label), - trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null, - onTap: () { - ref.read(themeProvider.notifier).setThemeMode(mode); - Navigator.pop(context); - }, - ); - } - - void _showColorPicker(BuildContext context, WidgetRef ref, int currentColor) { - final colors = [ - const Color(0xFF1DB954), // Spotify Green - const Color(0xFF6750A4), // Purple - const Color(0xFF0061A4), // Blue - const Color(0xFF006E1C), // Green - const Color(0xFFBA1A1A), // Red - const Color(0xFF984061), // Pink - const Color(0xFF7D5260), // Brown - const Color(0xFF006874), // Teal - const Color(0xFFFF6F00), // Orange - ]; - - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Choose Accent Color'), - content: Wrap( - spacing: 12, - runSpacing: 12, - children: colors.map((color) { - final isSelected = color.toARGB32() == currentColor; - return GestureDetector( - onTap: () { - ref.read(themeProvider.notifier).setSeedColor(color); - Navigator.pop(context); - }, - child: Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: isSelected - ? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3) - : null, - ), - child: isSelected - ? const Icon(Icons.check, color: Colors.white) - : null, - ), - ); - }).toList(), - ), - ), - ); - } - - void _showServicePicker(BuildContext context, WidgetRef ref, String current) { - final colorScheme = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Select Service'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildServiceOption(context, ref, 'tidal', 'Tidal', current, colorScheme), - _buildServiceOption(context, ref, 'qobuz', 'Qobuz', current, colorScheme), - _buildServiceOption(context, ref, 'amazon', 'Amazon Music', current, colorScheme), - ], - ), - ), - ); - } - - Widget _buildServiceOption(BuildContext context, WidgetRef ref, String value, String label, String current, ColorScheme colorScheme) { - final isSelected = value == current; - return ListTile( - title: Text(label), - trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null, - onTap: () { - ref.read(settingsProvider.notifier).setDefaultService(value); - Navigator.pop(context); - }, - ); - } - - void _showQualityPicker(BuildContext context, WidgetRef ref, String current) { - final colorScheme = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Select Quality'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Disclaimer - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text( - 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ), - _buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme), - _buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme), - ], - ), - ), - ); - } - - Widget _buildQualityOption(BuildContext context, WidgetRef ref, String value, String title, String subtitle, String current, ColorScheme colorScheme) { - final isSelected = value == current; - return ListTile( - title: Text(title), - subtitle: Text(subtitle), - trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null, - onTap: () { - ref.read(settingsProvider.notifier).setAudioQuality(value); - Navigator.pop(context); - }, - ); - } - - void _showFormatEditor(BuildContext context, WidgetRef ref, String current) { - final controller = TextEditingController(text: current); - final colorScheme = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Filename Format'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - controller: controller, - decoration: const InputDecoration( - hintText: '{artist} - {title}', - ), - ), - const SizedBox(height: 16), - Text( - 'Available placeholders:', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 4), - Text( - '{title}, {artist}, {album}, {track}, {year}, {disc}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - ref.read(settingsProvider.notifier).setFilenameFormat(controller.text); - Navigator.pop(context); - }, - child: const Text('Save'), - ), - ], - ), - ); - } - - Future _pickDirectory(BuildContext context, WidgetRef ref) async { - final result = await FilePicker.platform.getDirectoryPath(); - if (result != null) { - ref.read(settingsProvider.notifier).setDownloadDirectory(result); - } - } - - void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) { - final colorScheme = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Concurrent Downloads'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme), - _buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme), - _buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme), - const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Parallel downloads may trigger rate limiting from streaming services.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.error, - ), - ), - ), - ], - ), - ], - ), - ), - ); - } - - Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) { - final isSelected = value == current; - return ListTile( - title: Text(title), - subtitle: Text(subtitle), - trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null, - onTap: () { - ref.read(settingsProvider.notifier).setConcurrentDownloads(value); - Navigator.pop(context); - }, - ); - } - - Future _launchUrl(String url) async { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } -} diff --git a/lib/screens/settings_tab.dart b/lib/screens/settings_tab.dart deleted file mode 100644 index 29c41236..00000000 --- a/lib/screens/settings_tab.dart +++ /dev/null @@ -1,520 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:spotiflac_android/constants/app_info.dart'; -import 'package:spotiflac_android/providers/settings_provider.dart'; -import 'package:spotiflac_android/providers/theme_provider.dart'; - -class SettingsTab extends ConsumerStatefulWidget { - const SettingsTab({super.key}); - - @override - ConsumerState createState() => _SettingsTabState(); -} - -class _SettingsTabState extends ConsumerState with AutomaticKeepAliveClientMixin { - @override - bool get wantKeepAlive => true; - - @override - Widget build(BuildContext context) { - super.build(context); - final settings = ref.watch(settingsProvider); - final themeSettings = ref.watch(themeProvider); - final colorScheme = Theme.of(context).colorScheme; - - return ListView( - children: [ - // Theme Section - _buildSectionHeader(context, 'Appearance', colorScheme), - - // Theme Mode - ListTile( - leading: Icon(Icons.brightness_6, color: colorScheme.primary), - title: const Text('Theme Mode'), - subtitle: Text(_getThemeModeName(themeSettings.themeMode)), - onTap: () => _showThemeModePicker(context, ref, themeSettings.themeMode), - ), - - // Dynamic Color Toggle - SwitchListTile( - secondary: Icon(Icons.palette, color: colorScheme.primary), - title: const Text('Dynamic Color'), - subtitle: const Text('Use colors from your wallpaper'), - value: themeSettings.useDynamicColor, - onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value), - ), - - // Seed Color Picker (only when dynamic color is disabled) - if (!themeSettings.useDynamicColor) - ListTile( - leading: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: Color(themeSettings.seedColorValue), - shape: BoxShape.circle, - border: Border.all(color: colorScheme.outline), - ), - ), - title: const Text('Accent Color'), - subtitle: const Text('Choose your preferred color'), - onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue), - ), - - const Divider(), - - // Download Section - _buildSectionHeader(context, 'Download', colorScheme), - - // Download Service - ListTile( - leading: Icon(Icons.cloud_download, color: colorScheme.primary), - title: const Text('Default Service'), - subtitle: Text(_getServiceName(settings.defaultService)), - onTap: () => _showServicePicker(context, ref, settings.defaultService), - ), - - // Audio Quality - ListTile( - leading: Icon(Icons.high_quality, color: colorScheme.primary), - title: const Text('Audio Quality'), - subtitle: Text(_getQualityName(settings.audioQuality)), - onTap: () => _showQualityPicker(context, ref, settings.audioQuality), - ), - - // Filename Format - ListTile( - leading: Icon(Icons.text_fields, color: colorScheme.primary), - title: const Text('Filename Format'), - subtitle: Text(settings.filenameFormat), - onTap: () => _showFormatEditor(context, ref, settings.filenameFormat), - ), - - // Download Directory - ListTile( - leading: Icon(Icons.folder, color: colorScheme.primary), - title: const Text('Download Directory'), - subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory), - onTap: () => _pickDirectory(context, ref), - ), - - const Divider(), - - // Options Section - _buildSectionHeader(context, 'Options', colorScheme), - - // Auto Fallback - SwitchListTile( - secondary: Icon(Icons.sync, color: colorScheme.primary), - title: const Text('Auto Fallback'), - subtitle: const Text('Try other services if download fails'), - value: settings.autoFallback, - onChanged: (value) => ref.read(settingsProvider.notifier).setAutoFallback(value), - ), - - // Embed Lyrics - SwitchListTile( - secondary: Icon(Icons.lyrics, color: colorScheme.primary), - title: const Text('Embed Lyrics'), - subtitle: const Text('Embed synced lyrics into FLAC files'), - value: settings.embedLyrics, - onChanged: (value) => ref.read(settingsProvider.notifier).setEmbedLyrics(value), - ), - - // Max Quality Cover - SwitchListTile( - secondary: Icon(Icons.image, color: colorScheme.primary), - title: const Text('Max Quality Cover'), - subtitle: const Text('Download highest resolution cover art'), - value: settings.maxQualityCover, - onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value), - ), - - // Concurrent Downloads - ListTile( - leading: Icon(Icons.download_for_offline, color: colorScheme.primary), - title: const Text('Concurrent Downloads'), - subtitle: Text(settings.concurrentDownloads == 1 - ? 'Sequential (1 at a time)' - : '${settings.concurrentDownloads} parallel downloads'), - onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads), - ), - - // Check for Updates - SwitchListTile( - secondary: Icon(Icons.system_update, color: colorScheme.primary), - title: const Text('Check for Updates'), - subtitle: const Text('Notify when new version is available'), - value: settings.checkForUpdates, - onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value), - ), - - const Divider(), - - // GitHub & Credits Section - _buildSectionHeader(context, 'GitHub & Credits', colorScheme), - - ListTile( - leading: Icon(Icons.code, color: colorScheme.primary), - title: Text('${AppInfo.appName} Mobile'), - subtitle: Text('github.com/${AppInfo.githubRepo}'), - onTap: () => _launchUrl(AppInfo.githubUrl), - ), - - ListTile( - leading: Icon(Icons.computer, color: colorScheme.primary), - title: Text('Original ${AppInfo.appName} (Desktop)'), - subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'), - onTap: () => _launchUrl(AppInfo.originalGithubUrl), - ), - - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Text( - 'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - - const Divider(), - - // About - ListTile( - leading: Icon(Icons.info, color: colorScheme.primary), - title: const Text('About'), - subtitle: Text('${AppInfo.appName} v${AppInfo.version}'), - onTap: () => _showAboutDialog(context), - ), - - // Bottom padding for navigation bar - const SizedBox(height: 16), - ], - ); - } - - void _showAboutDialog(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Row( - children: [ - Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)), - const SizedBox(width: 12), - Text(AppInfo.appName), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildAboutRow('Version', AppInfo.version, colorScheme), - const SizedBox(height: 8), - _buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme), - const SizedBox(height: 8), - _buildAboutRow('Original', AppInfo.originalAuthor, colorScheme), - const SizedBox(height: 16), - Text( - AppInfo.copyright, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ), - ], - ), - ); - } - - Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)), - Text(value, style: const TextStyle(fontWeight: FontWeight.w500)), - ], - ); - } - - Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - ); - } - - String _getThemeModeName(ThemeMode mode) { - switch (mode) { - case ThemeMode.light: return 'Light'; - case ThemeMode.dark: return 'Dark'; - case ThemeMode.system: return 'System'; - } - } - - String _getServiceName(String service) { - switch (service) { - case 'tidal': return 'Tidal'; - case 'qobuz': return 'Qobuz'; - case 'amazon': return 'Amazon Music'; - default: return service; - } - } - - String _getQualityName(String quality) { - switch (quality) { - case 'LOSSLESS': return 'FLAC (16-bit / 44.1kHz)'; - case 'HI_RES': return 'Hi-Res FLAC (24-bit / 96kHz)'; - case 'HI_RES_LOSSLESS': return 'Hi-Res FLAC (24-bit / 192kHz)'; - default: return quality; - } - } - - void _showThemeModePicker(BuildContext context, WidgetRef ref, ThemeMode current) { - final colorScheme = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Theme Mode'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildThemeModeOption(context, ref, ThemeMode.system, 'System', Icons.brightness_auto, current, colorScheme), - _buildThemeModeOption(context, ref, ThemeMode.light, 'Light', Icons.light_mode, current, colorScheme), - _buildThemeModeOption(context, ref, ThemeMode.dark, 'Dark', Icons.dark_mode, current, colorScheme), - ], - ), - ), - ); - } - - Widget _buildThemeModeOption(BuildContext context, WidgetRef ref, ThemeMode mode, String label, IconData icon, ThemeMode current, ColorScheme colorScheme) { - final isSelected = mode == current; - return ListTile( - leading: Icon(icon, color: isSelected ? colorScheme.primary : null), - title: Text(label), - trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null, - onTap: () { - ref.read(themeProvider.notifier).setThemeMode(mode); - Navigator.pop(context); - }, - ); - } - - void _showColorPicker(BuildContext context, WidgetRef ref, int currentColor) { - final colors = [ - const Color(0xFF1DB954), const Color(0xFF6750A4), const Color(0xFF0061A4), - const Color(0xFF006E1C), const Color(0xFFBA1A1A), const Color(0xFF984061), - const Color(0xFF7D5260), const Color(0xFF006874), const Color(0xFFFF6F00), - ]; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Choose Accent Color'), - content: Wrap( - spacing: 12, - runSpacing: 12, - children: colors.map((color) { - final isSelected = color.toARGB32() == currentColor; - return GestureDetector( - onTap: () { - ref.read(themeProvider.notifier).setSeedColor(color); - Navigator.pop(context); - }, - child: Container( - width: 48, height: 48, - decoration: BoxDecoration( - color: color, shape: BoxShape.circle, - border: isSelected ? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3) : null, - ), - child: isSelected ? const Icon(Icons.check, color: Colors.white) : null, - ), - ); - }).toList(), - ), - ), - ); - } - - void _showServicePicker(BuildContext context, WidgetRef ref, String current) { - final colorScheme = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Select Service'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildServiceOption(context, ref, 'tidal', 'Tidal', current, colorScheme), - _buildServiceOption(context, ref, 'qobuz', 'Qobuz', current, colorScheme), - _buildServiceOption(context, ref, 'amazon', 'Amazon Music', current, colorScheme), - ], - ), - ), - ); - } - - Widget _buildServiceOption(BuildContext context, WidgetRef ref, String value, String label, String current, ColorScheme colorScheme) { - final isSelected = value == current; - return ListTile( - title: Text(label), - trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null, - onTap: () { - ref.read(settingsProvider.notifier).setDefaultService(value); - Navigator.pop(context); - }, - ); - } - - void _showQualityPicker(BuildContext context, WidgetRef ref, String current) { - final colorScheme = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Select Quality'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Disclaimer - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text( - 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ), - _buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme), - _buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 96kHz', current, colorScheme), - _buildQualityOption(context, ref, 'HI_RES_LOSSLESS', 'Hi-Res FLAC Max', '24-bit / up to 192kHz', current, colorScheme), - ], - ), - ), - ); - } - - Widget _buildQualityOption(BuildContext context, WidgetRef ref, String value, String title, String subtitle, String current, ColorScheme colorScheme) { - final isSelected = value == current; - return ListTile( - title: Text(title), - subtitle: Text(subtitle), - trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null, - onTap: () { - ref.read(settingsProvider.notifier).setAudioQuality(value); - Navigator.pop(context); - }, - ); - } - - void _showFormatEditor(BuildContext context, WidgetRef ref, String current) { - final controller = TextEditingController(text: current); - final colorScheme = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Filename Format'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField(controller: controller, decoration: const InputDecoration(hintText: '{artist} - {title}')), - const SizedBox(height: 16), - Text('Available placeholders:', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: 4), - Text('{title}, {artist}, {album}, {track}, {year}, {disc}', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), - ], - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), - FilledButton( - onPressed: () { - ref.read(settingsProvider.notifier).setFilenameFormat(controller.text); - Navigator.pop(context); - }, - child: const Text('Save'), - ), - ], - ), - ); - } - - Future _pickDirectory(BuildContext context, WidgetRef ref) async { - final result = await FilePicker.platform.getDirectoryPath(); - if (result != null) { - ref.read(settingsProvider.notifier).setDownloadDirectory(result); - } - } - - void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) { - final colorScheme = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Concurrent Downloads'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme), - _buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme), - _buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme), - const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Parallel downloads may trigger rate limiting from streaming services.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.error, - ), - ), - ), - ], - ), - ], - ), - ), - ); - } - - Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) { - final isSelected = value == current; - return ListTile( - title: Text(title), - subtitle: Text(subtitle), - trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null, - onTap: () { - ref.read(settingsProvider.notifier).setConcurrentDownloads(value); - Navigator.pop(context); - }, - ); - } - - Future _launchUrl(String url) async { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } -} diff --git a/lib/services/csv_import_service.dart b/lib/services/csv_import_service.dart index 49a4744a..5d77fa4e 100644 --- a/lib/services/csv_import_service.dart +++ b/lib/services/csv_import_service.dart @@ -21,7 +21,7 @@ class CsvImportService { final file = File(result.files.single.path!); final content = await file.readAsString(); final tracks = _parseCsv(content); - + if (tracks.isNotEmpty) { return await _enrichTracksMetadata(tracks, onProgress: onProgress); } @@ -39,43 +39,50 @@ class CsvImportService { }) async { _log.i('Enriching metadata for ${tracks.length} tracks from Deezer...'); final enrichedTracks = []; - + for (int i = 0; i < tracks.length; i++) { final track = tracks[i]; onProgress?.call(i + 1, tracks.length); - + if (track.coverUrl == null || track.duration == 0) { Map? trackData; - + if (track.isrc != null && track.isrc!.isNotEmpty) { try { trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!); _log.d('ISRC enrichment success for ${track.name}'); } catch (e) { - _log.w('ISRC search failed for ${track.name}, trying text search...'); + _log.w( + 'ISRC search failed for ${track.name}, trying text search...', + ); } } - + if (trackData == null) { try { final query = '${track.artistName} ${track.name}'; - final searchResult = await PlatformBridge.searchDeezerAll(query, trackLimit: 5); - + final searchResult = await PlatformBridge.searchDeezerAll( + query, + trackLimit: 5, + ); + if (searchResult.containsKey('tracks')) { final tracksList = searchResult['tracks'] as List?; if (tracksList != null && tracksList.isNotEmpty) { for (final result in tracksList) { final resultMap = result as Map; - final resultName = (resultMap['name'] as String?)?.toLowerCase() ?? ''; + final resultName = + (resultMap['name'] as String?)?.toLowerCase() ?? ''; final trackNameLower = track.name.toLowerCase(); - - if (resultName.contains(trackNameLower) || trackNameLower.contains(resultName)) { + + if (resultName.contains(trackNameLower) || + trackNameLower.contains(resultName)) { trackData = resultMap; _log.d('Text search match for ${track.name}: $resultName'); break; } } - + if (trackData == null && tracksList.isNotEmpty) { trackData = tracksList.first as Map; _log.d('Using first search result for ${track.name}'); @@ -86,38 +93,44 @@ class CsvImportService { _log.w('Text search also failed for ${track.name}: $e'); } } - + if (trackData != null) { final coverUrl = trackData['images'] as String?; final durationMs = trackData['duration_ms'] as int? ?? 0; final deezerIdRaw = trackData['spotify_id'] as String?; - - enrichedTracks.add(Track( - id: deezerIdRaw ?? track.id, - name: trackData['name'] as String? ?? track.name, - artistName: trackData['artists'] as String? ?? track.artistName, - albumName: trackData['album_name'] as String? ?? track.albumName, - albumArtist: trackData['album_artist'] as String?, - coverUrl: coverUrl ?? track.coverUrl, - isrc: trackData['isrc'] as String? ?? track.isrc, - duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration, - trackNumber: trackData['track_number'] as int? ?? track.trackNumber, - discNumber: trackData['disc_number'] as int? ?? track.discNumber, - releaseDate: trackData['release_date'] as String? ?? track.releaseDate, - )); - - _log.d('Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s'); - + + enrichedTracks.add( + Track( + id: deezerIdRaw ?? track.id, + name: trackData['name'] as String? ?? track.name, + artistName: trackData['artists'] as String? ?? track.artistName, + albumName: trackData['album_name'] as String? ?? track.albumName, + albumArtist: trackData['album_artist'] as String?, + coverUrl: coverUrl ?? track.coverUrl, + isrc: trackData['isrc'] as String? ?? track.isrc, + duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration, + trackNumber: + trackData['track_number'] as int? ?? track.trackNumber, + discNumber: trackData['disc_number'] as int? ?? track.discNumber, + releaseDate: + trackData['release_date'] as String? ?? track.releaseDate, + ), + ); + + _log.d( + 'Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s', + ); + if (i < tracks.length - 1) { await Future.delayed(const Duration(milliseconds: 100)); } continue; } } - + enrichedTracks.add(track); } - + _log.i('Enrichment complete: ${enrichedTracks.length} tracks'); return enrichedTracks; } @@ -136,8 +149,8 @@ class CsvImportService { final headers = _parseLine(lines[startIdx]); final colMap = {}; for (int i = 0; i < headers.length; i++) { - String h = _cleanValue(headers[i]).toLowerCase(); - colMap[h] = i; + String h = _cleanValue(headers[i]).toLowerCase(); + colMap[h] = i; } _log.d('CSV Headers: ${colMap.keys.toList()}'); @@ -147,48 +160,67 @@ class CsvImportService { if (line.isEmpty) continue; final values = _parseLine(line); - + String? getVal(List keys) { return _getValue(values, colMap, keys); } String? trackName = getVal(['track name', 'track', 'name', 'title']); - String? artistName = getVal(['artist name(s)', 'artist name', 'artist', 'artists']); + String? artistName = getVal([ + 'artist name(s)', + 'artist name', + 'artist', + 'artists', + ]); String? albumName = getVal(['album name', 'album']); String? isrc = getVal(['isrc']); - String? spotifyId = getVal(['track uri', 'spotify - id', 'spotify id', 'spotify_id', 'id', 'uri']); + String? spotifyId = getVal([ + 'track uri', + 'spotify - id', + 'spotify id', + 'spotify_id', + 'id', + 'uri', + ]); if (spotifyId != null && spotifyId.startsWith('spotify:track:')) { spotifyId = spotifyId.replaceAll('spotify:track:', ''); } - if ((trackName != null && trackName.isNotEmpty && artistName != null) || (spotifyId != null && spotifyId.isNotEmpty)) { - tracks.add(Track( - id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i', - name: trackName ?? 'Unknown Track', - artistName: artistName ?? 'Unknown Artist', - albumName: albumName ?? 'Unknown Album', - isrc: isrc, - duration: 0, // Will be updated by enrichment later - coverUrl: null, // Will be fetched by enrichment - )); + if ((trackName != null && trackName.isNotEmpty && artistName != null) || + (spotifyId != null && spotifyId.isNotEmpty)) { + tracks.add( + Track( + id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i', + name: trackName ?? 'Unknown Track', + artistName: artistName ?? 'Unknown Artist', + albumName: albumName ?? 'Unknown Album', + isrc: isrc, + duration: 0, // Will be updated by enrichment later + coverUrl: null, // Will be fetched by enrichment + ), + ); } } - + _log.i('Parsed ${tracks.length} tracks from CSV'); return tracks; } - - static String? _getValue(List values, Map colMap, List possibleKeys) { - for (final key in possibleKeys) { - if (colMap.containsKey(key)) { - final index = colMap[key]!; - if (index < values.length) { - return _cleanValue(values[index]); - } - } + + static String? _getValue( + List values, + Map colMap, + List possibleKeys, + ) { + for (final key in possibleKeys) { + if (colMap.containsKey(key)) { + final index = colMap[key]!; + if (index < values.length) { + return _cleanValue(values[index]); + } } - return null; + } + return null; } static String _cleanValue(String val) { @@ -201,30 +233,29 @@ class CsvImportService { } static List _parseLine(String line) { - final List result = []; - bool inQuote = false; - StringBuffer buffer = StringBuffer(); - - for (int i=0; i result = []; + bool inQuote = false; + var buffer = StringBuffer(); + + for (int i = 0; i < line.length; i++) { + final char = line[i]; + if (char == '"') { + if (inQuote && i + 1 < line.length && line[i + 1] == '"') { + buffer.write('"'); + i++; + } else { + inQuote = !inQuote; + } + continue; + } + if (char == ',' && !inQuote) { + result.add(buffer.toString()); + buffer = StringBuffer(); + continue; + } + buffer.write(char); + } + result.add(buffer.toString()); + return result; } } diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index aa68806f..78bd3fb1 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -46,10 +46,11 @@ class LogBuffer extends ChangeNotifier { LogBuffer._internal(); static const int maxEntries = 500; + static const Duration _goLogPollingInterval = Duration(milliseconds: 800); final Queue _entries = Queue(); Timer? _goLogTimer; int _lastGoLogIndex = 0; - + static bool _loggingEnabled = false; static bool get loggingEnabled => _loggingEnabled; static set loggingEnabled(bool value) { @@ -68,7 +69,7 @@ class LogBuffer extends ChangeNotifier { if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') { return; } - + if (_entries.length >= maxEntries) { _entries.removeFirst(); } @@ -78,7 +79,7 @@ class LogBuffer extends ChangeNotifier { void startGoLogPolling() { _goLogTimer?.cancel(); - _goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async { + _goLogTimer = Timer.periodic(_goLogPollingInterval, (_) async { await _fetchGoLogs(); }); } @@ -93,13 +94,13 @@ class LogBuffer extends ChangeNotifier { final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex); final logs = result['logs'] as List? ?? []; final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex; - + for (final log in logs) { final timestamp = log['timestamp'] as String? ?? ''; final level = log['level'] as String? ?? 'INFO'; final tag = log['tag'] as String? ?? 'Go'; final message = log['message'] as String? ?? ''; - + DateTime parsedTime = DateTime.now(); if (timestamp.isNotEmpty) { try { @@ -107,25 +108,29 @@ class LogBuffer extends ChangeNotifier { if (parts.length >= 3) { final secParts = parts[2].split('.'); parsedTime = DateTime( - parsedTime.year, parsedTime.month, parsedTime.day, - int.parse(parts[0]), int.parse(parts[1]), + parsedTime.year, + parsedTime.month, + parsedTime.day, + int.parse(parts[0]), + int.parse(parts[1]), int.parse(secParts[0]), secParts.length > 1 ? int.parse(secParts[1]) : 0, ); } - } catch (_) { - } + } catch (_) {} } - - add(LogEntry( - timestamp: parsedTime, - level: level, - tag: tag, - message: message, - isFromGo: true, - )); + + add( + LogEntry( + timestamp: parsedTime, + level: level, + tag: tag, + message: message, + isFromGo: true, + ), + ); } - + _lastGoLogIndex = nextIndex; } catch (e) { if (kDebugMode) { @@ -156,27 +161,31 @@ class LogBuffer extends ChangeNotifier { Future exportWithDeviceInfo() async { final buffer = StringBuffer(); - + buffer.writeln('=' * 60); buffer.writeln('SPOTIFLAC LOG EXPORT'); buffer.writeln('=' * 60); buffer.writeln(); - + buffer.writeln('--- App Information ---'); - buffer.writeln('App Version: ${AppInfo.version} (Build ${AppInfo.buildNumber})'); + buffer.writeln( + 'App Version: ${AppInfo.version} (Build ${AppInfo.buildNumber})', + ); buffer.writeln('Generated: ${DateTime.now().toIso8601String()}'); buffer.writeln(); - + buffer.writeln('--- Device Information ---'); try { final deviceInfo = DeviceInfoPlugin(); - + if (Platform.isAndroid) { final android = await deviceInfo.androidInfo; buffer.writeln('Platform: Android'); buffer.writeln('Device: ${android.manufacturer} ${android.model}'); buffer.writeln('Brand: ${android.brand}'); - buffer.writeln('Android Version: ${android.version.release} (SDK ${android.version.sdkInt})'); + buffer.writeln( + 'Android Version: ${android.version.release} (SDK ${android.version.sdkInt})', + ); buffer.writeln('Device ID: ${android.id}'); buffer.writeln('Hardware: ${android.hardware}'); buffer.writeln('Product: ${android.product}'); @@ -196,16 +205,16 @@ class LogBuffer extends ChangeNotifier { buffer.writeln('Failed to get device info: $e'); } buffer.writeln(); - + buffer.writeln('--- Log Summary ---'); buffer.writeln('Total Entries: ${_entries.length}'); - + int errorCount = 0; int warnCount = 0; int infoCount = 0; int debugCount = 0; int goCount = 0; - + for (final entry in _entries) { switch (entry.level) { case 'ERROR': @@ -224,23 +233,23 @@ class LogBuffer extends ChangeNotifier { } if (entry.isFromGo) goCount++; } - + buffer.writeln('Errors: $errorCount'); buffer.writeln('Warnings: $warnCount'); buffer.writeln('Info: $infoCount'); buffer.writeln('Debug: $debugCount'); buffer.writeln('From Go Backend: $goCount'); buffer.writeln(); - + buffer.writeln('=' * 60); buffer.writeln('LOG ENTRIES'); buffer.writeln('=' * 60); buffer.writeln(); - + for (final entry in _entries) { buffer.writeln(entry.toString()); } - + return buffer.toString(); } @@ -280,13 +289,15 @@ class BufferedOutput extends LogOutput { final level = _levelToString(event.level); final message = event.lines.join('\n'); - - LogBuffer().add(LogEntry( - timestamp: DateTime.now(), - level: level, - tag: tag, - message: message, - )); + + LogBuffer().add( + LogEntry( + timestamp: DateTime.now(), + level: level, + tag: tag, + message: message, + ), + ); } String _levelToString(Level level) { @@ -336,13 +347,15 @@ class AppLogger { } void _addToBuffer(String level, String message, {String? error}) { - LogBuffer().add(LogEntry( - timestamp: DateTime.now(), - level: level, - tag: _tag, - message: message, - error: error, - )); + LogBuffer().add( + LogEntry( + timestamp: DateTime.now(), + level: level, + tag: _tag, + message: message, + error: error, + ), + ); } void d(String message) {