diff --git a/go_backend/exports.go b/go_backend/exports.go index 909962c7..98a9b978 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -525,6 +525,12 @@ func ReadFileMetadata(filePath string) (string, error) { // Also get audio quality info quality, qualityErr := GetAudioQuality(filePath) + // Get duration from FLAC stream info + duration := 0 + if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 { + duration = int(quality.TotalSamples / int64(quality.SampleRate)) + } + result := map[string]interface{}{ "title": metadata.Title, "artist": metadata.Artist, @@ -535,6 +541,7 @@ func ReadFileMetadata(filePath string) (string, error) { "disc_number": metadata.DiscNumber, "isrc": metadata.ISRC, "lyrics": metadata.Lyrics, + "duration": duration, } // Add quality info if available @@ -980,7 +987,7 @@ func errorResponse(msg string) (string, error) { errorType := "unknown" lowerMsg := strings.ToLower(msg) - if strings.Contains(lowerMsg, "isp blocking") || + if strings.Contains(lowerMsg, "isp blocking") || strings.Contains(lowerMsg, "try using vpn") || strings.Contains(lowerMsg, "change dns") { errorType = "isp_blocked" diff --git a/go_backend/metadata.go b/go_backend/metadata.go index ccfa64fd..a6daa4bc 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -58,7 +58,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { setComment(cmt, "ALBUM", metadata.Album) setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist) setComment(cmt, "DATE", metadata.Date) - + if metadata.TrackNumber > 0 { if metadata.TotalTracks > 0 { setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)) @@ -66,15 +66,15 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber)) } } - + if metadata.DiscNumber > 0 { setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber)) } - + if metadata.ISRC != "" { setComment(cmt, "ISRC", metadata.ISRC) } - + if metadata.Description != "" { setComment(cmt, "DESCRIPTION", metadata.Description) } @@ -105,7 +105,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { f.Meta = append(f.Meta[:i], f.Meta[i+1:]...) } } - + picture, err := flacpicture.NewFromImageData( flacpicture.PictureTypeFrontCover, "Front Cover", @@ -162,7 +162,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData [] setComment(cmt, "ALBUM", metadata.Album) setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist) setComment(cmt, "DATE", metadata.Date) - + if metadata.TrackNumber > 0 { if metadata.TotalTracks > 0 { setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)) @@ -170,15 +170,15 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData [] setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber)) } } - + if metadata.DiscNumber > 0 { setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber)) } - + if metadata.ISRC != "" { setComment(cmt, "ISRC", metadata.ISRC) } - + if metadata.Description != "" { setComment(cmt, "DESCRIPTION", metadata.Description) } @@ -204,7 +204,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData [] f.Meta = append(f.Meta[:i], f.Meta[i+1:]...) } } - + picture, err := flacpicture.NewFromImageData( flacpicture.PictureTypeFrontCover, "Front Cover", @@ -276,7 +276,7 @@ func ReadMetadata(filePath string) (*Metadata, error) { fmt.Sscanf(discNum, "%d", &metadata.DiscNumber) } } - + // Try DATE variants if metadata.Date == "" { metadata.Date = getComment(cmt, "YEAR") @@ -380,13 +380,13 @@ func ExtractLyrics(filePath string) (string, error) { if err != nil { continue } - + // Try LYRICS tag first lyrics, err := cmt.Get("LYRICS") if err == nil && len(lyrics) > 0 && lyrics[0] != "" { return lyrics[0], nil } - + // Fallback to UNSYNCEDLYRICS lyrics, err = cmt.Get("UNSYNCEDLYRICS") if err == nil && len(lyrics) > 0 && lyrics[0] != "" { @@ -400,8 +400,9 @@ func ExtractLyrics(filePath string) (string, error) { // AudioQuality represents audio quality info from a FLAC file type AudioQuality struct { - BitDepth int `json:"bit_depth"` - SampleRate int `json:"sample_rate"` + BitDepth int `json:"bit_depth"` + SampleRate int `json:"sample_rate"` + TotalSamples int64 `json:"total_samples"` } // GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block @@ -419,7 +420,7 @@ func GetAudioQuality(filePath string) (AudioQuality, error) { if _, err := file.Read(marker); err != nil { return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err) } - + // Check if it's a FLAC file if string(marker) == "fLaC" { // Continue reading FLAC metadata @@ -446,12 +447,20 @@ func GetAudioQuality(filePath string) (AudioQuality, error) { // Parse bits per sample (5 bits) bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1 + // Parse total samples (36 bits: 4 bits from byte 13, all of bytes 14-17) + totalSamples := int64(streamInfo[13]&0x0F)<<32 | + int64(streamInfo[14])<<24 | + int64(streamInfo[15])<<16 | + int64(streamInfo[16])<<8 | + int64(streamInfo[17]) + return AudioQuality{ - BitDepth: bitsPerSample, - SampleRate: sampleRate, + BitDepth: bitsPerSample, + SampleRate: sampleRate, + TotalSamples: totalSamples, }, nil } - + // Check if it's an M4A/MP4 file (starts with size + "ftyp") // First 4 bytes are size, next 4 should be "ftyp" file.Seek(0, 0) // Reset to beginning @@ -459,17 +468,16 @@ func GetAudioQuality(filePath string) (AudioQuality, error) { if _, err := file.Read(header8); err != nil { return AudioQuality{}, fmt.Errorf("failed to read header: %w", err) } - + if string(header8[4:8]) == "ftyp" { // It's an M4A/MP4 file, use M4A quality reader file.Close() // Close before calling GetM4AQuality which opens the file again return GetM4AQuality(filePath) } - + return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)") } - // ======================================== // M4A (MP4/AAC) Metadata Embedding // ======================================== @@ -492,16 +500,16 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro // Find udta atom inside moov, or create one moovSize := int(data[moovPos]<<24 | data[moovPos+1]<<16 | data[moovPos+2]<<8 | data[moovPos+3]) udtaPos := findAtom(data, "udta", moovPos+8) - + // Build new metadata atoms metaAtom := buildMetaAtom(metadata, coverData) - + var newData []byte if udtaPos >= 0 && udtaPos < moovPos+moovSize { // udta exists, find meta inside it or replace udtaSize := int(data[udtaPos]<<24 | data[udtaPos+1]<<16 | data[udtaPos+2]<<8 | data[udtaPos+3]) metaPos := findAtom(data, "meta", udtaPos+8) - + if metaPos >= 0 && metaPos < udtaPos+udtaSize { // Replace existing meta atom metaSize := int(data[metaPos]<<24 | data[metaPos+1]<<16 | data[metaPos+2]<<8 | data[metaPos+3]) @@ -519,7 +527,7 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro newUdta[3] = byte(newUdtaSize) newUdta = append(newUdta, []byte("udta")...) newUdta = append(newUdta, newUdtaContent...) - + newData = append(newData, data[:udtaPos]...) newData = append(newData, newUdta...) newData = append(newData, data[udtaPos+udtaSize:]...) @@ -535,14 +543,14 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro newUdta[3] = byte(udtaSize) newUdta = append(newUdta, []byte("udta")...) newUdta = append(newUdta, udtaContent...) - + // Insert udta at end of moov insertPos := moovPos + moovSize newData = append(newData, data[:insertPos]...) newData = append(newData, newUdta...) newData = append(newData, data[insertPos:]...) } - + // Update moov size newMoovSize := moovSize + len(newData) - len(data) newData[moovPos] = byte(newMoovSize >> 24) @@ -579,52 +587,52 @@ func findAtom(data []byte, name string, offset int) int { func buildMetaAtom(metadata Metadata, coverData []byte) []byte { // Build ilst content var ilst []byte - + // ©nam - Title if metadata.Title != "" { ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...) } - + // ©ART - Artist if metadata.Artist != "" { ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...) } - + // ©alb - Album if metadata.Album != "" { ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...) } - + // aART - Album Artist if metadata.AlbumArtist != "" { ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...) } - + // ©day - Year/Date if metadata.Date != "" { ilst = append(ilst, buildTextAtom("©day", metadata.Date)...) } - + // trkn - Track Number if metadata.TrackNumber > 0 { ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...) } - + // disk - Disc Number if metadata.DiscNumber > 0 { ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...) } - + // ©lyr - Lyrics if metadata.Lyrics != "" { ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...) } - + // covr - Cover Art if len(coverData) > 0 { ilst = append(ilst, buildCoverAtom(coverData)...) } - + // Build ilst atom ilstSize := 8 + len(ilst) ilstAtom := make([]byte, 4) @@ -634,7 +642,7 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte { ilstAtom[3] = byte(ilstSize) ilstAtom = append(ilstAtom, []byte("ilst")...) ilstAtom = append(ilstAtom, ilst...) - + // Build hdlr atom (required for meta) hdlr := []byte{ 0, 0, 0, 33, // size = 33 @@ -647,11 +655,11 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte { 0, 0, 0, 0, // component flags mask 0, // null terminator } - + // Build meta atom metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr metaContent = append(metaContent, ilstAtom...) - + metaSize := 8 + len(metaContent) metaAtom := make([]byte, 4) metaAtom[0] = byte(metaSize >> 24) @@ -660,14 +668,14 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte { metaAtom[3] = byte(metaSize) metaAtom = append(metaAtom, []byte("meta")...) metaAtom = append(metaAtom, metaContent...) - + return metaAtom } // buildTextAtom builds a text metadata atom (©nam, ©ART, etc.) func buildTextAtom(name, value string) []byte { valueBytes := []byte(value) - + // data atom dataSize := 16 + len(valueBytes) dataAtom := make([]byte, 4) @@ -679,7 +687,7 @@ func buildTextAtom(name, value string) []byte { dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8 dataAtom = append(dataAtom, 0, 0, 0, 0) // locale dataAtom = append(dataAtom, valueBytes...) - + // container atom atomSize := 8 + len(dataAtom) atom := make([]byte, 4) @@ -689,7 +697,7 @@ func buildTextAtom(name, value string) []byte { atom[3] = byte(atomSize) atom = append(atom, []byte(name)...) atom = append(atom, dataAtom...) - + return atom } @@ -706,7 +714,7 @@ func buildTrackNumberAtom(track, total int) []byte { byte(total >> 8), byte(total), // total tracks 0, 0, // padding } - + // trkn atom atomSize := 8 + len(dataAtom) atom := make([]byte, 4) @@ -716,7 +724,7 @@ func buildTrackNumberAtom(track, total int) []byte { atom[3] = byte(atomSize) atom = append(atom, []byte("trkn")...) atom = append(atom, dataAtom...) - + return atom } @@ -732,7 +740,7 @@ func buildDiscNumberAtom(disc, total int) []byte { byte(disc >> 8), byte(disc), // disc number byte(total >> 8), byte(total), // total discs } - + // disk atom atomSize := 8 + len(dataAtom) atom := make([]byte, 4) @@ -742,7 +750,7 @@ func buildDiscNumberAtom(disc, total int) []byte { atom[3] = byte(atomSize) atom = append(atom, []byte("disk")...) atom = append(atom, dataAtom...) - + return atom } @@ -753,7 +761,7 @@ func buildCoverAtom(coverData []byte) []byte { if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' { imageType = 14 // PNG } - + // data atom dataSize := 16 + len(coverData) dataAtom := make([]byte, 4) @@ -765,7 +773,7 @@ func buildCoverAtom(coverData []byte) []byte { dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG dataAtom = append(dataAtom, 0, 0, 0, 0) // locale dataAtom = append(dataAtom, coverData...) - + // covr atom atomSize := 8 + len(dataAtom) atom := make([]byte, 4) @@ -775,7 +783,7 @@ func buildCoverAtom(coverData []byte) []byte { atom[3] = byte(atomSize) atom = append(atom, []byte("covr")...) atom = append(atom, dataAtom...) - + return atom } diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 792aa774..9abba0d4 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -82,26 +82,27 @@ class DownloadHistoryItem { 'sampleRate': sampleRate, }; - factory DownloadHistoryItem.fromJson(Map json) => DownloadHistoryItem( - id: json['id'] as String, - trackName: json['trackName'] as String, - artistName: json['artistName'] as String, - albumName: json['albumName'] as String, - albumArtist: json['albumArtist'] as String?, - coverUrl: json['coverUrl'] as String?, - filePath: json['filePath'] as String, - service: json['service'] as String, - downloadedAt: DateTime.parse(json['downloadedAt'] as String), - isrc: json['isrc'] as String?, - spotifyId: json['spotifyId'] as String?, - trackNumber: json['trackNumber'] as int?, - discNumber: json['discNumber'] as int?, - duration: json['duration'] as int?, - releaseDate: json['releaseDate'] as String?, - quality: json['quality'] as String?, - bitDepth: json['bitDepth'] as int?, - sampleRate: json['sampleRate'] as int?, - ); + factory DownloadHistoryItem.fromJson(Map json) => + DownloadHistoryItem( + id: json['id'] as String, + trackName: json['trackName'] as String, + artistName: json['artistName'] as String, + albumName: json['albumName'] as String, + albumArtist: json['albumArtist'] as String?, + coverUrl: json['coverUrl'] as String?, + filePath: json['filePath'] as String, + service: json['service'] as String, + downloadedAt: DateTime.parse(json['downloadedAt'] as String), + isrc: json['isrc'] as String?, + spotifyId: json['spotifyId'] as String?, + trackNumber: json['trackNumber'] as int?, + discNumber: json['discNumber'] as int?, + duration: json['duration'] as int?, + releaseDate: json['releaseDate'] as String?, + quality: json['quality'] as String?, + bitDepth: json['bitDepth'] as int?, + sampleRate: json['sampleRate'] as int?, + ); } // Download History State @@ -110,13 +111,14 @@ class DownloadHistoryState { final Set _downloadedSpotifyIds; // Cache for O(1) lookup DownloadHistoryState({this.items = const []}) - : _downloadedSpotifyIds = items - .where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty) - .map((item) => item.spotifyId!) - .toSet(); + : _downloadedSpotifyIds = items + .where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty) + .map((item) => item.spotifyId!) + .toSet(); /// Check if a track has been downloaded (by Spotify ID) - bool isDownloaded(String spotifyId) => _downloadedSpotifyIds.contains(spotifyId); + bool isDownloaded(String spotifyId) => + _downloadedSpotifyIds.contains(spotifyId); DownloadHistoryState copyWith({List? items}) { return DownloadHistoryState(items: items ?? this.items); @@ -150,7 +152,9 @@ class DownloadHistoryNotifier extends Notifier { final jsonStr = prefs.getString(_storageKey); if (jsonStr != null && jsonStr.isNotEmpty) { final List jsonList = jsonDecode(jsonStr); - final items = jsonList.map((e) => DownloadHistoryItem.fromJson(e as Map)).toList(); + final items = jsonList + .map((e) => DownloadHistoryItem.fromJson(e as Map)) + .toList(); state = state.copyWith(items: items); _historyLog.i('Loaded ${items.length} items from storage'); } else { @@ -210,9 +214,10 @@ class DownloadHistoryNotifier extends Notifier { } // Download History Provider -final downloadHistoryProvider = NotifierProvider( - DownloadHistoryNotifier.new, -); +final downloadHistoryProvider = + NotifierProvider( + DownloadHistoryNotifier.new, + ); class DownloadQueueState { final List items; @@ -261,10 +266,19 @@ class DownloadQueueState { ); } - int get queuedCount => items.where((i) => i.status == DownloadStatus.queued || i.status == DownloadStatus.downloading).length; - int get completedCount => items.where((i) => i.status == DownloadStatus.completed).length; - int get failedCount => items.where((i) => i.status == DownloadStatus.failed).length; - int get activeDownloadsCount => items.where((i) => i.status == DownloadStatus.downloading).length; + int get queuedCount => items + .where( + (i) => + i.status == DownloadStatus.queued || + i.status == DownloadStatus.downloading, + ) + .length; + int get completedCount => + items.where((i) => i.status == DownloadStatus.completed).length; + int get failedCount => + items.where((i) => i.status == DownloadStatus.failed).length; + int get activeDownloadsCount => + items.where((i) => i.status == DownloadStatus.downloading).length; } // Download Queue Notifier (Riverpod 3.x) @@ -272,7 +286,8 @@ class DownloadQueueNotifier extends Notifier { Timer? _progressTimer; int _downloadCount = 0; // Counter for connection cleanup static const _cleanupInterval = 50; // Cleanup every 50 downloads - static const _queueStorageKey = 'download_queue'; // Storage key for queue persistence + static const _queueStorageKey = + 'download_queue'; // Storage key for queue persistence final NotificationService _notificationService = NotificationService(); int _totalQueuedAtStart = 0; // Track total items when queue started int _completedInSession = 0; // Track completed downloads in current session @@ -286,7 +301,7 @@ class DownloadQueueNotifier extends Notifier { _progressTimer?.cancel(); _progressTimer = null; }); - + // Initialize output directory and load persisted queue asynchronously Future.microtask(() async { await _initOutputDir(); @@ -299,14 +314,16 @@ class DownloadQueueNotifier extends Notifier { Future _loadQueueFromStorage() async { if (_isLoaded) return; _isLoaded = true; - + try { final prefs = await SharedPreferences.getInstance(); final jsonStr = prefs.getString(_queueStorageKey); if (jsonStr != null && jsonStr.isNotEmpty) { final List jsonList = jsonDecode(jsonStr); - final items = jsonList.map((e) => DownloadItem.fromJson(e as Map)).toList(); - + final items = jsonList + .map((e) => DownloadItem.fromJson(e as Map)) + .toList(); + // Reset downloading items to queued (they were interrupted) final restoredItems = items.map((item) { if (item.status == DownloadStatus.downloading) { @@ -314,16 +331,16 @@ class DownloadQueueNotifier extends Notifier { } return item; }).toList(); - + // Only restore queued/downloading items (not completed/failed/skipped) - final pendingItems = restoredItems.where((item) => - item.status == DownloadStatus.queued - ).toList(); - + final pendingItems = restoredItems + .where((item) => item.status == DownloadStatus.queued) + .toList(); + if (pendingItems.isNotEmpty) { state = state.copyWith(items: pendingItems); _log.i('Restored ${pendingItems.length} pending items from storage'); - + // Auto-resume queue processing Future.microtask(() => _processQueue()); } else { @@ -343,13 +360,16 @@ class DownloadQueueNotifier extends Notifier { Future _saveQueueToStorage() async { try { final prefs = await SharedPreferences.getInstance(); - + // Only persist queued and downloading items - final pendingItems = state.items.where((item) => - item.status == DownloadStatus.queued || - item.status == DownloadStatus.downloading - ).toList(); - + final pendingItems = state.items + .where( + (item) => + item.status == DownloadStatus.queued || + item.status == DownloadStatus.downloading, + ) + .toList(); + if (pendingItems.isEmpty) { // Clear storage if no pending items await prefs.remove(_queueStorageKey); @@ -367,31 +387,37 @@ class DownloadQueueNotifier extends Notifier { /// Start multi-progress polling for all downloads (sequential and parallel) void _startMultiProgressPolling() { _progressTimer?.cancel(); - _progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async { + _progressTimer = Timer.periodic(const Duration(milliseconds: 500), ( + timer, + ) async { try { final allProgress = await PlatformBridge.getAllDownloadProgress(); final items = allProgress['items'] as Map? ?? {}; - + bool hasFinalizingItem = false; String? finalizingTrackName; String? finalizingArtistName; - + for (final entry in items.entries) { final itemId = entry.key; final itemProgress = entry.value as Map; final bytesReceived = itemProgress['bytes_received'] as int? ?? 0; final bytesTotal = itemProgress['bytes_total'] as int? ?? 0; - final speedMBps = (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0; - final isDownloading = itemProgress['is_downloading'] as bool? ?? false; + final speedMBps = + (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0; + final isDownloading = + itemProgress['is_downloading'] as bool? ?? false; final status = itemProgress['status'] as String? ?? 'downloading'; - + // Check if status is "finalizing" (embedding metadata) // Only trust finalizing status if bytesTotal > 0 (download actually happened) if (status == 'finalizing' && bytesTotal > 0) { updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0); - + // Track finalizing item for notification - final currentItem = state.items.where((i) => i.id == itemId).firstOrNull; + final currentItem = state.items + .where((i) => i.id == itemId) + .firstOrNull; if (currentItem != null) { hasFinalizingItem = true; finalizingTrackName = currentItem.track.name; @@ -399,33 +425,38 @@ class DownloadQueueNotifier extends Notifier { } continue; } - + // Use progress from backend if available (handles both explicit progress and byte-based) - final progressFromBackend = (itemProgress['progress'] as num?)?.toDouble() ?? 0.0; - + final progressFromBackend = + (itemProgress['progress'] as num?)?.toDouble() ?? 0.0; + if (isDownloading) { double percentage = 0.0; if (bytesTotal > 0) { - // Calculate from bytes if available for precision - percentage = bytesReceived / bytesTotal; + // Calculate from bytes if available for precision + percentage = bytesReceived / bytesTotal; } else { - // Fallback to backend-reported progress (e.g. for DASH segments) - percentage = progressFromBackend; + // Fallback to backend-reported progress (e.g. for DASH segments) + percentage = progressFromBackend; } - + updateProgress(itemId, percentage, speedMBps: speedMBps); - + // Log progress for each item with speed final mbReceived = bytesReceived / (1024 * 1024); final mbTotal = bytesTotal / (1024 * 1024); if (bytesTotal > 0) { - _log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s'); + _log.d( + 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s', + ); } else { - _log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s'); + _log.d( + 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s', + ); } } } - + // Show finalizing notification if any item is finalizing (takes priority) if (hasFinalizingItem && finalizingTrackName != null) { _notificationService.showDownloadFinalizing( @@ -434,43 +465,46 @@ class DownloadQueueNotifier extends Notifier { ); return; // Don't show download progress notification } - + // Update notification with active downloads if (items.isNotEmpty) { final firstEntry = items.entries.first; final firstProgress = firstEntry.value as Map; final bytesReceived = firstProgress['bytes_received'] as int? ?? 0; final bytesTotal = firstProgress['bytes_total'] as int? ?? 0; - + // Find downloading items (not finalizing) - final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading).toList(); + final downloadingItems = state.items + .where((i) => i.status == DownloadStatus.downloading) + .toList(); if (downloadingItems.isNotEmpty) { // Show single track name if only 1 download, otherwise show count - final trackName = downloadingItems.length == 1 - ? downloadingItems.first.track.name + final trackName = downloadingItems.length == 1 + ? downloadingItems.first.track.name : '${downloadingItems.length} downloads'; - final artistName = downloadingItems.length == 1 - ? downloadingItems.first.track.artistName + final artistName = downloadingItems.length == 1 + ? downloadingItems.first.track.artistName : 'Downloading...'; - + // Calculate notification progress values int notifProgress = bytesReceived; int notifTotal = bytesTotal; - + if (bytesTotal <= 0) { // Fallback to percentage for DASH/unknown size - final progressPercent = (firstProgress['progress'] as num?)?.toDouble() ?? 0.0; + final progressPercent = + (firstProgress['progress'] as num?)?.toDouble() ?? 0.0; notifProgress = (progressPercent * 100).toInt(); notifTotal = 100; } - + _notificationService.showDownloadProgress( trackName: trackName, artistName: artistName, progress: notifProgress, total: notifTotal > 0 ? notifTotal : 1, ); - + // Update foreground service notification (Android) if (Platform.isAndroid) { PlatformBridge.updateDownloadServiceProgress( @@ -509,7 +543,9 @@ class DownloadQueueNotifier extends Notifier { // Android: Use external storage Music folder final dir = await getExternalStorageDirectory(); if (dir != null) { - final musicDir = Directory('${dir.parent.parent.parent.parent.path}/Music/SpotiFLAC'); + final musicDir = Directory( + '${dir.parent.parent.parent.parent.path}/Music/SpotiFLAC', + ); if (!await musicDir.exists()) { await musicDir.create(recursive: true); } @@ -543,11 +579,11 @@ class DownloadQueueNotifier extends Notifier { /// Build output directory based on folder organization setting Future _buildOutputDir(Track track, String folderOrganization) async { String baseDir = state.outputDir; - + if (folderOrganization == 'none') { return baseDir; } - + // Sanitize folder names (remove invalid characters) String sanitize(String name) { return name @@ -555,7 +591,7 @@ class DownloadQueueNotifier extends Notifier { .replaceAll(RegExp(r'\.+$'), '') // Remove trailing dots .trim(); } - + String subPath = ''; switch (folderOrganization) { case 'artist': @@ -572,7 +608,7 @@ class DownloadQueueNotifier extends Notifier { subPath = '$artistName${Platform.pathSeparator}$albumName'; break; } - + if (subPath.isNotEmpty) { final fullPath = '$baseDir${Platform.pathSeparator}$subPath'; final dir = Directory(fullPath); @@ -582,13 +618,15 @@ class DownloadQueueNotifier extends Notifier { } return fullPath; } - + return baseDir; } void updateSettings(AppSettings settings) { state = state.copyWith( - outputDir: settings.downloadDirectory.isNotEmpty ? settings.downloadDirectory : state.outputDir, + outputDir: settings.downloadDirectory.isNotEmpty + ? settings.downloadDirectory + : state.outputDir, filenameFormat: settings.filenameFormat, audioQuality: settings.audioQuality, autoFallback: settings.autoFallback, @@ -600,8 +638,9 @@ class DownloadQueueNotifier extends Notifier { // Sync settings before adding to queue final settings = ref.read(settingsProvider); updateSettings(settings); - - final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}'; + + final id = + '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}'; final item = DownloadItem( id: id, track: track, @@ -621,13 +660,18 @@ class DownloadQueueNotifier extends Notifier { return id; } - void addMultipleToQueue(List tracks, String service, {String? qualityOverride}) { + void addMultipleToQueue( + List tracks, + String service, { + String? qualityOverride, + }) { // Sync settings before adding to queue final settings = ref.read(settingsProvider); updateSettings(settings); - + final newItems = tracks.map((track) { - final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}'; + final id = + '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}'; return DownloadItem( id: id, track: track, @@ -646,7 +690,15 @@ class DownloadQueueNotifier extends Notifier { } } - void updateItemStatus(String id, DownloadStatus status, {double? progress, double? speedMBps, String? filePath, String? error, DownloadErrorType? errorType}) { + void updateItemStatus( + String id, + DownloadStatus status, { + double? progress, + double? speedMBps, + String? filePath, + String? error, + DownloadErrorType? errorType, + }) { final items = state.items.map((item) { if (item.id == id) { return item.copyWith( @@ -662,17 +714,22 @@ class DownloadQueueNotifier extends Notifier { }).toList(); state = state.copyWith(items: items); - + // Persist queue when status changes to completed/failed/skipped (item removed from pending) - if (status == DownloadStatus.completed || - status == DownloadStatus.failed || + if (status == DownloadStatus.completed || + status == DownloadStatus.failed || status == DownloadStatus.skipped) { _saveQueueToStorage(); } } void updateProgress(String id, double progress, {double? speedMBps}) { - updateItemStatus(id, DownloadStatus.downloading, progress: progress, speedMBps: speedMBps); + updateItemStatus( + id, + DownloadStatus.downloading, + progress: progress, + speedMBps: speedMBps, + ); } void cancelItem(String id) { @@ -680,11 +737,14 @@ class DownloadQueueNotifier extends Notifier { } void clearCompleted() { - final items = state.items.where((item) => - item.status != DownloadStatus.completed && - item.status != DownloadStatus.failed && - item.status != DownloadStatus.skipped - ).toList(); + final items = state.items + .where( + (item) => + item.status != DownloadStatus.completed && + item.status != DownloadStatus.failed && + item.status != DownloadStatus.skipped, + ) + .toList(); state = state.copyWith(items: items); _saveQueueToStorage(); // Persist queue @@ -732,24 +792,29 @@ class DownloadQueueNotifier extends Notifier { _log.w('retryItem: Item not found: $id'); return; } - + // Only retry if status is failed or skipped - if (item.status != DownloadStatus.failed && item.status != DownloadStatus.skipped) { + if (item.status != DownloadStatus.failed && + item.status != DownloadStatus.skipped) { _log.w('retryItem: Item status is ${item.status}, not retrying'); return; } - + _log.i('Retrying item: ${item.track.name} (id: $id)'); - + final items = state.items.map((i) { if (i.id == id) { - return i.copyWith(status: DownloadStatus.queued, progress: 0, error: null); + return i.copyWith( + status: DownloadStatus.queued, + progress: 0, + error: null, + ); } return i; }).toList(); state = state.copyWith(items: items); _saveQueueToStorage(); // Persist queue - + // Start processing if not already running if (!state.isProcessing) { _log.d('Starting queue processing for retry'); @@ -774,9 +839,10 @@ class DownloadQueueNotifier extends Notifier { if (coverUrl != null && coverUrl.isNotEmpty) { try { final tempDir = await getTemporaryDirectory(); - final uniqueId = '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; + final uniqueId = + '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; coverPath = '${tempDir.path}/cover_$uniqueId.jpg'; - + // Download cover using HTTP final httpClient = HttpClient(); final request = await httpClient.getUrl(Uri.parse(coverUrl)); @@ -802,37 +868,37 @@ class DownloadQueueNotifier extends Notifier { try { // Use FFmpeg to embed cover art AND text metadata // FFmpeg can embed cover art to FLAC and also set tags - + // Construct metadata map final metadata = { 'TITLE': track.name, 'ARTIST': track.artistName, 'ALBUM': track.albumName, }; - + if (track.albumArtist != null) { metadata['ALBUMARTIST'] = track.albumArtist!; } - + if (track.trackNumber != null) { metadata['TRACKNUMBER'] = track.trackNumber.toString(); metadata['TRACK'] = track.trackNumber.toString(); // Compatibility } - + if (track.discNumber != null) { metadata['DISCNUMBER'] = track.discNumber.toString(); metadata['DISC'] = track.discNumber.toString(); // Compatibility } - + if (track.releaseDate != null) { metadata['DATE'] = track.releaseDate!; metadata['YEAR'] = track.releaseDate!.split('-').first; } - + if (track.isrc != null) { metadata['ISRC'] = track.isrc!; } - + _log.d('Metadata map content: $metadata'); // Fetch Lyrics (Critical for M4A->FLAC conversion parity) @@ -845,40 +911,42 @@ class DownloadQueueNotifier extends Notifier { track.artistName, filePath: '', // No local file path yet (processed in memory) ); - + if (lrcContent.isNotEmpty) { metadata['LYRICS'] = lrcContent; metadata['UNSYNCEDLYRICS'] = lrcContent; // Fallback for some players _log.d('Lyrics fetched for embedding (${lrcContent.length} chars)'); } } catch (e) { - _log.w('Failed to fetch lyrics for embedding: $e'); + _log.w('Failed to fetch lyrics for embedding: $e'); } - + _log.d('Generating tags for FLAC: $metadata'); - + // Perform embedding (cover + text metadata) // Note: FFmpegService.embedMetadata handles safe temp file creation final result = await FFmpegService.embedMetadata( flacPath: flacPath, - coverPath: coverPath != null && await File(coverPath).exists() ? coverPath : null, + coverPath: coverPath != null && await File(coverPath).exists() + ? coverPath + : null, metadata: metadata, ); - + if (result != null) { _log.d('Metadata and cover embedded via FFmpeg'); } else { _log.w('FFmpeg metadata/cover embed failed'); } - + // Clean up cover file if it exists if (coverPath != null) { try { final coverFile = File(coverPath); if (await coverFile.exists()) { - // In Android 10+ scoped storage, we can't easily delete if we didn't create it - // in this session or if it's not in our app dir. - // But coverPath is typically in temp dir now. + // In Android 10+ scoped storage, we can't easily delete if we didn't create it + // in this session or if it's not in our app dir. + // But coverPath is typically in temp dir now. await coverFile.delete(); } } catch (_) {} @@ -890,12 +958,14 @@ class DownloadQueueNotifier extends Notifier { Future _processQueue() async { if (state.isProcessing) return; // Prevent multiple concurrent processing - + state = state.copyWith(isProcessing: true); _log.i('Starting queue processing...'); // Track total items at start for notification - _totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length; + _totalQueuedAtStart = state.items + .where((i) => i.status == DownloadStatus.queued) + .length; _completedInSession = 0; _failedInSession = 0; @@ -922,7 +992,7 @@ class DownloadQueueNotifier extends Notifier { _log.d('Output dir empty, initializing...'); await _initOutputDir(); } - + // If still empty, use fallback if (state.outputDir.isEmpty) { _log.d('Using fallback directory...'); @@ -933,7 +1003,7 @@ class DownloadQueueNotifier extends Notifier { } state = state.copyWith(outputDir: musicDir.path); } - + _log.d('Output directory: ${state.outputDir}'); _log.d('Concurrent downloads: ${state.concurrentDownloads}'); @@ -945,7 +1015,7 @@ class DownloadQueueNotifier extends Notifier { } _stopProgressPolling(); - + // Stop foreground service (Android only) if (Platform.isAndroid) { try { @@ -955,7 +1025,7 @@ class DownloadQueueNotifier extends Notifier { _log.e('Failed to stop foreground service: $e'); } } - + // Final cleanup after queue finishes if (_downloadCount > 0) { _log.d('Final connection cleanup...'); @@ -966,23 +1036,29 @@ class DownloadQueueNotifier extends Notifier { } _downloadCount = 0; } - + // Show queue completion notification - _log.i('Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart'); + _log.i( + 'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart', + ); if (_totalQueuedAtStart > 0) { await _notificationService.showQueueComplete( completedCount: _completedInSession, failedCount: _failedInSession, ); } - + _log.i('Queue processing finished'); state = state.copyWith(isProcessing: false, currentDownload: null); - + // Check if there are new queued items (e.g., from retry) and restart if needed - final hasQueuedItems = state.items.any((item) => item.status == DownloadStatus.queued); + final hasQueuedItems = state.items.any( + (item) => item.status == DownloadStatus.queued, + ); if (hasQueuedItems) { - _log.i('Found queued items after processing finished, restarting queue...'); + _log.i( + 'Found queued items after processing finished, restarting queue...', + ); Future.microtask(() => _processQueue()); } } @@ -991,7 +1067,7 @@ class DownloadQueueNotifier extends Notifier { Future _processQueueSequential() async { // Start multi-progress polling (works for both sequential and parallel) _startMultiProgressPolling(); - + while (true) { // Check if paused if (state.isPaused) { @@ -999,31 +1075,41 @@ class DownloadQueueNotifier extends Notifier { await Future.delayed(const Duration(milliseconds: 500)); continue; } - + // Re-read state to get latest items (important for retry) final currentItems = state.items; final nextItem = currentItems.firstWhere( (item) => item.status == DownloadStatus.queued, orElse: () => DownloadItem( id: '', - track: const Track(id: '', name: '', artistName: '', albumName: '', duration: 0), + track: const Track( + id: '', + name: '', + artistName: '', + albumName: '', + duration: 0, + ), service: '', createdAt: DateTime.now(), ), ); if (nextItem.id.isEmpty) { - _log.d('No more items to process (checked ${currentItems.length} items)'); + _log.d( + 'No more items to process (checked ${currentItems.length} items)', + ); break; } - _log.d('Processing next item: ${nextItem.track.name} (id: ${nextItem.id})'); + _log.d( + 'Processing next item: ${nextItem.track.name} (id: ${nextItem.id})', + ); await _downloadSingleItem(nextItem); - + // Clear item progress after download completes PlatformBridge.clearItemProgress(nextItem.id).catchError((_) {}); } - + // Stop polling when queue is done _stopProgressPolling(); } @@ -1032,10 +1118,10 @@ class DownloadQueueNotifier extends Notifier { Future _processQueueParallel() async { final maxConcurrent = state.concurrentDownloads; final activeDownloads = >{}; // Map item ID to future - + // Start multi-progress polling (shared with sequential mode) _startMultiProgressPolling(); - + while (true) { // Check if paused - don't start new downloads but let active ones finish if (state.isPaused) { @@ -1047,44 +1133,50 @@ class DownloadQueueNotifier extends Notifier { } continue; } - + // Get queued items - final queuedItems = state.items.where((item) => item.status == DownloadStatus.queued).toList(); - + final queuedItems = state.items + .where((item) => item.status == DownloadStatus.queued) + .toList(); + if (queuedItems.isEmpty && activeDownloads.isEmpty) { _log.d('No more items to process'); break; } - + // Start new downloads up to max concurrent limit - while (activeDownloads.length < maxConcurrent && queuedItems.isNotEmpty && !state.isPaused) { + while (activeDownloads.length < maxConcurrent && + queuedItems.isNotEmpty && + !state.isPaused) { final item = queuedItems.removeAt(0); - + // Mark as downloading immediately to prevent double-processing updateItemStatus(item.id, DownloadStatus.downloading); - + // Create the download future final future = _downloadSingleItem(item).whenComplete(() { activeDownloads.remove(item.id); // Clear item progress after download completes PlatformBridge.clearItemProgress(item.id).catchError((_) {}); }); - + activeDownloads[item.id] = future; - _log.d('Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)'); + _log.d( + 'Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)', + ); } - + // Wait for at least one download to complete before checking for more if (activeDownloads.isNotEmpty) { await Future.any(activeDownloads.values); } } - + // Wait for all remaining downloads to complete if (activeDownloads.isNotEmpty) { await Future.wait(activeDownloads.values); } - + // Stop polling when queue is done _stopProgressPolling(); } @@ -1093,63 +1185,81 @@ class DownloadQueueNotifier extends Notifier { Future _downloadSingleItem(DownloadItem item) async { _log.d('Processing: ${item.track.name} by ${item.track.artistName}'); _log.d('Cover URL: ${item.track.coverUrl}'); - + // Set currentDownload for UI reference state = state.copyWith(currentDownload: item); - + updateItemStatus(item.id, DownloadStatus.downloading); try { // Get folder organization setting and build output directory final settings = ref.read(settingsProvider); - + // Metadata Enrichment: // If track number is missing/0 (common from Search results), fetch full metadata // This ensures the downloaded file has correct tags (Track, Disc, Year) Track trackToDownload = item.track; // Enrich metadata if ISRC or track number is missing (common from Search results) // ISRC is critical for accurate track matching on streaming services - final needsEnrichment = trackToDownload.id.startsWith('deezer:') && - (trackToDownload.isrc == null || trackToDownload.isrc!.isEmpty || - trackToDownload.trackNumber == null || trackToDownload.trackNumber == 0); - + final needsEnrichment = + trackToDownload.id.startsWith('deezer:') && + (trackToDownload.isrc == null || + trackToDownload.isrc!.isEmpty || + trackToDownload.trackNumber == null || + trackToDownload.trackNumber == 0); + if (needsEnrichment) { try { - _log.d('Enriching incomplete metadata for Deezer track: ${trackToDownload.name}'); - _log.d('Current ISRC: ${trackToDownload.isrc}, TrackNumber: ${trackToDownload.trackNumber}'); + _log.d( + 'Enriching incomplete metadata for Deezer track: ${trackToDownload.name}', + ); + _log.d( + 'Current ISRC: ${trackToDownload.isrc}, TrackNumber: ${trackToDownload.trackNumber}', + ); final rawId = trackToDownload.id.split(':')[1]; _log.d('Fetching full metadata for Deezer ID: $rawId'); - final fullData = await PlatformBridge.getDeezerMetadata('track', rawId); + final fullData = await PlatformBridge.getDeezerMetadata( + 'track', + rawId, + ); _log.d('Got response keys: ${fullData.keys.toList()}'); - + if (fullData.containsKey('track')) { - // Parse Go backend response (snake_case) to Track - final trackData = fullData['track']; - _log.d('Track data type: ${trackData.runtimeType}'); - if (trackData is Map) { - final data = trackData; - _log.d('Track data keys: ${data.keys.toList()}'); - _log.d('ISRC from API: ${data['isrc']}'); - trackToDownload = Track( - id: (data['spotify_id'] as String?) ?? trackToDownload.id, - name: (data['name'] as String?) ?? trackToDownload.name, - artistName: (data['artists'] as String?) ?? trackToDownload.artistName, - albumName: (data['album_name'] as String?) ?? trackToDownload.albumName, - albumArtist: data['album_artist'] as String?, - coverUrl: data['images'] as String?, - // duration_ms from Go is in milliseconds, Track.duration is in seconds - duration: ((data['duration_ms'] as int?) ?? (trackToDownload.duration * 1000)) ~/ 1000, - isrc: (data['isrc'] as String?) ?? trackToDownload.isrc, - trackNumber: data['track_number'] as int?, - discNumber: data['disc_number'] as int?, - releaseDate: data['release_date'] as String?, - deezerId: rawId, - availability: trackToDownload.availability, - ); - _log.d('Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}'); - } else { - _log.w('Unexpected track data type: ${trackData.runtimeType}'); - } + // Parse Go backend response (snake_case) to Track + final trackData = fullData['track']; + _log.d('Track data type: ${trackData.runtimeType}'); + if (trackData is Map) { + final data = trackData; + _log.d('Track data keys: ${data.keys.toList()}'); + _log.d('ISRC from API: ${data['isrc']}'); + trackToDownload = Track( + id: (data['spotify_id'] as String?) ?? trackToDownload.id, + name: (data['name'] as String?) ?? trackToDownload.name, + artistName: + (data['artists'] as String?) ?? trackToDownload.artistName, + albumName: + (data['album_name'] as String?) ?? + trackToDownload.albumName, + albumArtist: data['album_artist'] as String?, + coverUrl: data['images'] as String?, + // duration_ms from Go is in milliseconds, Track.duration is in seconds + duration: + ((data['duration_ms'] as int?) ?? + (trackToDownload.duration * 1000)) ~/ + 1000, + isrc: (data['isrc'] as String?) ?? trackToDownload.isrc, + trackNumber: data['track_number'] as int?, + discNumber: data['disc_number'] as int?, + releaseDate: data['release_date'] as String?, + deezerId: rawId, + availability: trackToDownload.availability, + ); + _log.d( + 'Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}', + ); + } else { + _log.w('Unexpected track data type: ${trackData.runtimeType}'); + } } else { _log.w('Response does not contain track key'); } @@ -1158,17 +1268,22 @@ class DownloadQueueNotifier extends Notifier { _log.w('Stack trace: $stack'); } } - - final outputDir = await _buildOutputDir(trackToDownload, settings.folderOrganization); - + + final outputDir = await _buildOutputDir( + trackToDownload, + settings.folderOrganization, + ); + // Use quality override if set, otherwise use default from settings final quality = item.qualityOverride ?? state.audioQuality; - + Map result; if (state.autoFallback) { _log.d('Using auto-fallback mode'); - _log.d('Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}'); + _log.d( + 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', + ); _log.d('Output dir: $outputDir'); result = await PlatformBridge.downloadWithFallback( isrc: trackToDownload.isrc ?? '', @@ -1186,7 +1301,8 @@ class DownloadQueueNotifier extends Notifier { releaseDate: trackToDownload.releaseDate, preferredService: item.service, itemId: item.id, // Pass item ID for progress tracking - durationMs: trackToDownload.duration, // Duration in ms for verification + durationMs: + trackToDownload.duration, // Duration in ms for verification ); } else { result = await PlatformBridge.downloadTrack( @@ -1205,14 +1321,18 @@ class DownloadQueueNotifier extends Notifier { discNumber: trackToDownload.discNumber ?? 1, releaseDate: trackToDownload.releaseDate, itemId: item.id, // Pass item ID for progress tracking - durationMs: trackToDownload.duration, // Duration in ms for verification + durationMs: + trackToDownload.duration, // Duration in ms for verification ); } - + _log.d('Result: $result'); - + // Check if item was cancelled while downloading - final currentItem = state.items.firstWhere((i) => i.id == item.id, orElse: () => item); + final currentItem = state.items.firstWhere( + (i) => i.id == item.id, + orElse: () => item, + ); if (currentItem.status == DownloadStatus.skipped) { _log.i('Download was cancelled, skipping result processing'); // Delete the downloaded file if it exists @@ -1230,84 +1350,107 @@ class DownloadQueueNotifier extends Notifier { } return; } - + if (result['success'] == true) { var filePath = result['file_path'] as String?; _log.i('Download success, file: $filePath'); - + // Get actual quality from response (if available) final actualBitDepth = result['actual_bit_depth'] as int?; final actualSampleRate = result['actual_sample_rate'] as int?; String actualQuality = quality; // Default to requested quality - + if (actualBitDepth != null && actualBitDepth > 0) { // Format: "24-bit/96kHz" or "16-bit/44.1kHz" - final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0 - ? (actualSampleRate / 1000).toStringAsFixed(actualSampleRate % 1000 == 0 ? 0 : 1) + final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0 + ? (actualSampleRate / 1000).toStringAsFixed( + actualSampleRate % 1000 == 0 ? 0 : 1, + ) : '?'; actualQuality = '$actualBitDepth-bit/${sampleRateKHz}kHz'; _log.i('Actual quality: $actualQuality'); } - + // M4A files from Tidal DASH streams - try to convert to FLAC // M4A files from Tidal DASH streams - try to convert to FLAC if (filePath != null && filePath.endsWith('.m4a')) { - _log.d('M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...'); - + _log.d( + 'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...', + ); + try { final file = File(filePath); if (!await file.exists()) { - _log.e('File does not exist at path: $filePath'); + _log.e('File does not exist at path: $filePath'); } else { final length = await file.length(); _log.i('File size before conversion: ${length / 1024} KB'); - + if (length < 1024) { - _log.w('File is too small (<1KB), skipping conversion. Download might be corrupt.'); + _log.w( + 'File is too small (<1KB), skipping conversion. Download might be corrupt.', + ); } else { - updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.95); + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.95, + ); final flacPath = await FFmpegService.convertM4aToFlac(filePath); - + if (flacPath != null) { filePath = flacPath; _log.d('Converted to FLAC: $flacPath'); - + // After conversion, embed metadata and cover to the new FLAC file _log.d('Embedding metadata and cover to converted FLAC...'); try { // Update track with actual metadata from backend result (if available) // This creates the most accurate metadata possible (from the service itself) Track finalTrack = trackToDownload; - if (result.containsKey('track_number') || result.containsKey('release_date')) { - _log.d('Using metadata from backend response for embedding'); - final backendTrackNum = result['track_number'] as int?; - final backendDiscNum = result['disc_number'] as int?; - final backendYear = result['release_date'] as String?; - final backendAlbum = result['album'] as String?; - - _log.d('Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear'); - - // Create updated track object with safety check for 0/null - final newTrackNumber = (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : trackToDownload.trackNumber; - final newDiscNumber = (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : trackToDownload.discNumber; - - _log.d('Final metadata for embedding - Track: $newTrackNumber, Disc: $newDiscNumber'); + if (result.containsKey('track_number') || + result.containsKey('release_date')) { + _log.d( + 'Using metadata from backend response for embedding', + ); + final backendTrackNum = result['track_number'] as int?; + final backendDiscNum = result['disc_number'] as int?; + final backendYear = result['release_date'] as String?; + final backendAlbum = result['album'] as String?; - finalTrack = Track( - id: trackToDownload.id, - name: trackToDownload.name, - artistName: trackToDownload.artistName, - albumName: backendAlbum ?? trackToDownload.albumName, - albumArtist: trackToDownload.albumArtist, - coverUrl: trackToDownload.coverUrl, - duration: trackToDownload.duration, - isrc: trackToDownload.isrc, - trackNumber: newTrackNumber, - discNumber: newDiscNumber, - releaseDate: backendYear ?? trackToDownload.releaseDate, - deezerId: trackToDownload.deezerId, - availability: trackToDownload.availability, - ); + _log.d( + 'Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear', + ); + + // Create updated track object with safety check for 0/null + final newTrackNumber = + (backendTrackNum != null && backendTrackNum > 0) + ? backendTrackNum + : trackToDownload.trackNumber; + final newDiscNumber = + (backendDiscNum != null && backendDiscNum > 0) + ? backendDiscNum + : trackToDownload.discNumber; + + _log.d( + 'Final metadata for embedding - Track: $newTrackNumber, Disc: $newDiscNumber', + ); + + finalTrack = Track( + id: trackToDownload.id, + name: trackToDownload.name, + artistName: trackToDownload.artistName, + albumName: backendAlbum ?? trackToDownload.albumName, + albumArtist: trackToDownload.albumArtist, + coverUrl: trackToDownload.coverUrl, + duration: trackToDownload.duration, + isrc: trackToDownload.isrc, + trackNumber: newTrackNumber, + discNumber: newDiscNumber, + releaseDate: backendYear ?? trackToDownload.releaseDate, + deezerId: trackToDownload.deezerId, + availability: trackToDownload.availability, + ); } // Use enriched/updated track for metadata embedding @@ -1326,9 +1469,12 @@ class DownloadQueueNotifier extends Notifier { // Keep the M4A file if conversion fails } } - + // Check again if cancelled before updating status and adding to history - final itemAfterDownload = state.items.firstWhere((i) => i.id == item.id, orElse: () => item); + final itemAfterDownload = state.items.firstWhere( + (i) => i.id == item.id, + orElse: () => item, + ); if (itemAfterDownload.status == DownloadStatus.skipped) { _log.i('Download was cancelled during finalization, cleaning up'); // Delete the downloaded file @@ -1345,14 +1491,14 @@ class DownloadQueueNotifier extends Notifier { } return; } - + updateItemStatus( item.id, DownloadStatus.completed, progress: 1.0, filePath: filePath, ); - + // Increment completed counter _completedInSession++; @@ -1365,7 +1511,7 @@ class DownloadQueueNotifier extends Notifier { ); if (filePath != null) { - // Extract updated metadata from backend result if available + // Extract metadata from backend result (most accurate source) final backendTitle = result['title'] as String?; final backendArtist = result['artist'] as String?; final backendAlbum = result['album'] as String?; @@ -1376,37 +1522,52 @@ class DownloadQueueNotifier extends Notifier { final backendSampleRate = result['actual_sample_rate'] as int?; final backendISRC = result['isrc'] as String?; - ref.read(downloadHistoryProvider.notifier).addToHistory( - DownloadHistoryItem( - id: item.id, - trackName: (backendTitle != null && backendTitle.isNotEmpty) ? backendTitle : item.track.name, - artistName: (backendArtist != null && backendArtist.isNotEmpty) ? backendArtist : item.track.artistName, - albumName: (backendAlbum != null && backendAlbum.isNotEmpty) ? backendAlbum : item.track.albumName, - albumArtist: item.track.albumArtist, - coverUrl: item.track.coverUrl, - filePath: filePath, - service: result['service'] as String? ?? item.service, - downloadedAt: DateTime.now(), - // Additional metadata - isrc: (backendISRC != null && backendISRC.isNotEmpty) ? backendISRC : item.track.isrc, - spotifyId: item.track.id, - trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : item.track.trackNumber, - discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : item.track.discNumber, - duration: item.track.duration, - releaseDate: (backendYear != null && backendYear.isNotEmpty) ? backendYear : item.track.releaseDate, - quality: actualQuality, - bitDepth: backendBitDepth, - sampleRate: backendSampleRate, - ), - ); - + ref + .read(downloadHistoryProvider.notifier) + .addToHistory( + DownloadHistoryItem( + id: item.id, + trackName: (backendTitle != null && backendTitle.isNotEmpty) + ? backendTitle + : trackToDownload.name, + artistName: (backendArtist != null && backendArtist.isNotEmpty) + ? backendArtist + : trackToDownload.artistName, + albumName: (backendAlbum != null && backendAlbum.isNotEmpty) + ? backendAlbum + : trackToDownload.albumName, + albumArtist: trackToDownload.albumArtist, + coverUrl: trackToDownload.coverUrl, + filePath: filePath, + service: result['service'] as String? ?? item.service, + downloadedAt: DateTime.now(), + isrc: (backendISRC != null && backendISRC.isNotEmpty) + ? backendISRC + : trackToDownload.isrc, + spotifyId: trackToDownload.id, + trackNumber: (backendTrackNum != null && backendTrackNum > 0) + ? backendTrackNum + : trackToDownload.trackNumber, + discNumber: (backendDiscNum != null && backendDiscNum > 0) + ? backendDiscNum + : trackToDownload.discNumber, + duration: trackToDownload.duration, + releaseDate: (backendYear != null && backendYear.isNotEmpty) + ? backendYear + : trackToDownload.releaseDate, + quality: actualQuality, + bitDepth: backendBitDepth, + sampleRate: backendSampleRate, + ), + ); + // Auto-remove completed item from queue (it's now in history) removeItem(item.id); } } else { final errorMsg = result['error'] as String? ?? 'Download failed'; final errorTypeStr = result['error_type'] as String? ?? 'unknown'; - + // Convert error type string to enum DownloadErrorType errorType; switch (errorTypeStr) { @@ -1422,7 +1583,7 @@ class DownloadQueueNotifier extends Notifier { default: errorType = DownloadErrorType.unknown; } - + _log.e('Download failed: $errorMsg (type: $errorTypeStr)'); updateItemStatus( item.id, @@ -1432,11 +1593,13 @@ class DownloadQueueNotifier extends Notifier { ); _failedInSession++; } - + // Increment download counter and cleanup connections periodically _downloadCount++; if (_downloadCount % _cleanupInterval == 0) { - _log.d('Cleaning up idle connections (after $_downloadCount downloads)...'); + _log.d( + 'Cleaning up idle connections (after $_downloadCount downloads)...', + ); try { await PlatformBridge.cleanupConnections(); } catch (e) { @@ -1445,15 +1608,15 @@ class DownloadQueueNotifier extends Notifier { } } catch (e, stackTrace) { _log.e('Exception: $e', e, stackTrace); - + String errorMsg = e.toString(); DownloadErrorType errorType = DownloadErrorType.unknown; // Check for specific Deezer fallback error - if (errorMsg.contains('could not find Deezer equivalent') || + if (errorMsg.contains('could not find Deezer equivalent') || errorMsg.contains('track not found on Deezer')) { - errorMsg = 'Track not found on Deezer (Metadata Unavailable)'; - errorType = DownloadErrorType.notFound; + errorMsg = 'Track not found on Deezer (Metadata Unavailable)'; + errorType = DownloadErrorType.notFound; } updateItemStatus( @@ -1467,6 +1630,7 @@ class DownloadQueueNotifier extends Notifier { } } -final downloadQueueProvider = NotifierProvider( - DownloadQueueNotifier.new, -); +final downloadQueueProvider = + NotifierProvider( + DownloadQueueNotifier.new, + ); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index bf96e6f2..e7fa901b 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; +import 'package:spotiflac_android/services/csv_import_service.dart'; import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/models/download_item.dart'; @@ -266,6 +267,104 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } + Future _importCsv(BuildContext context, WidgetRef ref) async { + // Show loading dialog with progress + int currentProgress = 0; + int totalTracks = 0; + + // Use StatefulBuilder to update dialog content + final dialogContext = context; + bool dialogShown = false; + StateSetter? setDialogState; + + void showProgressDialog() { + if (dialogShown) return; + dialogShown = true; + showDialog( + context: dialogContext, + barrierDismissible: false, + builder: (context) => StatefulBuilder( + builder: (context, setState) { + setDialogState = setState; + return AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + totalTracks > 0 + ? 'Fetching metadata... $currentProgress/$totalTracks' + : 'Reading CSV...', + ), + ], + ), + ); + }, + ), + ); + } + + final tracks = await CsvImportService.pickAndParseCsv( + onProgress: (current, total) { + currentProgress = current; + totalTracks = total; + if (!dialogShown && total > 0) { + showProgressDialog(); + } + setDialogState?.call(() {}); + }, + ); + + // Close progress dialog + if (dialogShown && mounted) { + Navigator.of(dialogContext).pop(); + } + + if (tracks.isNotEmpty) { + final settings = ref.read(settingsProvider); + + // Optionally show confirmation dialog + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Import Playlist'), + content: Text('Found ${tracks.length} tracks in CSV. Add them to download queue?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Import'), + ), + ], + ), + ); + + if (confirmed == true) { + ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Added ${tracks.length} tracks to queue'), + action: SnackBarAction( + label: 'View Queue', + onPressed: () { + // Navigate to queue tab (handled by main_shell index) + // We don't have direct access to set index here easily without provider + }, + ), + ), + ); + } + } + } else { + // Only show error if pick was not cancelled (handled inside service logging usually, but maybe show snackbar if file empty) + } + } + @override Widget build(BuildContext context) { super.build(context); @@ -770,12 +869,18 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient onPressed: _clearAndRefresh, tooltip: 'Clear', ) - else + else ...[ + IconButton( + icon: const Icon(Icons.file_upload_outlined), + onPressed: () => _importCsv(context, ref), + tooltip: 'Import CSV', + ), IconButton( icon: const Icon(Icons.paste), onPressed: _pasteFromClipboard, tooltip: 'Paste', ), + ], ], ), contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 3398e20c..92b016c0 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -62,6 +62,9 @@ class _MainShellState extends ConsumerState { } void _handleSharedUrl(String url) { + // Pop any existing screens (Album, Artist, Settings sub-pages) to return to root + Navigator.of(context).popUntil((route) => route.isFirst); + // Navigate to Home tab if (_currentIndex != 0) { _onNavTap(0); diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 18daa3de..84129130 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -27,20 +27,31 @@ class AppearanceSettingsPage extends ConsumerWidget { pinned: true, backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, - leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)), - flexibleSpace: _AppBarTitle(title: 'Appearance', topPadding: topPadding), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + flexibleSpace: _AppBarTitle( + title: 'Appearance', + topPadding: topPadding, + ), ), // Preview Section SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), child: _ThemePreviewCard(), ), ), // Color section - const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')), + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Color'), + ), SliverToBoxAdapter( child: SettingsGroup( @@ -50,7 +61,9 @@ class AppearanceSettingsPage extends ConsumerWidget { title: 'Dynamic Color', subtitle: 'Use colors from your wallpaper', value: themeSettings.useDynamicColor, - onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value), + onChanged: (value) => ref + .read(themeProvider.notifier) + .setUseDynamicColor(value), showDivider: false, ), ], @@ -62,47 +75,60 @@ class AppearanceSettingsPage extends ConsumerWidget { padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: _ColorPalettePicker( currentColor: themeSettings.seedColorValue, - onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color), + onColorSelected: (color) => + ref.read(themeProvider.notifier).setSeedColor(color), ), ), ), // Theme section - const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')), + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Theme'), + ), SliverToBoxAdapter( child: SettingsGroup( children: [ _ThemeModeSelector( currentMode: themeSettings.themeMode, - onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode), - ), - SettingsSwitchItem( - icon: Icons.brightness_2, - title: 'AMOLED Dark', - subtitle: 'Pure black background', - value: themeSettings.useAmoled, - onChanged: (value) => ref.read(themeProvider.notifier).setUseAmoled(value), - showDivider: false, + onChanged: (mode) => + ref.read(themeProvider.notifier).setThemeMode(mode), ), + if (Theme.of(context).brightness == Brightness.dark) + SettingsSwitchItem( + icon: Icons.brightness_2, + title: 'AMOLED Dark', + subtitle: 'Pure black background', + value: themeSettings.useAmoled, + onChanged: (value) => + ref.read(themeProvider.notifier).setUseAmoled(value), + showDivider: false, + ), ], ), ), // Layout section - const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')), + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Layout'), + ), SliverToBoxAdapter( child: SettingsGroup( children: [ _HistoryViewSelector( currentMode: settings.historyViewMode, - onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode), + onChanged: (mode) => ref + .read(settingsProvider.notifier) + .setHistoryViewMode(mode), ), ], ), ), // Fill remaining for scroll - const SliverFillRemaining(hasScrollBody: false, child: SizedBox(height: 32)), + const SliverFillRemaining( + hasScrollBody: false, + child: SizedBox(height: 32), + ), ], ), ), @@ -116,143 +142,180 @@ class _ThemePreviewCard extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; - - return Container( - height: 200, - width: double.infinity, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, // Background similar to reference - borderRadius: BorderRadius.circular(28), - ), - clipBehavior: Clip.antiAlias, - child: Stack( - children: [ - // Decorative background blobs - Positioned( - top: -50, - right: -50, - child: Container( - width: 200, height: 200, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: colorScheme.primaryContainer.withValues(alpha: 0.5), + + return RepaintBoundary( + child: Container( + height: 200, + width: double.infinity, + decoration: BoxDecoration( + color: colorScheme + .surfaceContainerHighest, // Background similar to reference + borderRadius: BorderRadius.circular(28), + ), + clipBehavior: Clip.antiAlias, + child: Stack( + children: [ + // Decorative background blobs + Positioned( + top: -50, + right: -50, + child: Container( + width: 200, + height: 200, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.primaryContainer.withValues(alpha: 0.5), + ), ), ), - ), - Positioned( - bottom: -30, - left: -30, - child: Container( - width: 150, height: 150, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: colorScheme.tertiaryContainer.withValues(alpha: 0.5), + Positioned( + bottom: -30, + left: -30, + child: Container( + width: 150, + height: 150, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.tertiaryContainer.withValues(alpha: 0.5), + ), ), ), - ), - - // Foreground "fake UI" - Center( - child: Container( - width: 260, - height: 140, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: Row( - children: [ - // Fake Album Art - Container( - width: 108, - height: 108, - decoration: BoxDecoration( - color: colorScheme.primary, - borderRadius: BorderRadius.circular(16), + + // Foreground "fake UI" + Center( + child: Container( + width: 260, + height: 140, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 12, // Reduced from 20 for performance + offset: const Offset(0, 8), ), - child: Icon(Icons.music_note, color: colorScheme.onPrimary, size: 48), - ), - const SizedBox(width: 16), - - // Fake Text Info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: double.infinity, height: 14, - decoration: BoxDecoration( - color: colorScheme.onSurface, - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(height: 8), - Container( - width: 80, height: 10, - decoration: BoxDecoration( - color: colorScheme.primary, - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(height: 24), - Row( - children: [ - Icon(Icons.skip_previous, size: 24, color: colorScheme.onSurfaceVariant), - const SizedBox(width: 12), - Icon(Icons.play_circle_fill, size: 32, color: colorScheme.primary), - const SizedBox(width: 12), - Icon(Icons.skip_next, size: 24, color: colorScheme.onSurfaceVariant), - ], - ), - ], + ], + ), + child: Row( + children: [ + // Fake Album Art + Container( + width: 108, + height: 108, + decoration: BoxDecoration( + color: colorScheme.primary, + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + Icons.music_note, + color: colorScheme.onPrimary, + size: 48, + ), ), + const SizedBox(width: 16), + + // Fake Text Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: double.infinity, + height: 14, + decoration: BoxDecoration( + color: colorScheme.onSurface, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 8), + Container( + width: 80, + height: 10, + decoration: BoxDecoration( + color: colorScheme.primary, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 24), + Row( + children: [ + Icon( + Icons.skip_previous, + size: 24, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Icon( + Icons.play_circle_fill, + size: 32, + color: colorScheme.primary, + ), + const SizedBox(width: 12), + Icon( + Icons.skip_next, + size: 24, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + + // Label badge + Positioned( + bottom: 12, + right: 12, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + isDark ? 'Dark Mode' : 'Light Mode', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, ), - ], + ), ), ), - ), - - // Label badge - Positioned( - bottom: 12, - right: 12, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - isDark ? 'Dark Mode' : 'Light Mode', - style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), - ), - ), - ) - ], + ], + ), ), ); } } - - class _ColorPalettePicker extends StatelessWidget { final int currentColor; final ValueChanged onColorSelected; - const _ColorPalettePicker({required this.currentColor, required this.onColorSelected}); + const _ColorPalettePicker({ + required this.currentColor, + required this.onColorSelected, + }); static const _colors = [ - Color(0xFF1DB954), Color(0xFF6750A4), Color(0xFF0061A4), Color(0xFF006E1C), - Color(0xFFBA1A1A), Color(0xFF984061), Color(0xFF7D5260), Color(0xFF006874), + Color(0xFF1DB954), + Color(0xFF6750A4), + Color(0xFF0061A4), + Color(0xFF006E1C), + Color(0xFFBA1A1A), + Color(0xFF984061), + Color(0xFF7D5260), + Color(0xFF006874), ]; @override @@ -278,22 +341,23 @@ class _ColorPalettePicker extends StatelessWidget { class _ColorPaletteItem extends StatelessWidget { final Color color; final bool isSelected; - + const _ColorPaletteItem({required this.color, required this.isSelected}); @override Widget build(BuildContext context) { - final scheme = ColorScheme.fromSeed(seedColor: color, brightness: Theme.of(context).brightness); + final scheme = ColorScheme.fromSeed( + seedColor: color, + brightness: Theme.of(context).brightness, + ); final size = 64.0; - + return Stack( children: [ Container( width: size, height: size, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)), clipBehavior: Clip.antiAlias, child: Column( children: [ @@ -308,7 +372,9 @@ class _ColorPaletteItem extends StatelessWidget { Expanded( child: Row( children: [ - Expanded(child: Container(color: scheme.secondaryContainer)), + Expanded( + child: Container(color: scheme.secondaryContainer), + ), Expanded(child: Container(color: scheme.surfaceContainer)), ], ), @@ -318,16 +384,16 @@ class _ColorPaletteItem extends StatelessWidget { ), if (isSelected) Positioned.fill( - child: Center( - child: Container( - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - ), - child: Icon(Icons.check, size: 16, color: scheme.primary), - ), - ), + child: Center( + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: Icon(Icons.check, size: 16, color: scheme.primary), + ), + ), ), ], ); @@ -338,7 +404,7 @@ class _ColorPaletteItem extends StatelessWidget { class _AppBarTitle extends StatelessWidget { final String title; final double topPadding; - + const _AppBarTitle({required this.title, required this.topPadding}); @override @@ -348,7 +414,9 @@ class _AppBarTitle extends StatelessWidget { builder: (context, constraints) { final maxHeight = 120 + topPadding; final minHeight = kToolbarHeight + topPadding; - final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); + final expandRatio = + ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)) + .clamp(0.0, 1.0); final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 return FlexibleSpaceBar( expandedTitleScale: 1.0, @@ -370,19 +438,39 @@ class _AppBarTitle extends StatelessWidget { class _ThemeModeSelector extends StatelessWidget { final ThemeMode currentMode; final ValueChanged onChanged; - const _ThemeModeSelector({required this.currentMode, required this.onChanged}); + const _ThemeModeSelector({ + required this.currentMode, + required this.onChanged, + }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(12), - child: Row(children: [ - _ThemeModeChip(icon: Icons.brightness_auto, label: 'System', isSelected: currentMode == ThemeMode.system, onTap: () => onChanged(ThemeMode.system)), - const SizedBox(width: 8), - _ThemeModeChip(icon: Icons.light_mode, label: 'Light', isSelected: currentMode == ThemeMode.light, onTap: () => onChanged(ThemeMode.light)), - const SizedBox(width: 8), - _ThemeModeChip(icon: Icons.dark_mode, label: 'Dark', isSelected: currentMode == ThemeMode.dark, onTap: () => onChanged(ThemeMode.dark)), - ]), + child: Row( + children: [ + _ThemeModeChip( + icon: Icons.brightness_auto, + label: 'System', + isSelected: currentMode == ThemeMode.system, + onTap: () => onChanged(ThemeMode.system), + ), + const SizedBox(width: 8), + _ThemeModeChip( + icon: Icons.light_mode, + label: 'Light', + isSelected: currentMode == ThemeMode.light, + onTap: () => onChanged(ThemeMode.light), + ), + const SizedBox(width: 8), + _ThemeModeChip( + icon: Icons.dark_mode, + label: 'Dark', + isSelected: currentMode == ThemeMode.dark, + onTap: () => onChanged(ThemeMode.dark), + ), + ], + ), ); } } @@ -392,27 +480,41 @@ class _ThemeModeChip extends StatelessWidget { final String label; final bool isSelected; final VoidCallback onTap; - const _ThemeModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap}); + const _ThemeModeChip({ + required this.icon, + required this.label, + required this.isSelected, + required this.onTap, + }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; - + // Unselected chips need contrast with card background // Card uses: dark = white 8% overlay, light = surfaceContainerHighest // So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card) - final unselectedColor = isDark - ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface) - : Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest); - + final unselectedColor = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.05), + colorScheme.surface, + ) + : Color.alphaBlend( + Colors.black.withValues(alpha: 0.05), + colorScheme.surfaceContainerHighest, + ); + return Expanded( child: Container( decoration: BoxDecoration( color: isSelected ? colorScheme.primaryContainer : unselectedColor, borderRadius: BorderRadius.circular(12), - border: !isDark && !isSelected - ? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1) + border: !isDark && !isSelected + ? Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + width: 1, + ) : null, ), child: Material( @@ -423,13 +525,29 @@ class _ThemeModeChip extends StatelessWidget { borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.symmetric(vertical: 14), - child: Column(children: [ - Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant), - const SizedBox(height: 6), - Text(label, style: TextStyle(fontSize: 12, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)), - ]), + child: Column( + children: [ + Icon( + icon, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + ), + ], + ), ), ), ), @@ -441,7 +559,10 @@ class _ThemeModeChip extends StatelessWidget { class _HistoryViewSelector extends StatelessWidget { final String currentMode; final ValueChanged onChanged; - const _HistoryViewSelector({required this.currentMode, required this.onChanged}); + const _HistoryViewSelector({ + required this.currentMode, + required this.onChanged, + }); @override Widget build(BuildContext context) { @@ -453,13 +574,30 @@ class _HistoryViewSelector extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.only(left: 8, bottom: 8), - child: Text('History View', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + child: Text( + 'History View', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + Row( + children: [ + _ViewModeChip( + icon: Icons.view_list, + label: 'List', + isSelected: currentMode == 'list', + onTap: () => onChanged('list'), + ), + const SizedBox(width: 8), + _ViewModeChip( + icon: Icons.grid_view, + label: 'Grid', + isSelected: currentMode == 'grid', + onTap: () => onChanged('grid'), + ), + ], ), - Row(children: [ - _ViewModeChip(icon: Icons.view_list, label: 'List', isSelected: currentMode == 'list', onTap: () => onChanged('list')), - const SizedBox(width: 8), - _ViewModeChip(icon: Icons.grid_view, label: 'Grid', isSelected: currentMode == 'grid', onTap: () => onChanged('grid')), - ]), ], ), ); @@ -471,25 +609,39 @@ class _ViewModeChip extends StatelessWidget { final String label; final bool isSelected; final VoidCallback onTap; - const _ViewModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap}); + const _ViewModeChip({ + required this.icon, + required this.label, + required this.isSelected, + required this.onTap, + }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; - + // Unselected chips need contrast with card background - final unselectedColor = isDark - ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface) - : Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest); - + final unselectedColor = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.05), + colorScheme.surface, + ) + : Color.alphaBlend( + Colors.black.withValues(alpha: 0.05), + colorScheme.surfaceContainerHighest, + ); + return Expanded( child: Container( decoration: BoxDecoration( color: isSelected ? colorScheme.primaryContainer : unselectedColor, borderRadius: BorderRadius.circular(12), - border: !isDark && !isSelected - ? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1) + border: !isDark && !isSelected + ? Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + width: 1, + ) : null, ), child: Material( @@ -500,13 +652,29 @@ class _ViewModeChip extends StatelessWidget { borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.symmetric(vertical: 14), - child: Column(children: [ - Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant), - const SizedBox(height: 6), - Text(label, style: TextStyle(fontSize: 12, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)), - ]), + child: Column( + children: [ + Icon( + icon, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + ), + ], + ), ), ), ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 3c2294c6..fec533b8 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -442,7 +442,7 @@ class _TrackMetadataScreenState extends ConsumerState { _MetadataItem('Album', albumName), if (trackNumber != null && trackNumber! > 0) _MetadataItem('Track number', trackNumber.toString()), - if (discNumber != null && discNumber! > 1) + if (discNumber != null && discNumber! > 0) _MetadataItem('Disc number', discNumber.toString()), if (item.duration != null) _MetadataItem('Duration', _formatDuration(item.duration!)), diff --git a/lib/services/csv_import_service.dart b/lib/services/csv_import_service.dart new file mode 100644 index 00000000..980b2848 --- /dev/null +++ b/lib/services/csv_import_service.dart @@ -0,0 +1,216 @@ +import 'dart:io'; +import 'package:file_picker/file_picker.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +class CsvImportService { + static final _log = AppLogger('CsvImportService'); + + /// Pick and parse CSV file, then enrich metadata from Deezer + /// [onProgress] callback receives (current, total) for progress updates + static Future> pickAndParseCsv({ + void Function(int current, int total)? onProgress, + }) async { + try { + final FilePickerResult? result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['csv'], + ); + + if (result != null && result.files.single.path != null) { + final file = File(result.files.single.path!); + final content = await file.readAsString(); + final tracks = _parseCsv(content); + + // Enrich tracks with metadata from Deezer (cover URL, duration, etc.) + if (tracks.isNotEmpty) { + return await _enrichTracksMetadata(tracks, onProgress: onProgress); + } + return tracks; + } + } catch (e) { + _log.e('Error picking/parsing CSV: $e'); + } + return []; + } + + /// Enrich tracks with metadata from Deezer using ISRC + /// This fetches cover URL, duration, and other metadata that CSV doesn't have + static Future> _enrichTracksMetadata( + List tracks, { + void Function(int current, int total)? onProgress, + }) async { + _log.i('Enriching metadata for ${tracks.length} tracks from Deezer...'); + final enrichedTracks = []; + + for (int i = 0; i < tracks.length; i++) { + final track = tracks[i]; + onProgress?.call(i + 1, tracks.length); + + // Only enrich if we have ISRC and missing cover/duration + if (track.isrc != null && + track.isrc!.isNotEmpty && + (track.coverUrl == null || track.duration == 0)) { + try { + // searchDeezerByISRC returns TrackMetadata directly (not wrapped in "track" key) + final trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!); + + // Extract enriched data from TrackMetadata + final coverUrl = trackData['images'] as String?; + final durationMs = trackData['duration_ms'] as int? ?? 0; + final deezerIdRaw = trackData['spotify_id'] as String?; // Format: "deezer:123456" + + enrichedTracks.add(Track( + id: deezerIdRaw ?? track.id, // Use Deezer ID if available + name: trackData['name'] as String? ?? track.name, + artistName: trackData['artists'] as String? ?? track.artistName, + albumName: trackData['album_name'] as String? ?? track.albumName, + albumArtist: trackData['album_artist'] as String?, + coverUrl: coverUrl ?? track.coverUrl, + isrc: trackData['isrc'] as String? ?? track.isrc, + duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration, + trackNumber: trackData['track_number'] as int? ?? track.trackNumber, + discNumber: trackData['disc_number'] as int? ?? track.discNumber, + releaseDate: trackData['release_date'] as String? ?? track.releaseDate, + )); + + _log.d('Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s'); + + // Small delay to avoid rate limiting (50ms between requests) + if (i < tracks.length - 1) { + await Future.delayed(const Duration(milliseconds: 50)); + } + continue; + } catch (e) { + _log.w('Failed to enrich ${track.name}: $e'); + } + } + + // Keep original track if enrichment failed or not needed + enrichedTracks.add(track); + } + + _log.i('Enrichment complete: ${enrichedTracks.length} tracks'); + return enrichedTracks; + } + + static List _parseCsv(String content) { + final List tracks = []; + final lines = content.split(RegExp(r'\r\n|\r|\n')); // Handle various newline formats + if (lines.isEmpty) return tracks; + + // Detect headers line (assume first non-empty line) + int startIdx = 0; + while (startIdx < lines.length && lines[startIdx].trim().isEmpty) { + startIdx++; + } + if (startIdx >= lines.length) return tracks; + + final headers = _parseLine(lines[startIdx]); + final colMap = {}; + for (int i = 0; i < headers.length; i++) { + // Normalize header: lowercase, trim, remove quotes + String h = _cleanValue(headers[i]).toLowerCase(); + colMap[h] = i; + } + + _log.d('CSV Headers: ${colMap.keys.toList()}'); + + // Parse rows + for (int i = startIdx + 1; i < lines.length; i++) { + final line = lines[i].trim(); + if (line.isEmpty) continue; + + final values = _parseLine(line); + + // Helper to get value securely + String? getVal(List keys) { + return _getValue(values, colMap, keys); + } + + String? trackName = getVal(['track name', 'track', 'name', 'title']); + String? artistName = getVal(['artist name', 'artist']); + String? albumName = getVal(['album name', 'album']); + String? isrc = getVal(['isrc']); // Often formatted with leading/trailing quotes + String? spotifyId = getVal(['spotify - id', 'spotify id', 'id', 'uri']); // Uri might need parsing + + // If 'spotify uri' contains the id: 'spotify:track:ID' + if (spotifyId != null && spotifyId.startsWith('spotify:track:')) { + spotifyId = spotifyId.replaceAll('spotify:track:', ''); + } + + // Basic validation: Need at least name and artist, OR a spotify ID + if ((trackName != null && trackName.isNotEmpty && artistName != null) || (spotifyId != null && spotifyId.isNotEmpty)) { + tracks.add(Track( + id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i', + name: trackName ?? 'Unknown Track', + artistName: artistName ?? 'Unknown Artist', + albumName: albumName ?? 'Unknown Album', + isrc: isrc, + duration: 0, // Will be updated by enrichment later + coverUrl: null, // Will be fetched by enrichment + )); + } + } + + _log.i('Parsed ${tracks.length} tracks from CSV'); + return tracks; + } + + static String? _getValue(List values, Map colMap, List possibleKeys) { + for (final key in possibleKeys) { + if (colMap.containsKey(key)) { + final index = colMap[key]!; + if (index < values.length) { + return _cleanValue(values[index]); + } + } + } + return null; + } + + static String _cleanValue(String val) { + val = val.trim(); + if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) { + val = val.substring(1, val.length - 1); + } + // Handle double quotes escape in CSV ("" -> ") + val = val.replaceAll('""', '"'); + return val; + } + + // Robust CSV Line Parser + static List _parseLine(String line) { + final List result = []; + bool inQuote = false; + StringBuffer buffer = StringBuffer(); + + for (int i=0; i Thumb "Up" + // My _cleanValue handles it, so I should just preserve raw content here mostly, + // BUT I need to know if " toggles inQuote. + // Escaped "" does NOT toggle inQuote mode effectively (it counts as literal char inside quote). + buffer.write('"'); // Write 1st quote + i++; // Skip next quote char loop + buffer.write('"'); // Write 2nd quote + } else { + inQuote = !inQuote; + buffer.write(char); + } + } else if (char == ',' && !inQuote) { + result.add(buffer.toString()); + buffer.clear(); + } else { + buffer.write(char); + } + } + result.add(buffer.toString()); + return result; + } +}