perf: optimize providers, throttle polling, queued settings save, remove dead screens

This commit is contained in:
zarzet committed 2026-02-07 19:57:44 +07:00
1 parent 35f412dbd2
commit 3d366d21b7
13 files changed
+1184 -2637

No files matched your search

+29 -1
View File
@@ -1,12 +1,40 @@
# Changelog # Changelog
## [3.5.1] - 2026-02-07 ## [3.5.1] - 2026-02-08
### Performance ### Performance
- Removed PaletteService (palette_generator) from all screens for faster navigation and reduced memory usage - 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 - 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 - 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
--- ---
+22 -18
View File
@@ -11,21 +11,9 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await CoverCacheManager.initialize();
debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}');
await Future.wait([
NotificationService().initialize(),
ShareIntentService().initialize(),
]);
runApp( runApp(
ProviderScope( ProviderScope(child: const _EagerInitialization(child: SpotiFLACApp())),
child: const _EagerInitialization(
child: SpotiFLACApp(),
),
),
); );
} }
@@ -35,27 +23,43 @@ class _EagerInitialization extends ConsumerStatefulWidget {
final Widget child; final Widget child;
@override @override
ConsumerState<_EagerInitialization> createState() => _EagerInitializationState(); ConsumerState<_EagerInitialization> createState() =>
_EagerInitializationState();
} }
class _EagerInitializationState extends ConsumerState<_EagerInitialization> { class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_initializeAppServices();
_initializeExtensions(); _initializeExtensions();
ref.read(downloadHistoryProvider); ref.read(downloadHistoryProvider);
} }
Future<void> _initializeAppServices() async {
try {
await CoverCacheManager.initialize();
await Future.wait([
NotificationService().initialize(),
ShareIntentService().initialize(),
]);
} catch (e) {
debugPrint('Failed to initialize app services: $e');
}
}
Future<void> _initializeExtensions() async { Future<void> _initializeExtensions() async {
try { try {
final appDir = await getApplicationDocumentsDirectory(); final appDir = await getApplicationDocumentsDirectory();
final extensionsDir = '${appDir.path}/extensions'; final extensionsDir = '${appDir.path}/extensions';
final dataDir = '${appDir.path}/extension_data'; final dataDir = '${appDir.path}/extension_data';
await Directory(extensionsDir).create(recursive: true); await Directory(extensionsDir).create(recursive: true);
await Directory(dataDir).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) { } catch (e) {
debugPrint('Failed to initialize extensions: $e'); debugPrint('Failed to initialize extensions: $e');
} }
+176 -62
View File
@@ -226,8 +226,11 @@ class DownloadHistoryState {
} }
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> { class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
static const int _safRepairBatchSize = 20;
static const int _safRepairMaxPerLaunch = 60;
final HistoryDatabase _db = HistoryDatabase.instance; final HistoryDatabase _db = HistoryDatabase.instance;
bool _isLoaded = false; bool _isLoaded = false;
bool _isSafRepairInProgress = false;
@override @override
DownloadHistoryState build() { DownloadHistoryState build() {
@@ -267,8 +270,14 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (Platform.isAndroid) { if (Platform.isAndroid) {
Future.microtask(() async { Future.microtask(() async {
await _repairMissingSafEntries(items); await _repairMissingSafEntries(
items,
maxItems: _safRepairMaxPerLaunch,
);
await cleanupOrphanedDownloads();
}); });
} else {
Future.microtask(() => cleanupOrphanedDownloads());
} }
} catch (e, stack) { } catch (e, stack) {
_historyLog.e('Failed to load history from database: $e', e, stack); _historyLog.e('Failed to load history from database: $e', e, stack);
@@ -285,10 +294,16 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return ''; return '';
} }
Future<void> _repairMissingSafEntries(List<DownloadHistoryItem> items) async { Future<void> _repairMissingSafEntries(
final updatedItems = [...items]; List<DownloadHistoryItem> items, {
var changed = false; required int maxItems,
}) async {
if (_isSafRepairInProgress || items.isEmpty) {
return;
}
_isSafRepairInProgress = true;
final candidateIndexes = <int>[];
for (var i = 0; i < items.length; i++) { for (var i = 0; i < items.length; i++) {
final item = items[i]; final item = items[i];
if (item.storageMode != 'saf') continue; if (item.storageMode != 'saf') continue;
@@ -299,46 +314,85 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (item.filePath.isEmpty || !isContentUri(item.filePath)) { if (item.filePath.isEmpty || !isContentUri(item.filePath)) {
continue; continue;
} }
candidateIndexes.add(i);
final exists = await fileExists(item.filePath); if (candidateIndexes.length >= maxItems) break;
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');
}
} }
if (changed) { if (candidateIndexes.isEmpty) {
state = state.copyWith(items: updatedItems); _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<DownloadHistoryState> {
/// Returns the number of orphaned entries removed /// Returns the number of orphaned entries removed
Future<int> cleanupOrphanedDownloads() async { Future<int> cleanupOrphanedDownloads() async {
_historyLog.i('Starting orphaned downloads cleanup...'); _historyLog.i('Starting orphaned downloads cleanup...');
final entries = await _db.getAllEntriesWithPaths(); final entries = await _db.getAllEntriesWithPaths();
final orphanedIds = <String>[]; final orphanedIds = <String>[];
for (final entry in entries) { for (final entry in entries) {
final id = entry['id'] as String; final id = entry['id'] as String;
final filePath = entry['file_path'] as String?; final filePath = entry['file_path'] as String?;
if (filePath == null || filePath.isEmpty) continue; if (filePath == null || filePath.isEmpty) continue;
bool exists = false; bool exists = false;
if (filePath.startsWith('content://')) { if (filePath.startsWith('content://')) {
// SAF path - check via platform bridge // SAF path - check via platform bridge
try { try {
@@ -436,31 +490,33 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
// Regular file path // Regular file path
exists = File(filePath).existsSync(); exists = File(filePath).existsSync();
} }
if (!exists) { if (!exists) {
orphanedIds.add(id); orphanedIds.add(id);
_historyLog.d('Found orphaned entry: $id ($filePath)'); _historyLog.d('Found orphaned entry: $id ($filePath)');
} }
} }
if (orphanedIds.isEmpty) { if (orphanedIds.isEmpty) {
_historyLog.i('No orphaned entries found'); _historyLog.i('No orphaned entries found');
return 0; return 0;
} }
// Delete from database // Delete from database
final deletedCount = await _db.deleteByIds(orphanedIds); final deletedCount = await _db.deleteByIds(orphanedIds);
// Update in-memory state // Update in-memory state
final orphanedSet = orphanedIds.toSet(); final orphanedSet = orphanedIds.toSet();
state = state.copyWith( 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'); _historyLog.i('Cleaned up $deletedCount orphaned entries');
return deletedCount; return deletedCount;
} }
void clearHistory() { void clearHistory() {
state = DownloadHistoryState(); state = DownloadHistoryState();
_db.clearAll().catchError((e) { _db.clearAll().catchError((e) {
@@ -557,6 +613,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
int _downloadCount = 0; int _downloadCount = 0;
static const _cleanupInterval = 50; static const _cleanupInterval = 50;
static const _queueStorageKey = 'download_queue'; static const _queueStorageKey = 'download_queue';
static const _progressPollingInterval = Duration(milliseconds: 800);
final NotificationService _notificationService = NotificationService(); final NotificationService _notificationService = NotificationService();
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance(); final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
int _totalQueuedAtStart = 0; int _totalQueuedAtStart = 0;
@@ -564,6 +621,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
int _failedInSession = 0; int _failedInSession = 0;
bool _isLoaded = false; bool _isLoaded = false;
final Set<String> _ensuredDirs = {}; final Set<String> _ensuredDirs = {};
int _progressPollingErrorCount = 0;
String? _lastServiceTrackName;
String? _lastServiceArtistName;
int _lastServicePercent = -1;
int _lastServiceQueueCount = -1;
DateTime _lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0);
@override @override
DownloadQueueState build() { DownloadQueueState build() {
@@ -647,9 +710,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
void _startMultiProgressPolling() { void _startMultiProgressPolling() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), ( _progressTimer = Timer.periodic(_progressPollingInterval, (timer) async {
timer,
) async {
try { try {
final allProgress = await PlatformBridge.getAllDownloadProgress(); final allProgress = await PlatformBridge.getAllDownloadProgress();
final items = allProgress['items'] as Map<String, dynamic>? ?? {}; final items = allProgress['items'] as Map<String, dynamic>? ?? {};
@@ -818,23 +879,76 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
); );
if (Platform.isAndroid) { if (Platform.isAndroid) {
PlatformBridge.updateDownloadServiceProgress( _maybeUpdateAndroidDownloadService(
trackName: firstDownloading.track.name, trackName: firstDownloading.track.name,
artistName: firstDownloading.track.artistName, artistName: firstDownloading.track.artistName,
progress: notifProgress, progress: notifProgress,
total: notifTotal > 0 ? notifTotal : 1, total: notifTotal > 0 ? notifTotal : 1,
queueCount: queuedCount, 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() { void _stopProgressPolling() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = null; _progressTimer = null;
_progressPollingErrorCount = 0;
_lastServiceTrackName = null;
_lastServiceArtistName = null;
_lastServicePercent = -1;
_lastServiceQueueCount = -1;
_lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0);
} }
Future<void> _initOutputDir() async { Future<void> _initOutputDir() async {
@@ -2241,7 +2355,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
while (true) { while (true) {
if (state.isPaused) { if (state.isPaused) {
_log.d('Queue is paused, waiting...'); _log.d('Queue is paused, waiting...');
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(_progressPollingInterval);
continue; continue;
} }
@@ -2280,7 +2394,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (activeDownloads.isNotEmpty) { if (activeDownloads.isNotEmpty) {
await Future.any(activeDownloads.values); await Future.any(activeDownloads.values);
} else { } else {
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(_progressPollingInterval);
} }
continue; continue;
} }
+121 -65
View File
@@ -36,16 +36,16 @@ class LocalLibraryState {
this.scanErrorCount = 0, this.scanErrorCount = 0,
this.scanWasCancelled = false, this.scanWasCancelled = false,
this.lastScannedAt, this.lastScannedAt,
}) : _isrcSet = items }) : _isrcSet = items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty) .where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => item.isrc!) .map((item) => item.isrc!)
.toSet(), .toSet(),
_trackKeySet = items.map((item) => item.matchKey).toSet(), _trackKeySet = items.map((item) => item.matchKey).toSet(),
_byIsrc = Map.fromEntries( _byIsrc = Map.fromEntries(
items items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty) .where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => MapEntry(item.isrc!, item)), .map((item) => MapEntry(item.isrc!, item)),
); );
bool hasIsrc(String isrc) => _isrcSet.contains(isrc); bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
@@ -99,9 +99,11 @@ class LocalLibraryState {
class LocalLibraryNotifier extends Notifier<LocalLibraryState> { class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final LibraryDatabase _db = LibraryDatabase.instance; final LibraryDatabase _db = LibraryDatabase.instance;
final HistoryDatabase _historyDb = HistoryDatabase.instance; final HistoryDatabase _historyDb = HistoryDatabase.instance;
static const _progressPollingInterval = Duration(milliseconds: 800);
Timer? _progressTimer; Timer? _progressTimer;
bool _isLoaded = false; bool _isLoaded = false;
bool _scanCancelRequested = false; bool _scanCancelRequested = false;
int _progressPollingErrorCount = 0;
@override @override
LocalLibraryState build() { LocalLibraryState build() {
@@ -121,10 +123,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try { try {
final jsonList = await _db.getAll(); final jsonList = await _db.getAll();
final items = jsonList final items = jsonList.map((e) => LocalLibraryItem.fromJson(e)).toList();
.map((e) => LocalLibraryItem.fromJson(e))
.toList();
DateTime? lastScannedAt; DateTime? lastScannedAt;
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -135,9 +135,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
} catch (e) { } catch (e) {
_log.w('Failed to load lastScannedAt: $e'); _log.w('Failed to load lastScannedAt: $e');
} }
state = state.copyWith(items: items, lastScannedAt: lastScannedAt); 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) { } catch (e, stack) {
_log.e('Failed to load library from database: $e', e, stack); _log.e('Failed to load library from database: $e', e, stack);
} }
@@ -148,14 +150,19 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
await _loadFromDatabase(); await _loadFromDatabase();
} }
Future<void> startScan(String folderPath, {bool forceFullScan = false}) async { Future<void> startScan(
String folderPath, {
bool forceFullScan = false,
}) async {
if (state.isScanning) { if (state.isScanning) {
_log.w('Scan already in progress'); _log.w('Scan already in progress');
return; return;
} }
_scanCancelRequested = false; _scanCancelRequested = false;
_log.i('Starting library scan: $folderPath (incremental: ${!forceFullScan})'); _log.i(
'Starting library scan: $folderPath (incremental: ${!forceFullScan})',
);
state = state.copyWith( state = state.copyWith(
isScanning: true, isScanning: true,
scanProgress: 0, scanProgress: 0,
@@ -179,11 +186,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try { try {
final isSaf = folderPath.startsWith('content://'); final isSaf = folderPath.startsWith('content://');
// Get all file paths from download history to exclude them // Get all file paths from download history to exclude them
final downloadedPaths = await _historyDb.getAllFilePaths(); 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) { if (forceFullScan) {
// Full scan path - ignores existing data // Full scan path - ignores existing data
final results = isSaf final results = isSaf
@@ -193,7 +202,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
state = state.copyWith(isScanning: false, scanWasCancelled: true); state = state.copyWith(isScanning: false, scanWasCancelled: true);
return; return;
} }
final items = <LocalLibraryItem>[]; final items = <LocalLibraryItem>[];
int skippedDownloads = 0; int skippedDownloads = 0;
for (final json in results) { for (final json in results) {
@@ -206,7 +215,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final item = LocalLibraryItem.fromJson(json); final item = LocalLibraryItem.fromJson(json);
items.add(item); items.add(item);
} }
if (skippedDownloads > 0) { if (skippedDownloads > 0) {
_log.i('Skipped $skippedDownloads files already in download history'); _log.i('Skipped $skippedDownloads files already in download history');
} }
@@ -234,7 +243,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
} else { } else {
// Incremental scan path - only scans new/modified files // Incremental scan path - only scans new/modified files
final existingFiles = await _db.getFileModTimes(); 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( final backfilledModTimes = await _backfillLegacyFileModTimes(
isSaf: isSaf, isSaf: isSaf,
@@ -245,7 +256,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
existingFiles.addAll(backfilledModTimes); existingFiles.addAll(backfilledModTimes);
_log.i('Backfilled ${backfilledModTimes.length} legacy mod times'); _log.i('Backfilled ${backfilledModTimes.length} legacy mod times');
} }
// Use appropriate incremental scan method based on SAF or not // Use appropriate incremental scan method based on SAF or not
final Map<String, dynamic> result; final Map<String, dynamic> result;
if (isSaf) { if (isSaf) {
@@ -259,63 +270,76 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
existingFiles, existingFiles,
); );
} }
if (_scanCancelRequested) { if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true); state = state.copyWith(isScanning: false, scanWasCancelled: true);
return; return;
} }
// Parse incremental scan result // Parse incremental scan result
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths' // SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
final scannedList = (result['files'] as List<dynamic>?) final scannedList =
?? (result['scanned'] as List<dynamic>?) (result['files'] as List<dynamic>?) ??
?? []; (result['scanned'] as List<dynamic>?) ??
final deletedPaths = (result['removedUris'] as List<dynamic>?) [];
?.map((e) => e as String) final deletedPaths =
.toList() (result['removedUris'] as List<dynamic>?)
?? (result['deletedPaths'] as List<dynamic>?)
?.map((e) => e as String) ?.map((e) => e as String)
.toList() .toList() ??
?? []; (result['deletedPaths'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[];
final skippedCount = result['skippedCount'] as int? ?? 0; final skippedCount = result['skippedCount'] as int? ?? 0;
final totalFiles = result['totalFiles'] as int? ?? 0; final totalFiles = result['totalFiles'] as int? ?? 0;
_log.i('Incremental result: ${scannedList.length} scanned, ' _log.i(
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total'); 'Incremental result: ${scannedList.length} scanned, '
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
);
final currentByPath = <String, LocalLibraryItem>{
for (final item in state.items) item.filePath: item,
};
// Upsert new/modified items (excluding downloaded files) // Upsert new/modified items (excluding downloaded files)
final updatedItems = <LocalLibraryItem>[];
int skippedDownloads = 0;
if (scannedList.isNotEmpty) { if (scannedList.isNotEmpty) {
final items = <LocalLibraryItem>[];
int skippedDownloads = 0;
for (final json in scannedList) { for (final json in scannedList) {
final map = json as Map<String, dynamic>; final map = json as Map<String, dynamic>;
final filePath = map['filePath'] as String?; final filePath = map['filePath'] as String?;
// Skip files that are already in download history
if (filePath != null && downloadedPaths.contains(filePath)) { if (filePath != null && downloadedPaths.contains(filePath)) {
skippedDownloads++; skippedDownloads++;
continue; continue;
} }
items.add(LocalLibraryItem.fromJson(map)); final item = LocalLibraryItem.fromJson(map);
updatedItems.add(item);
currentByPath[item.filePath] = item;
} }
if (items.isNotEmpty) { if (updatedItems.isNotEmpty) {
await _db.upsertBatch(items.map((e) => e.toJson()).toList()); await _db.upsertBatch(updatedItems.map((e) => e.toJson()).toList());
_log.i('Upserted ${items.length} items'); _log.i('Upserted ${updatedItems.length} items');
} }
if (skippedDownloads > 0) { if (skippedDownloads > 0) {
_log.i('Skipped $skippedDownloads files already in download history'); _log.i(
'Skipped $skippedDownloads files already in download history',
);
} }
} }
// Delete removed items // Delete removed items
if (deletedPaths.isNotEmpty) { if (deletedPaths.isNotEmpty) {
final deleteCount = await _db.deleteByPaths(deletedPaths); final deleteCount = await _db.deleteByPaths(deletedPaths);
for (final path in deletedPaths) {
currentByPath.remove(path);
}
_log.i('Deleted $deleteCount items from database'); _log.i('Deleted $deleteCount items from database');
} }
// Reload all items from database to get complete list final items = currentByPath.values.toList(growable: false)
final allItems = await _db.getAll(); ..sort(_compareLibraryItems);
final items = allItems.map((e) => LocalLibraryItem.fromJson(e)).toList();
final now = DateTime.now(); final now = DateTime.now();
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -333,8 +357,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanWasCancelled: false, scanWasCancelled: false,
); );
_log.i('Incremental scan complete: ${items.length} total tracks ' _log.i(
'(${scannedList.length} new/updated, $skippedCount unchanged, ${deletedPaths.length} removed)'); 'Incremental scan complete: ${items.length} total tracks '
'(${scannedList.length} new/updated, $skippedCount unchanged, ${deletedPaths.length} removed)',
);
} }
} catch (e, stack) { } catch (e, stack) {
_log.e('Library scan failed: $e', e, stack); _log.e('Library scan failed: $e', e, stack);
@@ -346,10 +372,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
void _startProgressPolling() { void _startProgressPolling() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async { _progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
try { try {
final progress = await PlatformBridge.getLibraryScanProgress(); final progress = await PlatformBridge.getLibraryScanProgress();
state = state.copyWith( state = state.copyWith(
scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0, scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0,
scanCurrentFile: progress['current_file'] as String?, scanCurrentFile: progress['current_file'] as String?,
@@ -361,18 +387,25 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (progress['is_complete'] == true) { if (progress['is_complete'] == true) {
_stopProgressPolling(); _stopProgressPolling();
} }
} catch (_) {} _progressPollingErrorCount = 0;
} catch (e) {
_progressPollingErrorCount++;
if (_progressPollingErrorCount <= 3) {
_log.w('Library scan progress polling failed: $e');
}
}
}); });
} }
void _stopProgressPolling() { void _stopProgressPolling() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = null; _progressTimer = null;
_progressPollingErrorCount = 0;
} }
Future<void> cancelScan() async { Future<void> cancelScan() async {
if (!state.isScanning) return; if (!state.isScanning) return;
_log.i('Cancelling library scan'); _log.i('Cancelling library scan');
_scanCancelRequested = true; _scanCancelRequested = true;
await PlatformBridge.cancelLibraryScan(); await PlatformBridge.cancelLibraryScan();
@@ -390,14 +423,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
Future<void> clearLibrary() async { Future<void> clearLibrary() async {
await _db.clearAll(); await _db.clearAll();
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_lastScannedAtKey); await prefs.remove(_lastScannedAtKey);
} catch (e) { } catch (e) {
_log.w('Failed to clear lastScannedAt: $e'); _log.w('Failed to clear lastScannedAt: $e');
} }
state = LocalLibraryState(); state = LocalLibraryState();
_log.i('Library cleared'); _log.i('Library cleared');
} }
@@ -421,7 +454,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return state.getByIsrc(isrc); 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) { if (isrc != null && isrc.isNotEmpty) {
final byIsrc = state.getByIsrc(isrc); final byIsrc = state.getByIsrc(isrc);
if (byIsrc != null) return byIsrc; if (byIsrc != null) return byIsrc;
@@ -434,7 +471,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
Future<List<LocalLibraryItem>> search(String query) async { Future<List<LocalLibraryItem>> search(String query) async {
if (query.isEmpty) return []; if (query.isEmpty) return [];
final results = await _db.search(query); final results = await _db.search(query);
return results.map((e) => LocalLibraryItem.fromJson(e)).toList(); return results.map((e) => LocalLibraryItem.fromJson(e)).toList();
} }
@@ -443,6 +480,23 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return await _db.getCount(); 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<Map<String, int>> _backfillLegacyFileModTimes({ Future<Map<String, int>> _backfillLegacyFileModTimes({
required bool isSaf, required bool isSaf,
required Map<String, int> existingFiles, required Map<String, int> existingFiles,
@@ -469,7 +523,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (_scanCancelRequested) { if (_scanCancelRequested) {
break; 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 chunk = uris.sublist(i, end);
final chunkResult = await PlatformBridge.getSafFileModTimes(chunk); final chunkResult = await PlatformBridge.getSafFileModTimes(chunk);
backfilled.addAll(chunkResult); backfilled.addAll(chunkResult);
+45 -18
View File
@@ -10,10 +10,14 @@ const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version'; const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 2; const _currentMigrationVersion = 2;
const _spotifyClientSecretKey = 'spotify_client_secret'; const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
class SettingsNotifier extends Notifier<AppSettings> { class SettingsNotifier extends Notifier<AppSettings> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance(); final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
bool _isSavingSettings = false;
bool _saveQueued = false;
String? _pendingSettingsJson;
@override @override
AppSettings build() { AppSettings build() {
@@ -26,27 +30,27 @@ class SettingsNotifier extends Notifier<AppSettings> {
final json = prefs.getString(_settingsKey); final json = prefs.getString(_settingsKey);
if (json != null) { if (json != null) {
state = AppSettings.fromJson(jsonDecode(json)); state = AppSettings.fromJson(jsonDecode(json));
await _runMigrations(prefs); await _runMigrations(prefs);
} }
await _loadSpotifyClientSecret(prefs); await _loadSpotifyClientSecret(prefs);
_applySpotifyCredentials(); _applySpotifyCredentials();
LogBuffer.loggingEnabled = state.enableLogging; LogBuffer.loggingEnabled = state.enableLogging;
} }
Future<void> _runMigrations(SharedPreferences prefs) async { Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0; final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
if (lastMigration < 1) { if (lastMigration < 1) {
if (!state.useCustomSpotifyCredentials) { if (!state.useCustomSpotifyCredentials) {
state = state.copyWith(metadataSource: 'deezer'); state = state.copyWith(metadataSource: 'deezer');
await _saveSettings(); await _saveSettings();
} }
} }
if (lastMigration < _currentMigrationVersion) { if (lastMigration < _currentMigrationVersion) {
if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') { if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') {
state = state.copyWith(storageMode: 'saf'); state = state.copyWith(storageMode: 'saf');
@@ -61,20 +65,43 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
Future<void> _saveSettings() async { Future<void> _saveSettings() async {
final prefs = await _prefs; final settingsToSave = state.copyWith(spotifyClientSecret: '');
final settingsToSave = state.copyWith( _pendingSettingsJson = jsonEncode(settingsToSave.toJson());
spotifyClientSecret: '',
); if (_isSavingSettings) {
await prefs.setString(_settingsKey, jsonEncode(settingsToSave.toJson())); _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<void> _loadSpotifyClientSecret(SharedPreferences prefs) async { Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
final storedSecret = await _secureStorage.read(key: _spotifyClientSecretKey); final storedSecret = await _secureStorage.read(
key: _spotifyClientSecretKey,
);
final prefsSecret = state.spotifyClientSecret; final prefsSecret = state.spotifyClientSecret;
if ((storedSecret == null || storedSecret.isEmpty) && if ((storedSecret == null || storedSecret.isEmpty) &&
prefsSecret.isNotEmpty) { prefsSecret.isNotEmpty) {
await _secureStorage.write(key: _spotifyClientSecretKey, value: prefsSecret); await _secureStorage.write(
key: _spotifyClientSecretKey,
value: prefsSecret,
);
} }
final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty) final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty)
@@ -99,7 +126,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
Future<void> _applySpotifyCredentials() async { Future<void> _applySpotifyCredentials() async {
if (state.spotifyClientId.isNotEmpty && if (state.spotifyClientId.isNotEmpty &&
state.spotifyClientSecret.isNotEmpty) { state.spotifyClientSecret.isNotEmpty) {
await PlatformBridge.setSpotifyCredentials( await PlatformBridge.setSpotifyCredentials(
state.spotifyClientId, state.spotifyClientId,
@@ -225,7 +252,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
Future<void> setSpotifyCredentials(String clientId, String clientSecret) async { Future<void> setSpotifyCredentials(
String clientId,
String clientSecret,
) async {
state = state.copyWith( state = state.copyWith(
spotifyClientId: clientId, spotifyClientId: clientId,
spotifyClientSecret: clientSecret, spotifyClientSecret: clientSecret,
@@ -236,10 +266,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
Future<void> clearSpotifyCredentials() async { Future<void> clearSpotifyCredentials() async {
state = state.copyWith( state = state.copyWith(spotifyClientId: '', spotifyClientSecret: '');
spotifyClientId: '',
spotifyClientSecret: '',
);
await _storeSpotifyClientSecret(''); await _storeSpotifyClientSecret('');
_saveSettings(); _saveSettings();
_applySpotifyCredentials(); _applySpotifyCredentials();
@@ -301,7 +328,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setUseAllFilesAccess(bool enabled) { void setUseAllFilesAccess(bool enabled) {
state = state.copyWith(useAllFilesAccess: enabled); state = state.copyWith(useAllFilesAccess: enabled);
_saveSettings(); _saveSettings();
} }
-413
View File
@@ -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<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> {
final _urlController = TextEditingController();
int _currentIndex = 0;
@override
void dispose() {
_urlController.dispose();
super.dispose();
}
Future<void> _pasteFromClipboard() async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data?.text != null) {
_urlController.text = data!.text!;
}
}
Future<void> _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<void> _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<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, 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<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, 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<String, dynamic> 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,
),
),
],
),
);
}
}
+24 -11
View File
@@ -82,9 +82,9 @@ class _MainShellState extends ConsumerState<MainShell> {
} }
} }
} }
if (!mounted) return; if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst); Navigator.of(context).popUntil((route) => route.isFirst);
if (_currentIndex != 0) { if (_currentIndex != 0) {
@@ -181,10 +181,14 @@ class _MainShellState extends ConsumerState<MainShell> {
final treeUri = result['tree_uri'] as String? ?? ''; final treeUri = result['tree_uri'] as String? ?? '';
final displayName = result['display_name'] as String? ?? ''; final displayName = result['display_name'] as String? ?? '';
if (treeUri.isNotEmpty) { if (treeUri.isNotEmpty) {
ref.read(settingsProvider.notifier).setDownloadTreeUri( ref
treeUri, .read(settingsProvider.notifier)
displayName: displayName.isNotEmpty ? displayName : treeUri, .setDownloadTreeUri(
); treeUri,
displayName: displayName.isNotEmpty
? displayName
: treeUri,
);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -280,7 +284,16 @@ class _MainShellState extends ConsumerState<MainShell> {
final queueState = ref.watch( final queueState = ref.watch(
downloadQueueProvider.select((s) => s.queuedCount), 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( final showStore = ref.watch(
settingsProvider.select((s) => s.showExtensionStore), settingsProvider.select((s) => s.showExtensionStore),
); );
@@ -292,10 +305,10 @@ class _MainShellState extends ConsumerState<MainShell> {
final canPop = final canPop =
_currentIndex == 0 && _currentIndex == 0 &&
!trackState.hasSearchText && !trackHasSearchText &&
!trackState.hasContent && !trackHasContent &&
!trackState.isLoading && !trackIsLoading &&
!trackState.isShowingRecentAccess && !trackIsShowingRecentAccess &&
!isKeyboardVisible; !isKeyboardVisible;
final tabs = <Widget>[ final tabs = <Widget>[
-248
View File
@@ -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)),
),
],
),
);
}
}
+595 -613
View File
File diff suppressed because it is too large. Load diff
-540
View File
@@ -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<void> _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<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}
-520
View File
@@ -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<SettingsTab> createState() => _SettingsTabState();
}
class _SettingsTabState extends ConsumerState<SettingsTab> 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<void> _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<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}
+114 -83
View File
@@ -21,7 +21,7 @@ class CsvImportService {
final file = File(result.files.single.path!); final file = File(result.files.single.path!);
final content = await file.readAsString(); final content = await file.readAsString();
final tracks = _parseCsv(content); final tracks = _parseCsv(content);
if (tracks.isNotEmpty) { if (tracks.isNotEmpty) {
return await _enrichTracksMetadata(tracks, onProgress: onProgress); return await _enrichTracksMetadata(tracks, onProgress: onProgress);
} }
@@ -39,43 +39,50 @@ class CsvImportService {
}) async { }) async {
_log.i('Enriching metadata for ${tracks.length} tracks from Deezer...'); _log.i('Enriching metadata for ${tracks.length} tracks from Deezer...');
final enrichedTracks = <Track>[]; final enrichedTracks = <Track>[];
for (int i = 0; i < tracks.length; i++) { for (int i = 0; i < tracks.length; i++) {
final track = tracks[i]; final track = tracks[i];
onProgress?.call(i + 1, tracks.length); onProgress?.call(i + 1, tracks.length);
if (track.coverUrl == null || track.duration == 0) { if (track.coverUrl == null || track.duration == 0) {
Map<String, dynamic>? trackData; Map<String, dynamic>? trackData;
if (track.isrc != null && track.isrc!.isNotEmpty) { if (track.isrc != null && track.isrc!.isNotEmpty) {
try { try {
trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!); trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!);
_log.d('ISRC enrichment success for ${track.name}'); _log.d('ISRC enrichment success for ${track.name}');
} catch (e) { } 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) { if (trackData == null) {
try { try {
final query = '${track.artistName} ${track.name}'; 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')) { if (searchResult.containsKey('tracks')) {
final tracksList = searchResult['tracks'] as List<dynamic>?; final tracksList = searchResult['tracks'] as List<dynamic>?;
if (tracksList != null && tracksList.isNotEmpty) { if (tracksList != null && tracksList.isNotEmpty) {
for (final result in tracksList) { for (final result in tracksList) {
final resultMap = result as Map<String, dynamic>; final resultMap = result as Map<String, dynamic>;
final resultName = (resultMap['name'] as String?)?.toLowerCase() ?? ''; final resultName =
(resultMap['name'] as String?)?.toLowerCase() ?? '';
final trackNameLower = track.name.toLowerCase(); final trackNameLower = track.name.toLowerCase();
if (resultName.contains(trackNameLower) || trackNameLower.contains(resultName)) { if (resultName.contains(trackNameLower) ||
trackNameLower.contains(resultName)) {
trackData = resultMap; trackData = resultMap;
_log.d('Text search match for ${track.name}: $resultName'); _log.d('Text search match for ${track.name}: $resultName');
break; break;
} }
} }
if (trackData == null && tracksList.isNotEmpty) { if (trackData == null && tracksList.isNotEmpty) {
trackData = tracksList.first as Map<String, dynamic>; trackData = tracksList.first as Map<String, dynamic>;
_log.d('Using first search result for ${track.name}'); _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'); _log.w('Text search also failed for ${track.name}: $e');
} }
} }
if (trackData != null) { if (trackData != null) {
final coverUrl = trackData['images'] as String?; final coverUrl = trackData['images'] as String?;
final durationMs = trackData['duration_ms'] as int? ?? 0; final durationMs = trackData['duration_ms'] as int? ?? 0;
final deezerIdRaw = trackData['spotify_id'] as String?; final deezerIdRaw = trackData['spotify_id'] as String?;
enrichedTracks.add(Track( enrichedTracks.add(
id: deezerIdRaw ?? track.id, Track(
name: trackData['name'] as String? ?? track.name, id: deezerIdRaw ?? track.id,
artistName: trackData['artists'] as String? ?? track.artistName, name: trackData['name'] as String? ?? track.name,
albumName: trackData['album_name'] as String? ?? track.albumName, artistName: trackData['artists'] as String? ?? track.artistName,
albumArtist: trackData['album_artist'] as String?, albumName: trackData['album_name'] as String? ?? track.albumName,
coverUrl: coverUrl ?? track.coverUrl, albumArtist: trackData['album_artist'] as String?,
isrc: trackData['isrc'] as String? ?? track.isrc, coverUrl: coverUrl ?? track.coverUrl,
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration, isrc: trackData['isrc'] as String? ?? track.isrc,
trackNumber: trackData['track_number'] as int? ?? track.trackNumber, duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
discNumber: trackData['disc_number'] as int? ?? track.discNumber, trackNumber:
releaseDate: trackData['release_date'] as String? ?? track.releaseDate, trackData['track_number'] as int? ?? track.trackNumber,
)); discNumber: trackData['disc_number'] as int? ?? track.discNumber,
releaseDate:
_log.d('Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s'); trackData['release_date'] as String? ?? track.releaseDate,
),
);
_log.d(
'Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s',
);
if (i < tracks.length - 1) { if (i < tracks.length - 1) {
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
} }
continue; continue;
} }
} }
enrichedTracks.add(track); enrichedTracks.add(track);
} }
_log.i('Enrichment complete: ${enrichedTracks.length} tracks'); _log.i('Enrichment complete: ${enrichedTracks.length} tracks');
return enrichedTracks; return enrichedTracks;
} }
@@ -136,8 +149,8 @@ class CsvImportService {
final headers = _parseLine(lines[startIdx]); final headers = _parseLine(lines[startIdx]);
final colMap = <String, int>{}; final colMap = <String, int>{};
for (int i = 0; i < headers.length; i++) { for (int i = 0; i < headers.length; i++) {
String h = _cleanValue(headers[i]).toLowerCase(); String h = _cleanValue(headers[i]).toLowerCase();
colMap[h] = i; colMap[h] = i;
} }
_log.d('CSV Headers: ${colMap.keys.toList()}'); _log.d('CSV Headers: ${colMap.keys.toList()}');
@@ -147,48 +160,67 @@ class CsvImportService {
if (line.isEmpty) continue; if (line.isEmpty) continue;
final values = _parseLine(line); final values = _parseLine(line);
String? getVal(List<String> keys) { String? getVal(List<String> keys) {
return _getValue(values, colMap, keys); return _getValue(values, colMap, keys);
} }
String? trackName = getVal(['track name', 'track', 'name', 'title']); 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? albumName = getVal(['album name', 'album']);
String? isrc = getVal(['isrc']); 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:')) { if (spotifyId != null && spotifyId.startsWith('spotify:track:')) {
spotifyId = spotifyId.replaceAll('spotify:track:', ''); spotifyId = spotifyId.replaceAll('spotify:track:', '');
} }
if ((trackName != null && trackName.isNotEmpty && artistName != null) || (spotifyId != null && spotifyId.isNotEmpty)) { if ((trackName != null && trackName.isNotEmpty && artistName != null) ||
tracks.add(Track( (spotifyId != null && spotifyId.isNotEmpty)) {
id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i', tracks.add(
name: trackName ?? 'Unknown Track', Track(
artistName: artistName ?? 'Unknown Artist', id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i',
albumName: albumName ?? 'Unknown Album', name: trackName ?? 'Unknown Track',
isrc: isrc, artistName: artistName ?? 'Unknown Artist',
duration: 0, // Will be updated by enrichment later albumName: albumName ?? 'Unknown Album',
coverUrl: null, // Will be fetched by enrichment 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'); _log.i('Parsed ${tracks.length} tracks from CSV');
return tracks; return tracks;
} }
static String? _getValue(List<String> values, Map<String, int> colMap, List<String> possibleKeys) { static String? _getValue(
for (final key in possibleKeys) { List<String> values,
if (colMap.containsKey(key)) { Map<String, int> colMap,
final index = colMap[key]!; List<String> possibleKeys,
if (index < values.length) { ) {
return _cleanValue(values[index]); 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) { static String _cleanValue(String val) {
@@ -201,30 +233,29 @@ class CsvImportService {
} }
static List<String> _parseLine(String line) { static List<String> _parseLine(String line) {
final List<String> result = []; final List<String> result = [];
bool inQuote = false; bool inQuote = false;
StringBuffer buffer = StringBuffer(); var buffer = StringBuffer();
for (int i=0; i<line.length; i++) { for (int i = 0; i < line.length; i++) {
String char = line[i]; final char = line[i];
if (char == '"') { if (char == '"') {
if (i + 1 < line.length && line[i+1] == '"') { if (inQuote && i + 1 < line.length && line[i + 1] == '"') {
buffer.write('"'); buffer.write('"');
buffer.write('"'); i++;
i++; // Skip next quote char loop } else {
buffer.write('"'); // Write 2nd quote inQuote = !inQuote;
} else { }
inQuote = !inQuote; continue;
buffer.write(char); }
} if (char == ',' && !inQuote) {
} else if (char == ',' && !inQuote) { result.add(buffer.toString());
result.add(buffer.toString()); buffer = StringBuffer();
buffer.clear(); continue;
} else { }
buffer.write(char); buffer.write(char);
} }
} result.add(buffer.toString());
result.add(buffer.toString()); return result;
return result;
} }
} }
+58 -45
View File
@@ -46,10 +46,11 @@ class LogBuffer extends ChangeNotifier {
LogBuffer._internal(); LogBuffer._internal();
static const int maxEntries = 500; static const int maxEntries = 500;
static const Duration _goLogPollingInterval = Duration(milliseconds: 800);
final Queue<LogEntry> _entries = Queue<LogEntry>(); final Queue<LogEntry> _entries = Queue<LogEntry>();
Timer? _goLogTimer; Timer? _goLogTimer;
int _lastGoLogIndex = 0; int _lastGoLogIndex = 0;
static bool _loggingEnabled = false; static bool _loggingEnabled = false;
static bool get loggingEnabled => _loggingEnabled; static bool get loggingEnabled => _loggingEnabled;
static set loggingEnabled(bool value) { static set loggingEnabled(bool value) {
@@ -68,7 +69,7 @@ class LogBuffer extends ChangeNotifier {
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') { if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
return; return;
} }
if (_entries.length >= maxEntries) { if (_entries.length >= maxEntries) {
_entries.removeFirst(); _entries.removeFirst();
} }
@@ -78,7 +79,7 @@ class LogBuffer extends ChangeNotifier {
void startGoLogPolling() { void startGoLogPolling() {
_goLogTimer?.cancel(); _goLogTimer?.cancel();
_goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async { _goLogTimer = Timer.periodic(_goLogPollingInterval, (_) async {
await _fetchGoLogs(); await _fetchGoLogs();
}); });
} }
@@ -93,13 +94,13 @@ class LogBuffer extends ChangeNotifier {
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex); final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
final logs = result['logs'] as List<dynamic>? ?? []; final logs = result['logs'] as List<dynamic>? ?? [];
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex; final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
for (final log in logs) { for (final log in logs) {
final timestamp = log['timestamp'] as String? ?? ''; final timestamp = log['timestamp'] as String? ?? '';
final level = log['level'] as String? ?? 'INFO'; final level = log['level'] as String? ?? 'INFO';
final tag = log['tag'] as String? ?? 'Go'; final tag = log['tag'] as String? ?? 'Go';
final message = log['message'] as String? ?? ''; final message = log['message'] as String? ?? '';
DateTime parsedTime = DateTime.now(); DateTime parsedTime = DateTime.now();
if (timestamp.isNotEmpty) { if (timestamp.isNotEmpty) {
try { try {
@@ -107,25 +108,29 @@ class LogBuffer extends ChangeNotifier {
if (parts.length >= 3) { if (parts.length >= 3) {
final secParts = parts[2].split('.'); final secParts = parts[2].split('.');
parsedTime = DateTime( parsedTime = DateTime(
parsedTime.year, parsedTime.month, parsedTime.day, parsedTime.year,
int.parse(parts[0]), int.parse(parts[1]), parsedTime.month,
parsedTime.day,
int.parse(parts[0]),
int.parse(parts[1]),
int.parse(secParts[0]), int.parse(secParts[0]),
secParts.length > 1 ? int.parse(secParts[1]) : 0, secParts.length > 1 ? int.parse(secParts[1]) : 0,
); );
} }
} catch (_) { } catch (_) {}
}
} }
add(LogEntry( add(
timestamp: parsedTime, LogEntry(
level: level, timestamp: parsedTime,
tag: tag, level: level,
message: message, tag: tag,
isFromGo: true, message: message,
)); isFromGo: true,
),
);
} }
_lastGoLogIndex = nextIndex; _lastGoLogIndex = nextIndex;
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
@@ -156,27 +161,31 @@ class LogBuffer extends ChangeNotifier {
Future<String> exportWithDeviceInfo() async { Future<String> exportWithDeviceInfo() async {
final buffer = StringBuffer(); final buffer = StringBuffer();
buffer.writeln('=' * 60); buffer.writeln('=' * 60);
buffer.writeln('SPOTIFLAC LOG EXPORT'); buffer.writeln('SPOTIFLAC LOG EXPORT');
buffer.writeln('=' * 60); buffer.writeln('=' * 60);
buffer.writeln(); buffer.writeln();
buffer.writeln('--- App Information ---'); 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('Generated: ${DateTime.now().toIso8601String()}');
buffer.writeln(); buffer.writeln();
buffer.writeln('--- Device Information ---'); buffer.writeln('--- Device Information ---');
try { try {
final deviceInfo = DeviceInfoPlugin(); final deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) { if (Platform.isAndroid) {
final android = await deviceInfo.androidInfo; final android = await deviceInfo.androidInfo;
buffer.writeln('Platform: Android'); buffer.writeln('Platform: Android');
buffer.writeln('Device: ${android.manufacturer} ${android.model}'); buffer.writeln('Device: ${android.manufacturer} ${android.model}');
buffer.writeln('Brand: ${android.brand}'); 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('Device ID: ${android.id}');
buffer.writeln('Hardware: ${android.hardware}'); buffer.writeln('Hardware: ${android.hardware}');
buffer.writeln('Product: ${android.product}'); buffer.writeln('Product: ${android.product}');
@@ -196,16 +205,16 @@ class LogBuffer extends ChangeNotifier {
buffer.writeln('Failed to get device info: $e'); buffer.writeln('Failed to get device info: $e');
} }
buffer.writeln(); buffer.writeln();
buffer.writeln('--- Log Summary ---'); buffer.writeln('--- Log Summary ---');
buffer.writeln('Total Entries: ${_entries.length}'); buffer.writeln('Total Entries: ${_entries.length}');
int errorCount = 0; int errorCount = 0;
int warnCount = 0; int warnCount = 0;
int infoCount = 0; int infoCount = 0;
int debugCount = 0; int debugCount = 0;
int goCount = 0; int goCount = 0;
for (final entry in _entries) { for (final entry in _entries) {
switch (entry.level) { switch (entry.level) {
case 'ERROR': case 'ERROR':
@@ -224,23 +233,23 @@ class LogBuffer extends ChangeNotifier {
} }
if (entry.isFromGo) goCount++; if (entry.isFromGo) goCount++;
} }
buffer.writeln('Errors: $errorCount'); buffer.writeln('Errors: $errorCount');
buffer.writeln('Warnings: $warnCount'); buffer.writeln('Warnings: $warnCount');
buffer.writeln('Info: $infoCount'); buffer.writeln('Info: $infoCount');
buffer.writeln('Debug: $debugCount'); buffer.writeln('Debug: $debugCount');
buffer.writeln('From Go Backend: $goCount'); buffer.writeln('From Go Backend: $goCount');
buffer.writeln(); buffer.writeln();
buffer.writeln('=' * 60); buffer.writeln('=' * 60);
buffer.writeln('LOG ENTRIES'); buffer.writeln('LOG ENTRIES');
buffer.writeln('=' * 60); buffer.writeln('=' * 60);
buffer.writeln(); buffer.writeln();
for (final entry in _entries) { for (final entry in _entries) {
buffer.writeln(entry.toString()); buffer.writeln(entry.toString());
} }
return buffer.toString(); return buffer.toString();
} }
@@ -280,13 +289,15 @@ class BufferedOutput extends LogOutput {
final level = _levelToString(event.level); final level = _levelToString(event.level);
final message = event.lines.join('\n'); final message = event.lines.join('\n');
LogBuffer().add(LogEntry( LogBuffer().add(
timestamp: DateTime.now(), LogEntry(
level: level, timestamp: DateTime.now(),
tag: tag, level: level,
message: message, tag: tag,
)); message: message,
),
);
} }
String _levelToString(Level level) { String _levelToString(Level level) {
@@ -336,13 +347,15 @@ class AppLogger {
} }
void _addToBuffer(String level, String message, {String? error}) { void _addToBuffer(String level, String message, {String? error}) {
LogBuffer().add(LogEntry( LogBuffer().add(
timestamp: DateTime.now(), LogEntry(
level: level, timestamp: DateTime.now(),
tag: _tag, level: level,
message: message, tag: _tag,
error: error, message: message,
)); error: error,
),
);
} }
void d(String message) { void d(String message) {