From 72d45746a5a5e2b54e1e976217e743e34b67d047 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 3 Feb 2026 21:51:40 +0700 Subject: [PATCH] feat: add local library albums to Albums tab with unified grid and LocalAlbumScreen - Add local library albums as clickable cards in Albums filter - Merge downloaded and local albums into single unified grid (fix layout gaps) - Create LocalAlbumScreen for viewing local album details with: - Cover art display with dominant color extraction - Album info card with Local badge and quality info - Track list with disc grouping support - Selection mode with delete functionality - UI consistent with DownloadedAlbumScreen (Card + ListTile layout) - Add singles filter support for local library singles - Add extractDominantColorFromFile to PaletteService - Add delete(id) method to LibraryDatabase - Add removeItem(id) method to LocalLibraryNotifier - Update CHANGELOG.md for v3.4.0 --- CHANGELOG.md | 46 + .../kotlin/com/zarz/spotiflac/MainActivity.kt | 7 + go_backend/audio_metadata.go | 1411 +++++++++++++++++ go_backend/exports.go | 5 + go_backend/library_scan.go | 103 +- ios/Runner/AppDelegate.swift | 6 + lib/providers/download_queue_provider.dart | 26 +- lib/providers/local_library_provider.dart | 19 + lib/screens/local_album_screen.dart | 735 +++++++++ lib/screens/queue_tab.dart | 1296 +++++++++------ lib/services/library_database.dart | 22 +- lib/services/notification_service.dart | 14 +- lib/services/palette_service.dart | 35 + lib/services/platform_bridge.dart | 8 + 14 files changed, 3255 insertions(+), 478 deletions(-) create mode 100644 go_backend/audio_metadata.go create mode 100644 lib/screens/local_album_screen.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 47adb363..0d7c72a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,34 @@ - Source badge on each item (Downloaded/Local) to identify the source - Local Library items shown in a separate section when enabled - Play button to open local library tracks directly + - **Selection mode works for both downloaded and local files** + - **Select All now selects all visible items (downloaded + local)** + - **Delete selected works for local library files** (removes from database and deletes file) + - **"All" count now includes local library items** +- **Local Library Albums in Albums Tab**: Local library albums now appear as clickable cards in the Albums filter + - Albums grid combines downloaded and local albums in a single unified grid + - Local albums display folder icon badge to distinguish from downloaded albums + - Tapping a local album opens the Local Album Screen with full track listing +- **Local Album Screen**: Dedicated screen for viewing local library album details + - Cover art display with dominant color extraction for header gradient + - Album info card with "Local" badge, track count, and quality info + - Track list with disc grouping support (multi-disc albums) + - Selection mode with delete functionality (removes files from storage and database) + - UI consistent with DownloadedAlbumScreen (Card + ListTile layout, same bottom bar) +- **Singles Filter with Local Library**: Singles filter now includes local library singles + - Shows tracks from albums with only 1 track (both downloaded and local) + - Search filter works across both sources + - Local items show "Local" badge +- **Cover Art Extraction for Local Library**: Embedded cover art is extracted and cached during scan + - Supports FLAC (PICTURE block), MP3 (APIC frames), Opus/Ogg (METADATA_BLOCK_PICTURE) + - Cover cached to app's cache directory with hash-based filenames + - Cache key includes file size + mtime to detect stale covers + - Cover art displayed in Library tab for local items + - **Dominant color extraction from local cover files** for album screen gradients +- **"Already in Library" Notification**: When downloading a track that already exists + - Shows "Already in Library" instead of "Download complete" + - Skips adding duplicate entry if track already in download history + - Reads actual quality from existing file (bit depth, sample rate) - **Cloud Upload with WebDAV & SFTP**: Automatically upload downloaded files to your NAS or cloud storage - Full WebDAV support (Synology DSM, Nextcloud, QNAP, ownCloud) - Full SFTP support (any SSH server with SFTP enabled) @@ -42,6 +70,24 @@ - Extension file sandbox now validates paths using boundary-safe checks - WebDAV now defaults to HTTPS; insecure HTTP requires explicit opt-in - WebDAV error messages are now localized in the UI +- **Albums grid now unified**: Downloaded and local albums render in a single grid (no layout gaps) +- **LocalAlbumScreen UI consistency**: Track items, disc separators, and selection bottom bar now match DownloadedAlbumScreen + +### Fixed + +- **MP3 Metadata Parsing**: Improved ID3v2 handling (extended headers, unsync, footer, frame flags) for more reliable tag reads +- **Ogg/Opus Metadata Parsing**: Reassembled Ogg packets and detect stream type from headers for accurate tags/quality/cover extraction +- **Library Scan Metadata**: MP3 scans now include ISRC and disc number; release date prefers full TDRC/TYER when available +- **Cover Cache Robustness**: Cache key now includes file size + mtime to reduce stale cover art when files change in place +- **Cover Extraction Overhead**: Skip cover extraction for M4A during library scan to avoid guaranteed errors +- **Library Scan Thread Safety**: Cover cache directory reads/writes are now synchronized to avoid potential data races +- **Go Mobile Bind Compatibility**: Cover art helper functions are now unexported to avoid gomobile "too many return values" errors +- **Local Library Selection**: Selection checkbox now shows correctly for local library items in both list and grid views +- **Local Library Delete**: Local library files can now be selected and deleted (removes from database and deletes file) +- **Albums/Singles Count**: Filter chip counts now include local library items (albums with 2+ tracks, singles with 1 track) +- **Duplicate Download Detection**: Uses `already_exists` field from Go backend instead of file path prefix detection +- **Albums Tab Layout Gap**: Fixed issue where downloaded and local albums rendered in separate grids causing empty spaces +- **Singles Filter Missing Local Items**: Singles filter now correctly includes local library singles (not just downloaded) --- diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 16dc6fb6..ea35abe0 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -893,6 +893,13 @@ class MainActivity: FlutterActivity() { result.success(response) } // Local Library Scanning + "setLibraryCoverCacheDir" -> { + val cacheDir = call.argument("cache_dir") ?: "" + withContext(Dispatchers.IO) { + Gobackend.setLibraryCoverCacheDirJSON(cacheDir) + } + result.success(null) + } "scanLibraryFolder" -> { val folderPath = call.argument("folder_path") ?: "" val response = withContext(Dispatchers.IO) { diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go new file mode 100644 index 00000000..e9c7844e --- /dev/null +++ b/go_backend/audio_metadata.go @@ -0,0 +1,1411 @@ +package gobackend + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" +) + +// AudioMetadata represents common audio file metadata +type AudioMetadata struct { + Title string + Artist string + Album string + AlbumArtist string + Genre string + Year string + Date string + TrackNumber int + DiscNumber int + ISRC string +} + +// MP3Quality represents MP3 specific quality info +type MP3Quality struct { + SampleRate int + BitDepth int + Duration int + Bitrate int +} + +// OggQuality represents Ogg/Opus specific quality info +type OggQuality struct { + SampleRate int + BitDepth int + Duration int +} + +// ============================================================================= +// ID3 Tag Reading (MP3) +// ============================================================================= + +// ReadID3Tags reads ID3v2 and ID3v1 tags from an MP3 file +func ReadID3Tags(filePath string) (*AudioMetadata, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + metadata := &AudioMetadata{} + + // Try ID3v2 first (at beginning of file) + id3v2, err := readID3v2(file) + if err == nil && id3v2 != nil { + metadata = id3v2 + } + + // If ID3v2 failed or is incomplete, try ID3v1 (at end of file) + if metadata.Title == "" || metadata.Artist == "" { + id3v1, err := readID3v1(file) + if err == nil && id3v1 != nil { + // Fill in missing fields + if metadata.Title == "" { + metadata.Title = id3v1.Title + } + if metadata.Artist == "" { + metadata.Artist = id3v1.Artist + } + if metadata.Album == "" { + metadata.Album = id3v1.Album + } + if metadata.Year == "" { + metadata.Year = id3v1.Year + } + if metadata.Genre == "" { + metadata.Genre = id3v1.Genre + } + } + } + + if metadata.Title == "" && metadata.Artist == "" { + return nil, fmt.Errorf("no ID3 tags found") + } + + return metadata, nil +} + +// readID3v2 reads ID3v2 tags from the beginning of file +func readID3v2(file *os.File) (*AudioMetadata, error) { + file.Seek(0, io.SeekStart) + + // Read ID3v2 header (10 bytes) + header := make([]byte, 10) + if _, err := io.ReadFull(file, header); err != nil { + return nil, err + } + + // Check for "ID3" identifier + if string(header[0:3]) != "ID3" { + return nil, fmt.Errorf("no ID3v2 header") + } + + // Get version + majorVersion := header[3] + // minorVersion := header[4] + flags := header[5] + unsync := (flags & 0x80) != 0 + extendedHeader := (flags & 0x40) != 0 + footerPresent := (flags & 0x10) != 0 + + // Get tag size (syncsafe integer) + size := int(header[6])<<21 | int(header[7])<<14 | int(header[8])<<7 | int(header[9]) + + // Read all tag data + tagData := make([]byte, size) + if _, err := io.ReadFull(file, tagData); err != nil { + return nil, err + } + + // Remove footer if present (10 bytes, starts with "3DI") + if footerPresent && len(tagData) >= 10 { + footerStart := len(tagData) - 10 + if footerStart >= 0 && string(tagData[footerStart:footerStart+3]) == "3DI" { + tagData = tagData[:footerStart] + } + } + + // Skip extended header if present + if extendedHeader { + if skip := extendedHeaderSize(tagData, majorVersion); skip > 0 && skip < len(tagData) { + tagData = tagData[skip:] + } + } + + metadata := &AudioMetadata{} + + // Parse frames based on version + if majorVersion == 2 { + parseID3v22Frames(tagData, metadata, unsync) + } else { + // ID3v2.3 and ID3v2.4 + parseID3v23Frames(tagData, metadata, majorVersion, unsync) + } + + return metadata, nil +} + +// parseID3v22Frames parses ID3v2.2 frames (3-char frame IDs) +func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) { + pos := 0 + for pos+6 < len(data) { + frameID := string(data[pos : pos+3]) + if frameID[0] == 0 { + break // Padding + } + + frameSize := int(data[pos+3])<<16 | int(data[pos+4])<<8 | int(data[pos+5]) + if frameSize <= 0 || pos+6+frameSize > len(data) { + break + } + + frameData := data[pos+6 : pos+6+frameSize] + if tagUnsync { + frameData = removeUnsync(frameData) + } + value := firstTextValue(extractTextFrame(frameData)) + + switch frameID { + case "TT2": // Title + metadata.Title = value + case "TP1": // Artist + metadata.Artist = value + case "TP2": // Album Artist + metadata.AlbumArtist = value + case "TAL": // Album + metadata.Album = value + case "TYE": // Year + metadata.Year = value + case "TCO": // Genre + metadata.Genre = cleanGenre(value) + case "TRK": // Track + metadata.TrackNumber = parseTrackNumber(value) + case "TPA": // Disc + metadata.DiscNumber = parseTrackNumber(value) + } + + pos += 6 + frameSize + } +} + +// parseID3v23Frames parses ID3v2.3 and ID3v2.4 frames (4-char frame IDs) +func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUnsync bool) { + pos := 0 + for pos+10 < len(data) { + frameID := string(data[pos : pos+4]) + if frameID[0] == 0 { + break // Padding + } + + var frameSize int + if version == 4 { + // ID3v2.4 uses syncsafe integers + frameSize = int(data[pos+4])<<21 | int(data[pos+5])<<14 | int(data[pos+6])<<7 | int(data[pos+7]) + } else { + // ID3v2.3 uses regular integers + frameSize = int(data[pos+4])<<24 | int(data[pos+5])<<16 | int(data[pos+6])<<8 | int(data[pos+7]) + } + + if frameSize <= 0 || pos+10+frameSize > len(data) { + break + } + + frameData := data[pos+10 : pos+10+frameSize] + + statusFlags := data[pos+8] + _ = statusFlags + formatFlags := data[pos+9] + + // Handle frame-specific flags + if version == 3 { + // ID3v2.3 format flags: compression/encryption/grouping not supported + const ( + id3v23FlagCompression = 0x80 + id3v23FlagEncryption = 0x40 + id3v23FlagGrouping = 0x20 + ) + if formatFlags&(id3v23FlagCompression|id3v23FlagEncryption) != 0 { + pos += 10 + frameSize + continue + } + if formatFlags&id3v23FlagGrouping != 0 { + if len(frameData) < 1 { + pos += 10 + frameSize + continue + } + frameData = frameData[1:] // skip group ID + } + if tagUnsync { + frameData = removeUnsync(frameData) + } + } else if version == 4 { + // ID3v2.4 format flags: grouping, compression, encryption, unsync, data length indicator + const ( + id3v24FlagGrouping = 0x40 + id3v24FlagCompression = 0x08 + id3v24FlagEncryption = 0x04 + id3v24FlagUnsync = 0x02 + id3v24FlagDataLen = 0x01 + ) + if formatFlags&id3v24FlagGrouping != 0 { + if len(frameData) < 1 { + pos += 10 + frameSize + continue + } + frameData = frameData[1:] // skip group ID + } + if formatFlags&id3v24FlagDataLen != 0 { + if len(frameData) < 4 { + pos += 10 + frameSize + continue + } + frameData = frameData[4:] + } + if formatFlags&id3v24FlagUnsync != 0 || tagUnsync { + frameData = removeUnsync(frameData) + } + if formatFlags&(id3v24FlagCompression|id3v24FlagEncryption) != 0 { + pos += 10 + frameSize + continue + } + } + + value := firstTextValue(extractTextFrame(frameData)) + + switch frameID { + case "TIT2": // Title + metadata.Title = value + case "TPE1": // Artist + metadata.Artist = value + case "TPE2": // Album Artist + metadata.AlbumArtist = value + case "TALB": // Album + metadata.Album = value + case "TYER", "TDRC": // Year + metadata.Year = value + if len(value) >= 4 { + metadata.Date = value + } + case "TCON": // Genre + metadata.Genre = cleanGenre(value) + case "TRCK": // Track + metadata.TrackNumber = parseTrackNumber(value) + case "TPOS": // Disc + metadata.DiscNumber = parseTrackNumber(value) + case "TSRC": // ISRC + metadata.ISRC = value + } + + pos += 10 + frameSize + } +} + +// readID3v1 reads ID3v1 tag from end of file +func readID3v1(file *os.File) (*AudioMetadata, error) { + // Seek to last 128 bytes + if _, err := file.Seek(-128, io.SeekEnd); err != nil { + return nil, err + } + + tag := make([]byte, 128) + if _, err := io.ReadFull(file, tag); err != nil { + return nil, err + } + + // Check for "TAG" identifier + if string(tag[0:3]) != "TAG" { + return nil, fmt.Errorf("no ID3v1 tag") + } + + metadata := &AudioMetadata{ + Title: strings.TrimRight(string(tag[3:33]), " \x00"), + Artist: strings.TrimRight(string(tag[33:63]), " \x00"), + Album: strings.TrimRight(string(tag[63:93]), " \x00"), + Year: strings.TrimRight(string(tag[93:97]), " \x00"), + } + + // ID3v1.1 track number (if byte 125 is 0 and byte 126 is not) + if tag[125] == 0 && tag[126] != 0 { + metadata.TrackNumber = int(tag[126]) + } + + // Genre index + genreIndex := int(tag[127]) + if genreIndex < len(id3v1Genres) { + metadata.Genre = id3v1Genres[genreIndex] + } + + return metadata, nil +} + +// extractTextFrame extracts text from ID3 text frame +func extractTextFrame(data []byte) string { + if len(data) == 0 { + return "" + } + + encoding := data[0] + text := data[1:] + + switch encoding { + case 0: // ISO-8859-1 + return strings.TrimRight(string(text), "\x00") + case 1: // UTF-16 with BOM + return decodeUTF16(text) + case 2: // UTF-16BE + return decodeUTF16BE(text) + case 3: // UTF-8 + return strings.TrimRight(string(text), "\x00") + default: + return strings.TrimRight(string(text), "\x00") + } +} + +// decodeUTF16 decodes UTF-16 with BOM +func decodeUTF16(data []byte) string { + if len(data) < 2 { + return "" + } + + // Check BOM + var littleEndian bool + if data[0] == 0xFF && data[1] == 0xFE { + littleEndian = true + data = data[2:] + } else if data[0] == 0xFE && data[1] == 0xFF { + littleEndian = false + data = data[2:] + } + + return decodeUTF16Data(data, littleEndian) +} + +// decodeUTF16BE decodes UTF-16 Big Endian +func decodeUTF16BE(data []byte) string { + return decodeUTF16Data(data, false) +} + +// decodeUTF16Data decodes UTF-16 data +func decodeUTF16Data(data []byte, littleEndian bool) string { + if len(data) < 2 { + return "" + } + + var runes []rune + for i := 0; i+1 < len(data); i += 2 { + var r uint16 + if littleEndian { + r = uint16(data[i]) | uint16(data[i+1])<<8 + } else { + r = uint16(data[i])<<8 | uint16(data[i+1]) + } + if r == 0 { + break + } + runes = append(runes, rune(r)) + } + return string(runes) +} + +// cleanGenre removes ID3 genre number format like "(17)" or "(17)Rock" +func cleanGenre(genre string) string { + if len(genre) == 0 { + return "" + } + + // Handle "(17)" or "(17)Rock" format + if genre[0] == '(' { + end := strings.Index(genre, ")") + if end > 0 { + numStr := genre[1:end] + if num, err := strconv.Atoi(numStr); err == nil && num < len(id3v1Genres) { + // If there's text after the number, use it + if end+1 < len(genre) { + return genre[end+1:] + } + return id3v1Genres[num] + } + } + } + return genre +} + +// parseTrackNumber extracts track number from "1/10" or "1" format +func parseTrackNumber(s string) int { + s = strings.TrimSpace(s) + if idx := strings.Index(s, "/"); idx > 0 { + s = s[:idx] + } + num, _ := strconv.Atoi(s) + return num +} + +// removeUnsync removes ID3 unsynchronization (0xFF 0x00 -> 0xFF) +func removeUnsync(data []byte) []byte { + if len(data) == 0 { + return data + } + out := make([]byte, 0, len(data)) + for i := 0; i < len(data); i++ { + b := data[i] + out = append(out, b) + if b == 0xFF && i+1 < len(data) && data[i+1] == 0x00 { + i++ + } + } + return out +} + +// extendedHeaderSize returns the total number of bytes to skip for the extended header +func extendedHeaderSize(data []byte, version byte) int { + if len(data) < 4 { + return 0 + } + var size int + if version == 3 { + size = int(binary.BigEndian.Uint32(data[:4])) + } else if version == 4 { + size = syncsafeToInt(data[:4]) + } else { + return 0 + } + if size <= 0 { + return 0 + } + total := size + 4 + if total <= len(data) { + return total + } + if size <= len(data) { + return size + } + return 0 +} + +// syncsafeToInt decodes a 4-byte syncsafe integer +func syncsafeToInt(b []byte) int { + if len(b) < 4 { + return 0 + } + return int(b[0])<<21 | int(b[1])<<14 | int(b[2])<<7 | int(b[3]) +} + +// firstTextValue returns the first value in a null-separated text list +func firstTextValue(s string) string { + if idx := strings.IndexByte(s, 0); idx >= 0 { + return s[:idx] + } + return s +} + +// GetMP3Quality reads MP3 audio quality info +func GetMP3Quality(filePath string) (*MP3Quality, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + quality := &MP3Quality{} + + // Get file size for duration estimation + stat, err := file.Stat() + if err != nil { + return nil, err + } + fileSize := stat.Size() + + // Skip ID3v2 header if present + header := make([]byte, 10) + if _, err := io.ReadFull(file, header); err != nil { + return nil, err + } + + var audioStart int64 = 0 + if string(header[0:3]) == "ID3" { + tagSize := int64(header[6])<<21 | int64(header[7])<<14 | int64(header[8])<<7 | int64(header[9]) + audioStart = 10 + tagSize + } + + // Seek to audio start + file.Seek(audioStart, io.SeekStart) + + // Find first valid MP3 frame + frameHeader := make([]byte, 4) + for i := 0; i < 10000; i++ { // Search first 10KB + if _, err := io.ReadFull(file, frameHeader); err != nil { + break + } + + // Check for sync word (11 set bits) + if frameHeader[0] == 0xFF && (frameHeader[1]&0xE0) == 0xE0 { + // Parse frame header + version := (frameHeader[1] >> 3) & 0x03 + layer := (frameHeader[1] >> 1) & 0x03 + bitrateIdx := (frameHeader[2] >> 4) & 0x0F + sampleRateIdx := (frameHeader[2] >> 2) & 0x03 + + // Get sample rate + sampleRates := [][]int{ + {11025, 12000, 8000}, // MPEG 2.5 + {0, 0, 0}, // Reserved + {22050, 24000, 16000}, // MPEG 2 + {44100, 48000, 32000}, // MPEG 1 + } + if version < 4 && sampleRateIdx < 3 { + quality.SampleRate = sampleRates[version][sampleRateIdx] + } + + // Get bitrate (for MPEG 1 Layer 3) + if version == 3 && layer == 1 { // MPEG 1, Layer 3 + bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0} + if bitrateIdx < 16 { + quality.Bitrate = bitrates[bitrateIdx] * 1000 + } + } + + // MP3 is always 16-bit PCM when decoded + quality.BitDepth = 16 + + // Estimate duration from file size and bitrate + if quality.Bitrate > 0 { + audioSize := fileSize - audioStart - 128 // Subtract ID3v1 tag + if audioSize > 0 { + quality.Duration = int(audioSize * 8 / int64(quality.Bitrate)) + } + } + + break + } + + // Seek back 3 bytes to continue search + file.Seek(-3, io.SeekCurrent) + } + + return quality, nil +} + +// ============================================================================= +// Ogg/Opus Vorbis Comment Reading +// ============================================================================= + +// ReadOggVorbisComments reads Vorbis comments from Ogg/Opus files +func ReadOggVorbisComments(filePath string) (*AudioMetadata, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + metadata := &AudioMetadata{} + + packets, err := collectOggPackets(file, 30, 80) + if err != nil && len(packets) == 0 { + return nil, err + } + + streamType := detectOggStreamType(packets) + for _, pkt := range packets { + if streamType == oggStreamOpus { + if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" { + parseVorbisComments(pkt[8:], metadata) + break + } + continue + } + if streamType == oggStreamVorbis || streamType == oggStreamUnknown { + if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" { + parseVorbisComments(pkt[7:], metadata) + break + } + } + // Fallback: if unknown, still try OpusTags + if streamType == oggStreamUnknown { + if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" { + parseVorbisComments(pkt[8:], metadata) + break + } + } + } + + if metadata.Title == "" && metadata.Artist == "" { + return nil, fmt.Errorf("no Vorbis comments found") + } + + return metadata, nil +} + +type oggPage struct { + headerType byte + segmentTable []byte + data []byte +} + +// readOggPageWithHeader reads a single Ogg page including header info +func readOggPageWithHeader(file *os.File) (*oggPage, error) { + // Read page header + header := make([]byte, 27) + if _, err := io.ReadFull(file, header); err != nil { + return nil, err + } + + // Check capture pattern "OggS" + if string(header[0:4]) != "OggS" { + return nil, fmt.Errorf("not an Ogg page") + } + + headerType := header[5] + numSegments := int(header[26]) + + // Read segment table + segmentTable := make([]byte, numSegments) + if _, err := io.ReadFull(file, segmentTable); err != nil { + return nil, err + } + + // Calculate total page size + var pageSize int + for _, seg := range segmentTable { + pageSize += int(seg) + } + + // Read page data + pageData := make([]byte, pageSize) + if _, err := io.ReadFull(file, pageData); err != nil { + return nil, err + } + + return &oggPage{ + headerType: headerType, + segmentTable: segmentTable, + data: pageData, + }, nil +} + +// readOggPage reads a single Ogg page (data only) +func readOggPage(file *os.File) ([]byte, error) { + page, err := readOggPageWithHeader(file) + if err != nil { + return nil, err + } + return page.data, nil +} + +// collectOggPackets reads Ogg pages and returns reassembled packets +func collectOggPackets(file *os.File, maxPackets, maxPages int) ([][]byte, error) { + const maxPacketSize = 10 * 1024 * 1024 + var packets [][]byte + var cur []byte + skipPacket := false + + for pageNum := 0; pageNum < maxPages && len(packets) < maxPackets; pageNum++ { + page, err := readOggPageWithHeader(file) + if err != nil { + if len(packets) > 0 { + return packets, nil + } + return nil, err + } + + // If this page is not a continuation but we have partial packet, drop it + if page.headerType&0x01 == 0 && len(cur) > 0 { + cur = nil + skipPacket = false + } + + offset := 0 + for _, seg := range page.segmentTable { + segLen := int(seg) + if offset+segLen > len(page.data) { + return packets, fmt.Errorf("invalid ogg segment size") + } + + if skipPacket { + offset += segLen + if segLen < 255 { + skipPacket = false + } + continue + } + + if len(cur)+segLen > maxPacketSize { + // Skip this oversized packet + cur = nil + skipPacket = true + offset += segLen + if segLen < 255 { + skipPacket = false + } + continue + } + + cur = append(cur, page.data[offset:offset+segLen]...) + offset += segLen + + if segLen < 255 { + if len(cur) > 0 { + packets = append(packets, cur) + } + cur = nil + if len(packets) >= maxPackets { + return packets, nil + } + } + } + } + + return packets, nil +} + +type oggStreamType int + +const ( + oggStreamUnknown oggStreamType = iota + oggStreamOpus + oggStreamVorbis +) + +func detectOggStreamType(packets [][]byte) oggStreamType { + for _, p := range packets { + if len(p) >= 8 && string(p[0:8]) == "OpusHead" { + return oggStreamOpus + } + if len(p) > 7 && p[0] == 0x01 && string(p[1:7]) == "vorbis" { + return oggStreamVorbis + } + } + return oggStreamUnknown +} + +// parseVorbisComments parses Vorbis comment block +func parseVorbisComments(data []byte, metadata *AudioMetadata) { + if len(data) < 4 { + return + } + + reader := bytes.NewReader(data) + + // Read vendor string length + var vendorLen uint32 + if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil { + return + } + + // Skip vendor string + if vendorLen > uint32(len(data)-4) { + return + } + vendor := make([]byte, vendorLen) + if _, err := reader.Read(vendor); err != nil { + return + } + + // Read comment count + var commentCount uint32 + if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil { + return + } + + // Read each comment + for i := uint32(0); i < commentCount && i < 100; i++ { + var commentLen uint32 + if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil { + break + } + + if commentLen > 10000 { // Sanity check + break + } + + comment := make([]byte, commentLen) + if _, err := reader.Read(comment); err != nil { + break + } + + // Parse "KEY=VALUE" format + parts := strings.SplitN(string(comment), "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.ToUpper(parts[0]) + value := parts[1] + + switch key { + case "TITLE": + metadata.Title = value + case "ARTIST": + metadata.Artist = value + case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST": + metadata.AlbumArtist = value + case "ALBUM": + metadata.Album = value + case "DATE", "YEAR": + metadata.Date = value + if len(value) >= 4 { + metadata.Year = value[:4] + } + case "GENRE": + metadata.Genre = value + case "TRACKNUMBER", "TRACK": + metadata.TrackNumber = parseTrackNumber(value) + case "DISCNUMBER", "DISC": + metadata.DiscNumber = parseTrackNumber(value) + case "ISRC": + metadata.ISRC = value + } + } +} + +// GetOggQuality reads Ogg/Opus audio quality info +func GetOggQuality(filePath string) (*OggQuality, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + quality := &OggQuality{} + isOpus := false + + packets, err := collectOggPackets(file, 5, 10) + if err != nil && len(packets) == 0 { + return nil, err + } + + streamType := detectOggStreamType(packets) + if streamType == oggStreamUnknown { + // Fallback to file extension + if strings.HasSuffix(strings.ToLower(filePath), ".opus") { + streamType = oggStreamOpus + } else { + streamType = oggStreamVorbis + } + } + + if streamType == oggStreamOpus { + isOpus = true + for _, pkt := range packets { + if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" { + quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16])) + if quality.SampleRate == 0 { + quality.SampleRate = 48000 + } + quality.BitDepth = 16 + break + } + } + } else { + for _, pkt := range packets { + if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" { + quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16])) + quality.BitDepth = 16 + break + } + } + } + + // Get file size for duration estimation + stat, err := file.Stat() + if err == nil { + // Very rough duration estimate based on file size + // Assume ~128kbps average for Opus, ~160kbps for Vorbis + avgBitrate := 128000 + if !isOpus { + avgBitrate = 160000 + } + quality.Duration = int(stat.Size() * 8 / int64(avgBitrate)) + } + + return quality, nil +} + +// ============================================================================= +// ID3v1 Genre List +// ============================================================================= + +var id3v1Genres = []string{ + "Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", + "Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", + "Rap", "Reggae", "Rock", "Techno", "Industrial", "Alternative", "Ska", + "Death Metal", "Pranks", "Soundtrack", "Euro-Techno", "Ambient", + "Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance", "Classical", + "Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel", + "Noise", "AlternRock", "Bass", "Soul", "Punk", "Space", "Meditative", + "Instrumental Pop", "Instrumental Rock", "Ethnic", "Gothic", + "Darkwave", "Techno-Industrial", "Electronic", "Pop-Folk", "Eurodance", + "Dream", "Southern Rock", "Comedy", "Cult", "Gangsta", "Top 40", + "Christian Rap", "Pop/Funk", "Jungle", "Native American", "Cabaret", + "New Wave", "Psychedelic", "Rave", "Showtunes", "Trailer", "Lo-Fi", + "Tribal", "Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", + "Rock & Roll", "Hard Rock", "Folk", "Folk-Rock", "National Folk", + "Swing", "Fast Fusion", "Bebop", "Latin", "Revival", "Celtic", + "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock", + "Psychedelic Rock", "Symphonic Rock", "Slow Rock", "Big Band", + "Chorus", "Easy Listening", "Acoustic", "Humour", "Speech", "Chanson", + "Opera", "Chamber Music", "Sonata", "Symphony", "Booty Bass", "Primus", + "Porn Groove", "Satire", "Slow Jam", "Club", "Tango", "Samba", + "Folklore", "Ballad", "Power Ballad", "Rhythmic Soul", "Freestyle", + "Duet", "Punk Rock", "Drum Solo", "A capella", "Euro-House", + "Dance Hall", "Goa", "Drum & Bass", "Club-House", "Hardcore", + "Terror", "Indie", "BritPop", "Negerpunk", "Polsk Punk", "Beat", + "Christian Gangsta Rap", "Heavy Metal", "Black Metal", "Crossover", + "Contemporary Christian", "Christian Rock", "Merengue", "Salsa", + "Thrash Metal", "Anime", "J-Pop", "Synthpop", +} + +// ============================================================================= +// Cover Art Extraction +// ============================================================================= + +// extractMP3CoverArt extracts cover art from MP3 file (APIC frame) +func extractMP3CoverArt(filePath string) ([]byte, string, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, "", err + } + defer file.Close() + + // Read ID3v2 header + header := make([]byte, 10) + if _, err := io.ReadFull(file, header); err != nil { + return nil, "", err + } + + if string(header[0:3]) != "ID3" { + return nil, "", fmt.Errorf("no ID3v2 header") + } + + majorVersion := header[3] + size := int(header[6])<<21 | int(header[7])<<14 | int(header[8])<<7 | int(header[9]) + + tagData := make([]byte, size) + if _, err := io.ReadFull(file, tagData); err != nil { + return nil, "", err + } + + // Parse frames looking for APIC (Attached Picture) + pos := 0 + var frameIDLen, headerLen int + if majorVersion == 2 { + frameIDLen = 3 + headerLen = 6 + } else { + frameIDLen = 4 + headerLen = 10 + } + + for pos+headerLen < len(tagData) { + frameID := string(tagData[pos : pos+frameIDLen]) + if frameID[0] == 0 { + break + } + + var frameSize int + if majorVersion == 2 { + frameSize = int(tagData[pos+3])<<16 | int(tagData[pos+4])<<8 | int(tagData[pos+5]) + } else if majorVersion == 4 { + frameSize = int(tagData[pos+4])<<21 | int(tagData[pos+5])<<14 | int(tagData[pos+6])<<7 | int(tagData[pos+7]) + } else { + frameSize = int(tagData[pos+4])<<24 | int(tagData[pos+5])<<16 | int(tagData[pos+6])<<8 | int(tagData[pos+7]) + } + + if frameSize <= 0 || pos+headerLen+frameSize > len(tagData) { + break + } + + // Check for APIC (ID3v2.3/2.4) or PIC (ID3v2.2) + if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") { + frameData := tagData[pos+headerLen : pos+headerLen+frameSize] + imageData, mimeType := parseAPICFrame(frameData, majorVersion) + if len(imageData) > 0 { + return imageData, mimeType, nil + } + } + + pos += headerLen + frameSize + } + + return nil, "", fmt.Errorf("no cover art found") +} + +// parseAPICFrame parses APIC frame data +func parseAPICFrame(data []byte, version byte) ([]byte, string) { + if len(data) < 4 { + return nil, "" + } + + pos := 0 + encoding := data[pos] + pos++ + + // Read MIME type + var mimeType string + if version == 2 { + // ID3v2.2: 3-byte image format (JPG, PNG) + if pos+3 > len(data) { + return nil, "" + } + format := string(data[pos : pos+3]) + pos += 3 + switch format { + case "JPG": + mimeType = "image/jpeg" + case "PNG": + mimeType = "image/png" + default: + mimeType = "image/jpeg" + } + } else { + // ID3v2.3/2.4: null-terminated MIME string + end := pos + for end < len(data) && data[end] != 0 { + end++ + } + mimeType = string(data[pos:end]) + pos = end + 1 + } + + if pos >= len(data) { + return nil, "" + } + + // Skip picture type + // pictureType := data[pos] + pos++ + + // Skip description (null-terminated, may be UTF-16) + if encoding == 0 || encoding == 3 { + // ISO-8859-1 or UTF-8 + for pos < len(data) && data[pos] != 0 { + pos++ + } + pos++ // Skip null + } else { + // UTF-16: look for double null + for pos+1 < len(data) { + if data[pos] == 0 && data[pos+1] == 0 { + pos += 2 + break + } + pos++ + } + } + + if pos >= len(data) { + return nil, "" + } + + // Rest is image data + return data[pos:], mimeType +} + +// extractOggCoverArt extracts cover art from Ogg/Opus file (METADATA_BLOCK_PICTURE) +func extractOggCoverArt(filePath string) ([]byte, string, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, "", err + } + defer file.Close() + + packets, err := collectOggPackets(file, 30, 80) + if err != nil && len(packets) == 0 { + return nil, "", err + } + + streamType := detectOggStreamType(packets) + for _, pkt := range packets { + var comments []byte + if streamType == oggStreamOpus { + if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" { + comments = pkt[8:] + } + } else { + if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" { + comments = pkt[7:] + } + } + if len(comments) == 0 && streamType == oggStreamUnknown { + if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" { + comments = pkt[8:] + } else if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" { + comments = pkt[7:] + } + } + + if len(comments) > 0 { + imageData, mimeType := extractPictureFromVorbisComments(comments) + if len(imageData) > 0 { + return imageData, mimeType, nil + } + } + } + + return nil, "", fmt.Errorf("no cover art found") +} + +// extractPictureFromVorbisComments looks for METADATA_BLOCK_PICTURE in Vorbis comments +func extractPictureFromVorbisComments(data []byte) ([]byte, string) { + if len(data) < 8 { + return nil, "" + } + + reader := bytes.NewReader(data) + + // Skip vendor string + var vendorLen uint32 + if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil { + return nil, "" + } + if vendorLen > uint32(len(data)-4) { + return nil, "" + } + reader.Seek(int64(vendorLen), io.SeekCurrent) + + // Read comment count + var commentCount uint32 + if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil { + return nil, "" + } + + // Look for METADATA_BLOCK_PICTURE + for i := uint32(0); i < commentCount && i < 100; i++ { + var commentLen uint32 + if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil { + break + } + if commentLen > 10000000 { // 10MB sanity check + break + } + + comment := make([]byte, commentLen) + if _, err := reader.Read(comment); err != nil { + break + } + + // Check for METADATA_BLOCK_PICTURE= + key := "METADATA_BLOCK_PICTURE=" + if len(comment) > len(key) && strings.ToUpper(string(comment[:len(key)])) == key { + // Base64-encoded FLAC picture block + b64Data := comment[len(key):] + decoded := make([]byte, base64StdDecodeLen(len(b64Data))) + n, err := base64StdDecode(decoded, b64Data) + if err != nil { + continue + } + decoded = decoded[:n] + + // Parse FLAC picture block + imageData, mimeType := parseFLACPictureBlock(decoded) + if len(imageData) > 0 { + return imageData, mimeType + } + } + } + + return nil, "" +} + +// parseFLACPictureBlock parses FLAC PICTURE metadata block format +func parseFLACPictureBlock(data []byte) ([]byte, string) { + if len(data) < 32 { + return nil, "" + } + + reader := bytes.NewReader(data) + + // Picture type (4 bytes) + var pictureType uint32 + binary.Read(reader, binary.BigEndian, &pictureType) + + // MIME type length (4 bytes) + var mimeLen uint32 + binary.Read(reader, binary.BigEndian, &mimeLen) + if mimeLen > 256 { + return nil, "" + } + + // MIME type + mimeBytes := make([]byte, mimeLen) + reader.Read(mimeBytes) + mimeType := string(mimeBytes) + + // Description length (4 bytes) + var descLen uint32 + binary.Read(reader, binary.BigEndian, &descLen) + if descLen > 10000 { + return nil, "" + } + + // Skip description + reader.Seek(int64(descLen), io.SeekCurrent) + + // Skip width, height, color depth, colors used (16 bytes) + reader.Seek(16, io.SeekCurrent) + + // Image data length (4 bytes) + var dataLen uint32 + binary.Read(reader, binary.BigEndian, &dataLen) + if dataLen > 10000000 { // 10MB + return nil, "" + } + + // Image data + imageData := make([]byte, dataLen) + reader.Read(imageData) + + return imageData, mimeType +} + +// base64StdDecodeLen returns decoded length +func base64StdDecodeLen(n int) int { + return n * 6 / 8 +} + +// base64StdDecode decodes base64 data (simplified) +func base64StdDecode(dst, src []byte) (int, error) { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + + decodeMap := make([]byte, 256) + for i := range decodeMap { + decodeMap[i] = 0xFF + } + for i := 0; i < len(alphabet); i++ { + decodeMap[alphabet[i]] = byte(i) + } + + si, di := 0, 0 + for si < len(src) { + // Skip whitespace and newlines + for si < len(src) && (src[si] == '\n' || src[si] == '\r' || src[si] == ' ' || src[si] == '\t') { + si++ + } + if si >= len(src) { + break + } + + // Read 4 characters + var vals [4]byte + var valCount int + for valCount < 4 && si < len(src) { + c := src[si] + si++ + if c == '=' { + vals[valCount] = 0 + valCount++ + } else if c == '\n' || c == '\r' || c == ' ' || c == '\t' { + continue + } else if decodeMap[c] != 0xFF { + vals[valCount] = decodeMap[c] + valCount++ + } + } + + if valCount < 2 { + break + } + + // Decode + if di < len(dst) { + dst[di] = vals[0]<<2 | vals[1]>>4 + di++ + } + if valCount >= 3 && di < len(dst) { + dst[di] = vals[1]<<4 | vals[2]>>2 + di++ + } + if valCount >= 4 && di < len(dst) { + dst[di] = vals[2]<<6 | vals[3] + di++ + } + } + + return di, nil +} + +// extractAnyCoverArt extracts cover art from any supported audio file +func extractAnyCoverArt(filePath string) ([]byte, string, error) { + ext := strings.ToLower(filepath.Ext(filePath)) + + switch ext { + case ".flac": + // Use existing ExtractCoverArt function + data, err := ExtractCoverArt(filePath) + if err != nil { + return nil, "", err + } + // Detect MIME type from magic bytes + mimeType := "image/jpeg" + if len(data) > 8 && string(data[1:4]) == "PNG" { + mimeType = "image/png" + } + return data, mimeType, nil + + case ".mp3": + return extractMP3CoverArt(filePath) + + case ".opus", ".ogg": + return extractOggCoverArt(filePath) + + case ".m4a": + // M4A cover extraction would need more complex MP4 atom parsing + // For now, return error + return nil, "", fmt.Errorf("M4A cover extraction not yet supported") + + default: + return nil, "", fmt.Errorf("unsupported format: %s", ext) + } +} + +// SaveCoverToCache extracts and saves cover art to cache directory +// Returns the path to the saved cover image, or empty string if no cover found +func SaveCoverToCache(filePath, cacheDir string) (string, error) { + // Generate cache filename from file path + size + mtime to reduce stale cache + cacheKey := filePath + if stat, err := os.Stat(filePath); err == nil { + cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano()) + } + hash := hashString(cacheKey) + + // Check if cover already cached + jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash)) + pngPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.png", hash)) + + if _, err := os.Stat(jpgPath); err == nil { + return jpgPath, nil + } + if _, err := os.Stat(pngPath); err == nil { + return pngPath, nil + } + + // Extract cover art + imageData, mimeType, err := extractAnyCoverArt(filePath) + if err != nil { + return "", err + } + + // Ensure cache directory exists + if err := os.MkdirAll(cacheDir, 0755); err != nil { + return "", fmt.Errorf("failed to create cache dir: %w", err) + } + + // Determine file extension + var cachePath string + if strings.Contains(mimeType, "png") { + cachePath = pngPath + } else { + cachePath = jpgPath + } + + // Write to file + if err := os.WriteFile(cachePath, imageData, 0644); err != nil { + return "", fmt.Errorf("failed to write cover: %w", err) + } + + return cachePath, nil +} diff --git a/go_backend/exports.go b/go_backend/exports.go index f01352f0..2d0ed9c5 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -2092,6 +2092,11 @@ func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) { // ==================== LOCAL LIBRARY SCANNING ==================== +// SetLibraryCoverCacheDirJSON sets the directory for caching extracted cover art +func SetLibraryCoverCacheDirJSON(cacheDir string) { + SetLibraryCoverCacheDir(cacheDir) +} + // ScanLibraryFolderJSON scans a folder for audio files and returns metadata func ScanLibraryFolderJSON(folderPath string) (string, error) { return ScanLibraryFolder(folderPath) diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index ea44a698..dd133394 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -18,6 +18,7 @@ type LibraryScanResult struct { AlbumName string `json:"albumName"` AlbumArtist string `json:"albumArtist,omitempty"` FilePath string `json:"filePath"` + CoverPath string `json:"coverPath,omitempty"` ScannedAt string `json:"scannedAt"` ISRC string `json:"isrc,omitempty"` TrackNumber int `json:"trackNumber,omitempty"` @@ -45,6 +46,8 @@ var ( libraryScanProgressMu sync.RWMutex libraryScanCancel chan struct{} libraryScanCancelMu sync.Mutex + libraryCoverCacheDir string // Directory to cache extracted cover art + libraryCoverCacheMu sync.RWMutex ) // supportedAudioFormats lists file extensions we can read metadata from @@ -56,6 +59,13 @@ var supportedAudioFormats = map[string]bool{ ".ogg": true, } +// SetLibraryCoverCacheDir sets the directory to cache extracted cover art +func SetLibraryCoverCacheDir(cacheDir string) { + libraryCoverCacheMu.Lock() + libraryCoverCacheDir = cacheDir + libraryCoverCacheMu.Unlock() +} + // ScanLibraryFolder scans a folder recursively for audio files and reads their metadata // Returns JSON array of LibraryScanResult func ScanLibraryFolder(folderPath string) (string, error) { @@ -183,6 +193,17 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) { Format: strings.TrimPrefix(ext, "."), } + // Try to extract and cache cover art + libraryCoverCacheMu.RLock() + coverCacheDir := libraryCoverCacheDir + libraryCoverCacheMu.RUnlock() + if coverCacheDir != "" && ext != ".m4a" { + coverPath, err := SaveCoverToCache(filePath, coverCacheDir) + if err == nil && coverPath != "" { + result.CoverPath = coverPath + } + } + // Try to read metadata based on format switch ext { case ".flac": @@ -257,14 +278,86 @@ func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult // scanMP3File reads metadata from MP3 file (ID3 tags) func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { - // We don't have ID3 parsing in Go backend yet, use filename - return scanFromFilename(filePath, result) + metadata, err := ReadID3Tags(filePath) + if err != nil { + GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err) + return scanFromFilename(filePath, result) + } + + result.TrackName = metadata.Title + result.ArtistName = metadata.Artist + result.AlbumName = metadata.Album + result.AlbumArtist = metadata.AlbumArtist + result.TrackNumber = metadata.TrackNumber + result.DiscNumber = metadata.DiscNumber + result.Genre = metadata.Genre + if metadata.Date != "" { + result.ReleaseDate = metadata.Date + } else { + result.ReleaseDate = metadata.Year + } + result.ISRC = metadata.ISRC + + // Get audio quality info + quality, err := GetMP3Quality(filePath) + if err == nil { + result.SampleRate = quality.SampleRate + result.BitDepth = quality.BitDepth + result.Duration = quality.Duration + } + + // Ensure we have at least a title + if result.TrackName == "" { + result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) + } + if result.ArtistName == "" { + result.ArtistName = "Unknown Artist" + } + if result.AlbumName == "" { + result.AlbumName = "Unknown Album" + } + + return result, nil } -// scanOggFile reads metadata from Ogg Vorbis/Opus file +// scanOggFile reads metadata from Ogg Vorbis/Opus file (Vorbis comments) func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { - // Limited support, use filename - return scanFromFilename(filePath, result) + metadata, err := ReadOggVorbisComments(filePath) + if err != nil { + GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err) + return scanFromFilename(filePath, result) + } + + result.TrackName = metadata.Title + result.ArtistName = metadata.Artist + result.AlbumName = metadata.Album + result.AlbumArtist = metadata.AlbumArtist + result.ISRC = metadata.ISRC + result.TrackNumber = metadata.TrackNumber + result.DiscNumber = metadata.DiscNumber + result.Genre = metadata.Genre + result.ReleaseDate = metadata.Date + + // Get audio quality info + quality, err := GetOggQuality(filePath) + if err == nil { + result.SampleRate = quality.SampleRate + result.BitDepth = quality.BitDepth + result.Duration = quality.Duration + } + + // Ensure we have at least a title + if result.TrackName == "" { + result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) + } + if result.ArtistName == "" { + result.ArtistName = "Unknown Artist" + } + if result.AlbumName == "" { + result.AlbumName = "Unknown Album" + } + + return result, nil } // scanFromFilename extracts title/artist from filename pattern diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 36976e10..5a840ee3 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -688,6 +688,12 @@ import Gobackend // Import Go framework return response // Local Library Scanning + case "setLibraryCoverCacheDir": + let args = call.arguments as! [String: Any] + let cacheDir = args["cache_dir"] as! String + GobackendSetLibraryCoverCacheDirJSON(cacheDir) + return nil + case "scanLibraryFolder": let args = call.arguments as! [String: Any] let folderPath = args["folder_path"] as! String diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 9f3f890e..718bd572 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2131,10 +2131,10 @@ result = await PlatformBridge.downloadWithExtensions( if (result['success'] == true) { var filePath = result['file_path'] as String?; - final wasExisting = filePath != null && filePath.startsWith('EXISTS:'); + // Check if file already existed (detected via ISRC match in Go backend) + final wasExisting = result['already_exists'] == true; if (wasExisting) { - filePath = filePath.substring(7); // Remove "EXISTS:" prefix - _log.i('Using existing file: $filePath'); + _log.i('File already exists in library: $filePath'); } _log.i('Download success, file: $filePath'); @@ -2363,11 +2363,31 @@ result = await PlatformBridge.downloadWithExtensions( _completedInSession++; + // Check if this track is already in download history + final historyNotifier = ref.read(downloadHistoryProvider.notifier); + final existingInHistory = historyNotifier.getBySpotifyId(trackToDownload.id) ?? + (trackToDownload.isrc != null ? historyNotifier.getByIsrc(trackToDownload.isrc!) : null); + + if (wasExisting && existingInHistory != null) { + // File exists and is already in download history - skip adding + _log.i('Track already in library, skipping history update'); + await _notificationService.showDownloadComplete( + trackName: item.track.name, + artistName: item.track.artistName, + completedCount: _completedInSession, + totalCount: _totalQueuedAtStart, + alreadyInLibrary: true, + ); + removeItem(item.id); + return; + } + await _notificationService.showDownloadComplete( trackName: item.track.name, artistName: item.track.artistName, completedCount: _completedInSession, totalCount: _totalQueuedAtStart, + alreadyInLibrary: wasExisting, ); if (filePath != null) { diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 927a9fbc..8145db44 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -145,6 +146,16 @@ class LocalLibraryNotifier extends Notifier { scanErrorCount: 0, ); + // Set cover cache directory before scanning + try { + final cacheDir = await getApplicationCacheDirectory(); + final coverCacheDir = '${cacheDir.path}/library_covers'; + await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir); + _log.i('Cover cache directory set to: $coverCacheDir'); + } catch (e) { + _log.w('Failed to set cover cache directory: $e'); + } + // Start progress polling _startProgressPolling(); @@ -229,6 +240,14 @@ class LocalLibraryNotifier extends Notifier { _log.i('Library cleared'); } + /// Remove a single item from library by ID + Future removeItem(String id) async { + await _db.delete(id); + state = state.copyWith( + items: state.items.where((item) => item.id != id).toList(), + ); + } + /// Check if a track exists in library bool existsInLibrary({String? isrc, String? trackName, String? artistName}) { return state.existsInLibrary( diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart new file mode 100644 index 00000000..5dd403e5 --- /dev/null +++ b/lib/screens/local_album_screen.dart @@ -0,0 +1,735 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/utils/mime_utils.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/palette_service.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; + +/// Screen to display tracks from a local library album +class LocalAlbumScreen extends ConsumerStatefulWidget { + final String albumName; + final String artistName; + final String? coverPath; + final List tracks; + + const LocalAlbumScreen({ + super.key, + required this.albumName, + required this.artistName, + this.coverPath, + required this.tracks, + }); + + @override + ConsumerState createState() => _LocalAlbumScreenState(); +} + +class _LocalAlbumScreenState extends ConsumerState { + bool _isSelectionMode = false; + final Set _selectedIds = {}; + Color? _dominantColor; + bool _showTitleInAppBar = false; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + _extractDominantColor(); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + final shouldShow = _scrollController.offset > 280; + if (shouldShow != _showTitleInAppBar) { + setState(() => _showTitleInAppBar = shouldShow); + } + } + + Future _extractDominantColor() async { + if (widget.coverPath == null || widget.coverPath!.isEmpty) return; + + // Extract color from local file + final color = await PaletteService.instance.extractDominantColorFromFile(widget.coverPath!); + if (mounted && color != null && color != _dominantColor) { + setState(() { + _dominantColor = color; + }); + } + } + + List get _sortedTracks { + final tracks = List.from(widget.tracks); + tracks.sort((a, b) { + // Sort by disc number first, then by track number + final aDisc = a.discNumber ?? 1; + final bDisc = b.discNumber ?? 1; + if (aDisc != bDisc) return aDisc.compareTo(bDisc); + final aNum = a.trackNumber ?? 999; + final bNum = b.trackNumber ?? 999; + if (aNum != bNum) return aNum.compareTo(bNum); + return a.trackName.compareTo(b.trackName); + }); + return tracks; + } + + Map> _groupTracksByDisc(List tracks) { + final discMap = >{}; + for (final track in tracks) { + final discNumber = track.discNumber ?? 1; + discMap.putIfAbsent(discNumber, () => []).add(track); + } + return discMap; + } + + void _enterSelectionMode(String itemId) { + HapticFeedback.mediumImpact(); + setState(() { + _isSelectionMode = true; + _selectedIds.add(itemId); + }); + } + + void _exitSelectionMode() { + setState(() { + _isSelectionMode = false; + _selectedIds.clear(); + }); + } + + void _toggleSelection(String itemId) { + setState(() { + if (_selectedIds.contains(itemId)) { + _selectedIds.remove(itemId); + if (_selectedIds.isEmpty) { + _isSelectionMode = false; + } + } else { + _selectedIds.add(itemId); + } + }); + } + + void _selectAll(List tracks) { + setState(() { + _selectedIds.addAll(tracks.map((e) => e.id)); + }); + } + + Future _deleteSelected(List currentTracks) async { + final count = _selectedIds.length; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.downloadedAlbumDeleteSelected), + content: Text(context.l10n.downloadedAlbumDeleteMessage(count)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), + child: Text(context.l10n.dialogDelete), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + final libraryNotifier = ref.read(localLibraryProvider.notifier); + final idsToDelete = _selectedIds.toList(); + + int deletedCount = 0; + for (final id in idsToDelete) { + final item = currentTracks.where((e) => e.id == id).firstOrNull; + if (item != null) { + try { + final file = File(item.filePath); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + libraryNotifier.removeItem(id); + deletedCount++; + } + } + + _exitSelectionMode(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))), + ); + + // Go back if all tracks were deleted + if (deletedCount == currentTracks.length) { + Navigator.pop(context); + } + } + } + } + + Future _openFile(String filePath) async { + try { + final mimeType = audioMimeTypeForPath(filePath); + await OpenFilex.open(filePath, type: mimeType); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final bottomPadding = MediaQuery.of(context).padding.bottom; + final tracks = _sortedTracks; + + // Show empty state if no tracks found + if (tracks.isEmpty) { + return Scaffold( + appBar: AppBar( + title: Text(widget.albumName), + ), + body: const Center( + child: Text('No tracks found for this album'), + ), + ); + } + + final validIds = tracks.map((t) => t.id).toSet(); + _selectedIds.removeWhere((id) => !validIds.contains(id)); + if (_selectedIds.isEmpty && _isSelectionMode) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _isSelectionMode = false); + }); + } + + return PopScope( + canPop: !_isSelectionMode, + onPopInvokedWithResult: (didPop, result) { + if (!didPop && _isSelectionMode) { + _exitSelectionMode(); + } + }, + child: Scaffold( + body: Stack( + children: [ + CustomScrollView( + controller: _scrollController, + slivers: [ + _buildAppBar(context, colorScheme), + _buildInfoCard(context, colorScheme, tracks), + _buildTrackListHeader(context, colorScheme, tracks), + _buildTrackList(context, colorScheme, tracks), + SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)), + ], + ), + + AnimatedPositioned( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + left: 0, + right: 0, + bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), + child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding), + ), + ], + ), + ), + ); + } + + Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { + final screenWidth = MediaQuery.of(context).size.width; + final coverSize = screenWidth * 0.5; + final bgColor = _dominantColor ?? colorScheme.surface; + + return SliverAppBar( + expandedHeight: 320, + pinned: true, + stretch: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + title: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _showTitleInAppBar ? 1.0 : 0.0, + child: Text( + widget.albumName, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); + final showContent = collapseRatio > 0.3; + + return FlexibleSpaceBar( + collapseMode: CollapseMode.none, + background: Stack( + fit: StackFit.expand, + children: [ + // Background with dominant color + AnimatedContainer( + duration: const Duration(milliseconds: 500), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + bgColor, + bgColor.withValues(alpha: 0.8), + colorScheme.surface, + ], + stops: const [0.0, 0.6, 1.0], + ), + ), + ), + // Cover image centered + AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Center( + child: Padding( + padding: const EdgeInsets.only(top: 60), + child: Container( + width: coverSize, + height: coverSize, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 30, + offset: const Offset(0, 15), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: widget.coverPath != null + ? Image.file( + File(widget.coverPath!), + fit: BoxFit.cover, + cacheWidth: (coverSize * 2).toInt(), + errorBuilder: (context, error, stackTrace) => + Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant), + ), + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant), + ), + ), + ), + ), + ), + ), + ], + ), + stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], + ); + }, + ), + leading: IconButton( + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), + child: Icon(Icons.arrow_back, color: colorScheme.onSurface), + ), + onPressed: () => Navigator.pop(context), + ), + ); + } + + Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List tracks) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Card( + elevation: 0, + color: colorScheme.surfaceContainerLow, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.albumName, + style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface), + ), + const SizedBox(height: 4), + Text( + widget.artistName, + style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 12), + Row( + children: [ + // "Local" badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.folder, size: 14, color: colorScheme.onTertiaryContainer), + const SizedBox(width: 4), + Text('Local', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), + ], + ), + ), + const SizedBox(width: 8), + // Track count + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.music_note, size: 14, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12)), + ], + ), + ), + const SizedBox(width: 8), + // Quality badge if all tracks have the same quality + if (_getCommonQuality(tracks) != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _getCommonQuality(tracks)!.contains('24') + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _getCommonQuality(tracks)!, + style: TextStyle( + color: _getCommonQuality(tracks)!.contains('24') + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + String? _getCommonQuality(List tracks) { + if (tracks.isEmpty) return null; + final first = tracks.first; + if (first.bitDepth == null || first.sampleRate == null) return null; + + final firstQuality = '${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz'; + for (final track in tracks) { + if (track.bitDepth != first.bitDepth || track.sampleRate != first.sampleRate) { + return null; + } + } + return firstQuality; + } + + Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List tracks) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Row( + children: [ + Icon(Icons.queue_music, size: 20, color: colorScheme.primary), + const SizedBox(width: 8), + Text(context.l10n.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)), + const Spacer(), + if (!_isSelectionMode) + TextButton.icon( + onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null, + icon: const Icon(Icons.checklist, size: 18), + label: Text(context.l10n.actionSelect), + style: TextButton.styleFrom(visualDensity: VisualDensity.compact), + ), + ], + ), + ), + ); + } + + Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List tracks) { + final discGroups = _groupTracksByDisc(tracks); + final hasMultipleDiscs = discGroups.length > 1; + + final slivers = []; + + final sortedDiscNumbers = discGroups.keys.toList()..sort(); + for (final discNumber in sortedDiscNumbers) { + final discTracks = discGroups[discNumber]!; + + if (hasMultipleDiscs) { + slivers.add( + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer), + const SizedBox(width: 6), + Text( + context.l10n.downloadedAlbumDiscHeader(discNumber), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Container( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), + ], + ), + ), + ), + ); + } + + slivers.add( + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _buildTrackItem(context, colorScheme, discTracks[index]), + childCount: discTracks.length, + ), + ), + ); + } + + return SliverMainAxisGroup(slivers: slivers); + } + + Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, LocalLibraryItem track) { + final isSelected = _selectedIds.contains(track.id); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Card( + elevation: 0, + color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent, + margin: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + onTap: _isSelectionMode + ? () => _toggleSelection(track.id) + : () => _openFile(track.filePath), + onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id), + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isSelectionMode) ...[ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: isSelected ? colorScheme.primary : Colors.transparent, + shape: BoxShape.circle, + border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2), + ), + child: isSelected + ? Icon(Icons.check, color: colorScheme.onPrimary, size: 16) + : null, + ), + const SizedBox(width: 12), + ], + SizedBox( + width: 24, + child: Text( + track.trackNumber?.toString() ?? '-', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + title: Text( + track.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + ), + subtitle: Row( + children: [ + Flexible( + child: Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ), + if (track.format != null) ...[ + Text(' • ', style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12)), + Text( + track.format!.toUpperCase(), + style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12), + ), + ], + ], + ), + trailing: _isSelectionMode ? null : IconButton( + onPressed: () => _openFile(track.filePath), + icon: Icon(Icons.play_arrow, color: colorScheme.primary), + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), + ), + ), + ), + ), + ); + } + + Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List tracks, double bottomPadding) { + final selectedCount = _selectedIds.length; + final allSelected = selectedCount == tracks.length && tracks.isNotEmpty; + + return Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 12, + offset: const Offset(0, -4), + ), + ], + ), + child: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + Row( + children: [ + IconButton.filledTonal( + onPressed: _exitSelectionMode, + icon: const Icon(Icons.close), + style: IconButton.styleFrom( + backgroundColor: colorScheme.surfaceContainerHighest, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.downloadedAlbumSelectedCount(selectedCount), + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + TextButton.icon( + onPressed: () { + if (allSelected) { + _exitSelectionMode(); + } else { + _selectAll(tracks); + } + }, + icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20), + label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll), + style: TextButton.styleFrom(foregroundColor: colorScheme.primary), + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null, + icon: const Icon(Icons.delete_outline), + label: Text( + selectedCount > 0 + ? context.l10n.downloadedAlbumDeleteCount(selectedCount) + : context.l10n.downloadedAlbumSelectToDelete, + ), + style: FilledButton.styleFrom( + backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest, + foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index afb29a2d..da01d7b8 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -16,6 +16,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; +import 'package:spotiflac_android/screens/local_album_screen.dart'; /// Represents the source of a library item enum LibraryItemSource { downloaded, local } @@ -27,6 +28,7 @@ class UnifiedLibraryItem { final String artistName; final String albumName; final String? coverUrl; + final String? localCoverPath; // For local library items with extracted cover final String filePath; final String? quality; final DateTime addedAt; @@ -42,6 +44,7 @@ class UnifiedLibraryItem { required this.artistName, required this.albumName, this.coverUrl, + this.localCoverPath, required this.filePath, this.quality, required this.addedAt, @@ -76,6 +79,7 @@ class UnifiedLibraryItem { artistName: item.artistName, albumName: item.albumName, coverUrl: null, // Local library doesn't have cover URLs + localCoverPath: item.coverPath, // Use extracted cover path filePath: item.filePath, quality: quality, addedAt: item.scannedAt, @@ -84,6 +88,9 @@ class UnifiedLibraryItem { ); } + /// Returns true if this item has a cover (either URL or local path) + bool get hasCover => coverUrl != null || (localCoverPath != null && localCoverPath!.isNotEmpty); + String get searchKey => '${trackName.toLowerCase()}|${artistName.toLowerCase()}|${albumName.toLowerCase()}'; String get albumKey => '$albumName|$artistName'; } @@ -107,18 +114,53 @@ class _GroupedAlbum { String get key => '$albumName|$artistName'; } +/// Grouped album from local library +class _GroupedLocalAlbum { + final String albumName; + final String artistName; + final String? coverPath; // Local cover file path + final List tracks; + final DateTime latestScanned; + final String searchKey; + + _GroupedLocalAlbum({ + required this.albumName, + required this.artistName, + this.coverPath, + required this.tracks, + required this.latestScanned, + }) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; + + String get key => '$albumName|$artistName'; +} + class _HistoryStats { final Map albumCounts; + final Map localAlbumCounts; // For identifying local singles final List<_GroupedAlbum> groupedAlbums; + final List<_GroupedLocalAlbum> groupedLocalAlbums; // Local library albums final int albumCount; final int singleTracks; + // Local library stats + final int localAlbumCount; + final int localSingleTracks; const _HistoryStats({ required this.albumCounts, + this.localAlbumCounts = const {}, required this.groupedAlbums, + this.groupedLocalAlbums = const [], required this.albumCount, required this.singleTracks, + this.localAlbumCount = 0, + this.localSingleTracks = 0, }); + + /// Total album count including local library + int get totalAlbumCount => albumCount + localAlbumCount; + + /// Total singles count including local library + int get totalSingleTracks => singleTracks + localSingleTracks; } Map> _filterHistoryInIsolate( @@ -191,6 +233,7 @@ class _QueueTabState extends ConsumerState { String _searchQuery = ''; Timer? _searchDebounce; List? _historyItemsCache; + List? _localLibraryItemsCache; _HistoryStats? _historyStatsCache; final Map _searchIndexCache = {}; Map _historyItemsById = {}; @@ -244,10 +287,15 @@ class _QueueTabState extends ConsumerState { _requestFilterRefresh(); } - void _ensureHistoryCaches(List items) { - if (identical(items, _historyItemsCache)) return; + void _ensureHistoryCaches(List items, List localItems) { + final historyChanged = !identical(items, _historyItemsCache); + final localChanged = !identical(localItems, _localLibraryItemsCache); + + if (!historyChanged && !localChanged) return; + _historyItemsCache = items; - _historyStatsCache = _buildHistoryStats(items); + _localLibraryItemsCache = localItems; + _historyStatsCache = _buildHistoryStats(items, localItems); _searchIndexCache ..clear() ..addEntries( @@ -433,7 +481,7 @@ final albumKey = } /// Select all visible items - void _selectAll(List items) { + void _selectAll(List items) { setState(() { _selectedIds.addAll(items.map((e) => e.id)); }); @@ -454,7 +502,7 @@ final albumKey = return quality.split(' ').first; } - Future _deleteSelected() async { + Future _deleteSelected(List allItems) async { final count = _selectedIds.length; final confirmed = await showDialog( context: context, @@ -479,11 +527,11 @@ final albumKey = if (confirmed == true && mounted) { final historyNotifier = ref.read(downloadHistoryProvider.notifier); - final items = ref.read(downloadHistoryProvider).items; + final localLibraryDb = LibraryDatabase.instance; int deletedCount = 0; for (final id in _selectedIds) { - final item = items.where((e) => e.id == id).firstOrNull; + final item = allItems.where((e) => e.id == id).firstOrNull; if (item != null) { try { final cleanPath = _cleanFilePath(item.filePath); @@ -492,11 +540,23 @@ final albumKey = await file.delete(); } } catch (_) {} - historyNotifier.removeFromHistory(id); + + // Remove from appropriate database + if (item.source == LibraryItemSource.downloaded) { + historyNotifier.removeFromHistory(item.historyItem!.id); + } else { + // Remove from local library database + await localLibraryDb.deleteByPath(item.filePath); + } deletedCount++; } } + // Reload local library if we deleted any local items + if (allItems.any((i) => _selectedIds.contains(i.id) && i.source == LibraryItemSource.local)) { + ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + _exitSelectionMode(); if (mounted) { @@ -657,7 +717,7 @@ switch (filterMode) { } } -_HistoryStats _buildHistoryStats(List items) { +_HistoryStats _buildHistoryStats(List items, [List localItems = const []]) { final albumCounts = {}; final albumMap = >{}; for (final item in items) { @@ -702,11 +762,57 @@ _HistoryStats _buildHistoryStats(List items) { if (count > 1) albumCount++; } + // Calculate local library stats + final localAlbumCounts = {}; + final localAlbumMap = >{}; + for (final item in localItems) { + final key = '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; + localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1; + localAlbumMap.putIfAbsent(key, () => []).add(item); + } + + int localAlbumCount = 0; + int localSingleTracks = 0; + for (final count in localAlbumCounts.values) { + if (count > 1) { + localAlbumCount++; + } else { + localSingleTracks++; + } + } + + // Build grouped local albums + final groupedLocalAlbums = <_GroupedLocalAlbum>[]; + localAlbumMap.forEach((_, tracks) { + if (tracks.length <= 1) return; + tracks.sort((a, b) { + final aNum = a.trackNumber ?? 999; + final bNum = b.trackNumber ?? 999; + return aNum.compareTo(bNum); + }); + + groupedLocalAlbums.add(_GroupedLocalAlbum( + albumName: tracks.first.albumName, + artistName: tracks.first.albumArtist ?? tracks.first.artistName, + coverPath: tracks.firstWhere((t) => t.coverPath != null && t.coverPath!.isNotEmpty, orElse: () => tracks.first).coverPath, + tracks: tracks, + latestScanned: tracks + .map((t) => t.scannedAt) + .reduce((a, b) => a.isAfter(b) ? a : b), + )); + }); + + groupedLocalAlbums.sort((a, b) => b.latestScanned.compareTo(a.latestScanned)); + return _HistoryStats( albumCounts: albumCounts, + localAlbumCounts: localAlbumCounts, groupedAlbums: groupedAlbums, + groupedLocalAlbums: groupedLocalAlbums, albumCount: albumCount, singleTracks: singleTracks, + localAlbumCount: localAlbumCount, + localSingleTracks: localSingleTracks, ); } @@ -729,6 +835,26 @@ void _navigateToDownloadedAlbum(_GroupedAlbum album) { ).then((_) => _searchFocusNode.unfocus()); } + void _navigateToLocalAlbum(_GroupedLocalAlbum album) { + _searchFocusNode.unfocus(); + Navigator.push( + context, + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => + LocalAlbumScreen( + albumName: album.albumName, + artistName: album.artistName, + coverPath: album.coverPath, + tracks: album.tracks, + ), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), + ), + ).then((_) => _searchFocusNode.unfocus()); + } + @override Widget build(BuildContext context) { _initializePageController(); @@ -743,7 +869,7 @@ final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items)); ? ref.watch(localLibraryProvider.select((s) => s.items)) : []; - _ensureHistoryCaches(allHistoryItems); + _ensureHistoryCaches(allHistoryItems, localLibraryItems); final historyViewMode = ref.watch( settingsProvider.select((s) => s.historyViewMode), ); @@ -754,10 +880,11 @@ final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items)); final topPadding = MediaQuery.of(context).padding.top; final historyStats = - _historyStatsCache ?? _buildHistoryStats(allHistoryItems); + _historyStatsCache ?? _buildHistoryStats(allHistoryItems, localLibraryItems); final groupedAlbums = historyStats.groupedAlbums; - final albumCount = historyStats.albumCount; - final singleCount = historyStats.singleTracks; + final groupedLocalAlbums = historyStats.groupedLocalAlbums; + final albumCount = historyStats.totalAlbumCount; + final singleCount = historyStats.totalSingleTracks; final bottomPadding = MediaQuery.of(context).padding.bottom; @@ -907,7 +1034,7 @@ const Spacer(), }, childCount: queueItems.length), ), - if (allHistoryItems.isNotEmpty) + if (allHistoryItems.isNotEmpty || localLibraryItems.isNotEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), @@ -917,7 +1044,7 @@ const Spacer(), children: [ _FilterChip( label: context.l10n.historyFilterAll, - count: allHistoryItems.length, + count: allHistoryItems.length + localLibraryItems.length, isSelected: historyFilterMode == 'all', onTap: () { _animateToFilterPage(0); @@ -1023,7 +1150,9 @@ const Spacer(), historyViewMode: historyViewMode, queueItems: queueItems, groupedAlbums: groupedAlbums, + groupedLocalAlbums: groupedLocalAlbums, albumCounts: historyStats.albumCounts, + localAlbumCounts: historyStats.localAlbumCounts, localLibraryItems: localLibraryItems, ); }, @@ -1041,10 +1170,11 @@ const Spacer(), child: _buildSelectionBottomBar( context, colorScheme, - _resolveHistoryItems( + _buildUnifiedItemsForSelection( filterMode: historyFilterMode, allHistoryItems: allHistoryItems, albumCounts: historyStats.albumCounts, + localLibraryItems: localLibraryItems, ), bottomPadding, ), @@ -1054,6 +1184,42 @@ child: _buildSelectionBottomBar( ); } + /// Build unified items list for selection mode + List _buildUnifiedItemsForSelection({ + required String filterMode, + required List allHistoryItems, + required Map albumCounts, + required List localLibraryItems, + }) { + final historyItems = _resolveHistoryItems( + filterMode: filterMode, + allHistoryItems: allHistoryItems, + albumCounts: albumCounts, + ); + + // Convert download history to unified items + final unifiedDownloaded = historyItems.map((item) => + UnifiedLibraryItem.fromDownloadHistory(item)).toList(); + + // For 'all' filter, include local library items + if (filterMode == 'all') { + final searchQuery = _searchQuery; + final filteredLocalItems = searchQuery.isEmpty + ? localLibraryItems + : localLibraryItems.where((item) { + final searchKey = '${item.trackName} ${item.artistName} ${item.albumName}'.toLowerCase(); + return searchKey.contains(searchQuery); + }).toList(); + final unifiedLocal = filteredLocalItems.map((item) => + UnifiedLibraryItem.fromLocalLibrary(item)).toList(); + + return [...unifiedDownloaded, ...unifiedLocal] + ..sort((a, b) => b.addedAt.compareTo(a.addedAt)); + } + + return unifiedDownloaded; + } + Widget _buildFilterContent({ required BuildContext context, required ColorScheme colorScheme, @@ -1062,10 +1228,12 @@ child: _buildSelectionBottomBar( required String historyViewMode, required List queueItems, required List<_GroupedAlbum> groupedAlbums, + required List<_GroupedLocalAlbum> groupedLocalAlbums, required Map albumCounts, + required Map localAlbumCounts, required List localLibraryItems, }) { -final historyItems = _resolveHistoryItems( + final historyItems = _resolveHistoryItems( filterMode: filterMode, allHistoryItems: allHistoryItems, albumCounts: albumCounts, @@ -1083,9 +1251,72 @@ final historyItems = _resolveHistoryItems( .where((album) => album.searchKey.contains(searchQuery)) .toList(); + // Filter local library albums based on search query + final filteredGroupedLocalAlbums = searchQuery.isEmpty + ? groupedLocalAlbums + : groupedLocalAlbums + .where((album) => album.searchKey.contains(searchQuery)) + .toList(); + + // Total album count for display + final totalAlbumCount = filteredGroupedAlbums.length + filteredGroupedLocalAlbums.length; + + // Create unified library items (merge downloaded + local) for 'all' filter + List unifiedItems = []; + if (filterMode == 'all') { + // Convert download history to unified items + final unifiedDownloaded = historyItems.map((item) => + UnifiedLibraryItem.fromDownloadHistory(item)).toList(); + + // Convert local library to unified items (filter by search query) + final filteredLocalItems = searchQuery.isEmpty + ? localLibraryItems + : localLibraryItems.where((item) { + final searchKey = '${item.trackName} ${item.artistName} ${item.albumName}'.toLowerCase(); + return searchKey.contains(searchQuery); + }).toList(); + final unifiedLocal = filteredLocalItems.map((item) => + UnifiedLibraryItem.fromLocalLibrary(item)).toList(); + + // Merge and sort by date (newest first) + unifiedItems = [...unifiedDownloaded, ...unifiedLocal] + ..sort((a, b) => b.addedAt.compareTo(a.addedAt)); + } else if (filterMode == 'singles') { + // For singles filter, include both downloaded singles and local library singles + final unifiedDownloaded = historyItems.map((item) => + UnifiedLibraryItem.fromDownloadHistory(item)).toList(); + + // Filter local library items to only include singles (albums with count == 1) + final filteredLocalSingles = localLibraryItems.where((item) { + final key = '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; + return (localAlbumCounts[key] ?? 0) == 1; + }).toList(); + + // Apply search filter to local singles + final searchFilteredLocalSingles = searchQuery.isEmpty + ? filteredLocalSingles + : filteredLocalSingles.where((item) { + final searchKey = '${item.trackName} ${item.artistName} ${item.albumName}'.toLowerCase(); + return searchKey.contains(searchQuery); + }).toList(); + + final unifiedLocalSingles = searchFilteredLocalSingles.map((item) => + UnifiedLibraryItem.fromLocalLibrary(item)).toList(); + + // Merge and sort by date (newest first) + unifiedItems = [...unifiedDownloaded, ...unifiedLocalSingles] + ..sort((a, b) => b.addedAt.compareTo(a.addedAt)); + } else { + // For albums filter, no unified items needed (we use album grid instead) + unifiedItems = []; + } + + // Total count for display + final totalTrackCount = unifiedItems.length; + return CustomScrollView( slivers: [ - if (historyItems.isNotEmpty && + if (totalTrackCount > 0 && queueItems.isEmpty && filterMode != 'albums') SliverToBoxAdapter( @@ -1094,16 +1325,14 @@ final historyItems = _resolveHistoryItems( child: Row( children: [ Text( - '${historyItems.length} ${historyItems.length == 1 ? 'track' : 'tracks'}', + '$totalTrackCount ${totalTrackCount == 1 ? 'track' : 'tracks'}', style: Theme.of(context).textTheme.bodyMedium ?.copyWith(color: colorScheme.onSurfaceVariant), ), const Spacer(), - if (!_isSelectionMode) + if (!_isSelectionMode && unifiedItems.isNotEmpty) TextButton.icon( - onPressed: historyItems.isNotEmpty - ? () => _enterSelectionMode(historyItems.first.id) - : null, + onPressed: () => _enterSelectionMode(unifiedItems.first.id), icon: const Icon(Icons.checklist, size: 18), label: Text(context.l10n.actionSelect), style: TextButton.styleFrom( @@ -1115,14 +1344,14 @@ final historyItems = _resolveHistoryItems( ), ), -if (filteredGroupedAlbums.isNotEmpty && + if ((filteredGroupedAlbums.isNotEmpty || filteredGroupedLocalAlbums.isNotEmpty) && queueItems.isEmpty && filterMode == 'albums') SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Text( - '${filteredGroupedAlbums.length} ${filteredGroupedAlbums.length == 1 ? 'album' : 'albums'}', + '$totalAlbumCount ${totalAlbumCount == 1 ? 'album' : 'albums'}', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -1169,7 +1398,8 @@ if (filteredGroupedAlbums.isNotEmpty && ), ), -if (filterMode == 'albums' && filteredGroupedAlbums.isNotEmpty) + // Combined albums grid (downloaded + local in single grid) + if (filterMode == 'albums' && (filteredGroupedAlbums.isNotEmpty || filteredGroupedLocalAlbums.isNotEmpty)) SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), sliver: SliverGrid( @@ -1181,16 +1411,27 @@ if (filterMode == 'albums' && filteredGroupedAlbums.isNotEmpty) childAspectRatio: 0.75, ), delegate: SliverChildBuilderDelegate((context, index) { - final album = filteredGroupedAlbums[index]; - return KeyedSubtree( - key: ValueKey(album.key), - child: _buildAlbumGridItem(context, album, colorScheme), - ); - }, childCount: filteredGroupedAlbums.length), + // First render downloaded albums, then local albums + if (index < filteredGroupedAlbums.length) { + final album = filteredGroupedAlbums[index]; + return KeyedSubtree( + key: ValueKey(album.key), + child: _buildAlbumGridItem(context, album, colorScheme), + ); + } else { + final localIndex = index - filteredGroupedAlbums.length; + final album = filteredGroupedLocalAlbums[localIndex]; + return KeyedSubtree( + key: ValueKey('local_${album.key}'), + child: _buildLocalAlbumGridItem(context, album, colorScheme), + ); + } + }, childCount: filteredGroupedAlbums.length + filteredGroupedLocalAlbums.length), ), ), - if (historyItems.isNotEmpty && filterMode != 'albums') + // Unified list for 'all' filter (merged downloaded + local) + if (unifiedItems.isNotEmpty && filterMode == 'all') historyViewMode == 'grid' ? SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -1206,83 +1447,77 @@ if (filterMode == 'albums' && filteredGroupedAlbums.isNotEmpty) context, index, ) { - final item = historyItems[index]; + final item = unifiedItems[index]; return KeyedSubtree( key: ValueKey(item.id), - child: _buildHistoryGridItem( + child: _buildUnifiedGridItem( context, item, colorScheme, ), ); - }, childCount: historyItems.length), + }, childCount: unifiedItems.length), ), ) : SliverList( delegate: SliverChildBuilderDelegate((context, index) { - final item = historyItems[index]; + final item = unifiedItems[index]; return KeyedSubtree( key: ValueKey(item.id), - child: _buildHistoryItem( + child: _buildUnifiedLibraryItem( context, item, colorScheme, ), ); - }, childCount: historyItems.length ), - ), - - // Local Library Section - if (localLibraryItems.isNotEmpty && filterMode == 'all') - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( - children: [ - Text( - context.l10n.libraryFilterLocal, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '${localLibraryItems.length}', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSecondaryContainer, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ), - ), - - if (localLibraryItems.isNotEmpty && filterMode == 'all') - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final item = localLibraryItems[index]; - return KeyedSubtree( - key: ValueKey('local_${item.id}'), - child: _buildLocalLibraryItem( - context, - item, - colorScheme, + }, childCount: unifiedItems.length), ), - ); - }, childCount: localLibraryItems.length), - ), -if (queueItems.isEmpty && - historyItems.isEmpty && - localLibraryItems.isEmpty && + // Singles filter - show unified items (downloaded + local singles) + if (unifiedItems.isNotEmpty && filterMode == 'singles') + historyViewMode == 'grid' + ? SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverGrid( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 0.75, + ), + delegate: SliverChildBuilderDelegate(( + context, + index, + ) { + final item = unifiedItems[index]; + return KeyedSubtree( + key: ValueKey(item.id), + child: _buildUnifiedGridItem( + context, + item, + colorScheme, + ), + ); + }, childCount: unifiedItems.length), + ), + ) + : SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final item = unifiedItems[index]; + return KeyedSubtree( + key: ValueKey(item.id), + child: _buildUnifiedLibraryItem( + context, + item, + colorScheme, + ), + ); + }, childCount: unifiedItems.length), + ), + + if (queueItems.isEmpty && + totalTrackCount == 0 && (filterMode != 'albums' || filteredGroupedAlbums.isEmpty) && !showFilteringIndicator) SliverFillRemaining( @@ -1571,16 +1806,122 @@ Widget _buildClearAllButton( ); } + /// Album grid item for local library albums + Widget _buildLocalAlbumGridItem( + BuildContext context, + _GroupedLocalAlbum album, + ColorScheme colorScheme, + ) { + return GestureDetector( + onTap: () => _navigateToLocalAlbum(album), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: album.coverPath != null + ? Image.file( + File(album.coverPath!), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + cacheWidth: 300, + cacheHeight: 300, + errorBuilder: (context, error, stackTrace) => + Container( + color: colorScheme.surfaceContainerHighest, + child: Center( + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + size: 48, + ), + ), + ), + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Center( + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + size: 48, + ), + ), + ), + ), + // "Local" badge instead of track count + Positioned( + right: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder, + size: 12, + color: colorScheme.onTertiaryContainer, + ), + const SizedBox(width: 4), + Text( + '${album.tracks.length}', + style: TextStyle( + color: colorScheme.onTertiaryContainer, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Text( + album.albumName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + album.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + /// Bottom action bar for selection mode (Material Design 3 style) Widget _buildSelectionBottomBar( BuildContext context, ColorScheme colorScheme, - List historyItems, + List unifiedItems, double bottomPadding, ) { final selectedCount = _selectedIds.length; final allSelected = - selectedCount == historyItems.length && historyItems.isNotEmpty; + selectedCount == unifiedItems.length && unifiedItems.isNotEmpty; return Container( decoration: BoxDecoration( @@ -1647,7 +1988,7 @@ Widget _buildClearAllButton( if (allSelected) { _exitSelectionMode(); } else { - _selectAll(historyItems); + _selectAll(unifiedItems); } }, icon: Icon( @@ -1667,7 +2008,7 @@ Widget _buildClearAllButton( SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: selectedCount > 0 ? _deleteSelected : null, + onPressed: selectedCount > 0 ? () => _deleteSelected(unifiedItems) : null, icon: const Icon(Icons.delete_outline), label: Text( selectedCount > 0 @@ -1931,19 +2272,386 @@ child: CachedNetworkImage( } } - Widget _buildHistoryGridItem( + /// Build cover image widget for unified library item + /// Supports network URLs (from downloads) and local file paths (from library scan) + Widget _buildUnifiedCoverImage( + UnifiedLibraryItem item, + ColorScheme colorScheme, + double size, + ) { + final isDownloaded = item.source == LibraryItemSource.downloaded; + + // Network URL cover (downloaded items) + if (item.coverUrl != null) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: item.coverUrl!, + width: size, + height: size, + fit: BoxFit.cover, + memCacheWidth: (size * 2).toInt(), + memCacheHeight: (size * 2).toInt(), + cacheManager: CoverCacheManager.instance, + placeholder: (context, url) => Container( + width: size, + height: size, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + errorWidget: (context, url, error) => Container( + width: size, + height: size, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + ), + ); + } + + // Local file cover (from library scan) + if (item.localCoverPath != null && item.localCoverPath!.isNotEmpty) { + final coverFile = File(item.localCoverPath!); + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: FutureBuilder( + future: coverFile.exists(), + builder: (context, snapshot) { + if (snapshot.data == true) { + return Image.file( + coverFile, + width: size, + height: size, + fit: BoxFit.cover, + cacheWidth: (size * 2).toInt(), + cacheHeight: (size * 2).toInt(), + errorBuilder: (context, error, stackTrace) => _buildPlaceholderCover( + colorScheme, size, isDownloaded, + ), + ); + } + return _buildPlaceholderCover(colorScheme, size, isDownloaded); + }, + ), + ); + } + + // Placeholder (no cover) + return _buildPlaceholderCover(colorScheme, size, isDownloaded); + } + + /// Build placeholder cover image + Widget _buildPlaceholderCover(ColorScheme colorScheme, double size, bool isDownloaded) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: isDownloaded + ? colorScheme.surfaceContainerHighest + : colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + isDownloaded ? Icons.music_note : Icons.music_note, + color: isDownloaded + ? colorScheme.onSurfaceVariant + : colorScheme.onSecondaryContainer, + size: size * 0.4, + ), + ); + } + + /// Build cover image for unified grid item (fills container) + Widget _buildUnifiedGridCoverImage( + UnifiedLibraryItem item, + ColorScheme colorScheme, + ) { + final isDownloaded = item.source == LibraryItemSource.downloaded; + + // Network URL cover (downloaded items) + if (item.coverUrl != null) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: item.coverUrl!, + fit: BoxFit.cover, + memCacheWidth: 200, + memCacheHeight: 200, + cacheManager: CoverCacheManager.instance, + placeholder: (context, url) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 32), + ), + errorWidget: (context, url, error) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 32), + ), + ), + ); + } + + // Local file cover (from library scan) + if (item.localCoverPath != null && item.localCoverPath!.isNotEmpty) { + final coverFile = File(item.localCoverPath!); + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: FutureBuilder( + future: coverFile.exists(), + builder: (context, snapshot) { + if (snapshot.data == true) { + return Image.file( + coverFile, + fit: BoxFit.cover, + cacheWidth: 200, + cacheHeight: 200, + errorBuilder: (context, error, stackTrace) => Container( + color: colorScheme.secondaryContainer, + child: Icon(Icons.music_note, color: colorScheme.onSecondaryContainer, size: 32), + ), + ); + } + return Container( + color: colorScheme.secondaryContainer, + child: Icon(Icons.music_note, color: colorScheme.onSecondaryContainer, size: 32), + ); + }, + ), + ); + } + + // Placeholder (no cover) + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + color: isDownloaded + ? colorScheme.surfaceContainerHighest + : colorScheme.secondaryContainer, + child: Icon( + Icons.music_note, + color: isDownloaded + ? colorScheme.onSurfaceVariant + : colorScheme.onSecondaryContainer, + size: 32, + ), + ), + ); + } + + /// Build a unified library item (merged downloaded + local) + Widget _buildUnifiedLibraryItem( BuildContext context, - DownloadHistoryItem item, + UnifiedLibraryItem item, ColorScheme colorScheme, ) { final fileExists = _checkFileExists(item.filePath); final isSelected = _selectedIds.contains(item.id); + final date = item.addedAt; + final months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + final dateStr = + '${months[date.month - 1]} ${date.day}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + + final isDownloaded = item.source == LibraryItemSource.downloaded; + final sourceLabel = isDownloaded + ? context.l10n.librarySourceDownloaded + : context.l10n.librarySourceLocal; + final sourceColor = isDownloaded + ? colorScheme.primaryContainer + : colorScheme.secondaryContainer; + final sourceTextColor = isDownloaded + ? colorScheme.onPrimaryContainer + : colorScheme.onSecondaryContainer; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + color: isSelected + ? colorScheme.primaryContainer.withValues(alpha: 0.3) + : null, + child: InkWell( + onTap: _isSelectionMode + ? () => _toggleSelection(item.id) + : isDownloaded + ? () => _navigateToHistoryMetadataScreen(item.historyItem!) + : () => _openFile(item.filePath), + onLongPress: _isSelectionMode + ? null + : () => _enterSelectionMode(item.id), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + if (_isSelectionMode) ...[ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), + ), + child: isSelected + ? Icon( + Icons.check, + color: colorScheme.onPrimary, + size: 16, + ) + : null, + ), + const SizedBox(width: 12), + ], + // Cover image - supports network URL and local file path + _buildUnifiedCoverImage(item, colorScheme, 56), + const SizedBox(width: 12), + + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + item.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + // Source badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: sourceColor, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + sourceLabel, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: sourceTextColor, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + Text( + dateStr, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.7, + ), + ), + ), + if (item.quality != null && + item.quality!.isNotEmpty) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: item.quality!.startsWith('24') + ? colorScheme.tertiaryContainer + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + item.quality!, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: item.quality!.startsWith('24') + ? colorScheme.onTertiaryContainer + : colorScheme.onSurfaceVariant, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), + ], + ), + ), + const SizedBox(width: 8), + + if (!_isSelectionMode) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (fileExists) + IconButton( + onPressed: () => _openFile(item.filePath), + icon: Icon( + Icons.play_arrow, + color: colorScheme.primary, + ), + tooltip: context.l10n.tooltipPlay, + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer + .withValues(alpha: 0.3), + ), + ) + else + Icon( + Icons.error_outline, + color: colorScheme.error, + size: 20, + ), + ], + ), + ], + ), + ), + ), + ); + } + + /// Build unified grid item for grid view mode + Widget _buildUnifiedGridItem( + BuildContext context, + UnifiedLibraryItem item, + ColorScheme colorScheme, + ) { + final fileExists = _checkFileExists(item.filePath); + final isSelected = _selectedIds.contains(item.id); + final isDownloaded = item.source == LibraryItemSource.downloaded; return GestureDetector( onTap: _isSelectionMode ? () => _toggleSelection(item.id) - : () => _navigateToHistoryMetadataScreen(item), - onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id), + : isDownloaded + ? () => _navigateToHistoryMetadataScreen(item.historyItem!) + : () => _openFile(item.filePath), + onLongPress: _isSelectionMode + ? null + : () => _enterSelectionMode(item.id), child: Stack( children: [ Column( @@ -1953,26 +2661,33 @@ child: CachedNetworkImage( children: [ AspectRatio( aspectRatio: 1, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: item.coverUrl != null -? CachedNetworkImage( - imageUrl: item.coverUrl!, - fit: BoxFit.cover, - memCacheWidth: 200, - memCacheHeight: 200, - cacheManager: CoverCacheManager.instance, - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - size: 32, - ), - ), + child: _buildUnifiedGridCoverImage(item, colorScheme), + ), + // Source badge (top-right) + Positioned( + right: 4, + top: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + decoration: BoxDecoration( + color: isDownloaded + ? colorScheme.primaryContainer + : colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Icon( + isDownloaded ? Icons.download_done : Icons.folder, + size: 12, + color: isDownloaded + ? colorScheme.onPrimaryContainer + : colorScheme.onSecondaryContainer, + ), ), ), + // Quality badge (top-left) if (item.quality != null && item.quality!.isNotEmpty) Positioned( left: 4, @@ -2095,355 +2810,6 @@ child: CachedNetworkImage( ); } - Widget _buildHistoryItem( - BuildContext context, - DownloadHistoryItem item, - ColorScheme colorScheme, - ) { - final fileExists = _checkFileExists(item.filePath); - final isSelected = _selectedIds.contains(item.id); - final date = item.downloadedAt; - final months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - final dateStr = - '${months[date.month - 1]} ${date.day}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; - - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - color: isSelected - ? colorScheme.primaryContainer.withValues(alpha: 0.3) - : null, - child: InkWell( - onTap: _isSelectionMode - ? () => _toggleSelection(item.id) - : () => _navigateToHistoryMetadataScreen(item), - onLongPress: _isSelectionMode - ? null - : () => _enterSelectionMode(item.id), - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - if (_isSelectionMode) ...[ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 16, - ) - : null, - ), - const SizedBox(width: 12), - ], - item.coverUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(8), -child: CachedNetworkImage( - imageUrl: item.coverUrl!, - width: 56, - height: 56, - fit: BoxFit.cover, - memCacheWidth: 112, - memCacheHeight: 112, - cacheManager: CoverCacheManager.instance, - ), - ) - : Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 12), - - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - item.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 2), - Row( - children: [ - // Source badge - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - context.l10n.librarySourceDownloaded, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: colorScheme.onPrimaryContainer, - fontSize: 10, - fontWeight: FontWeight.w500, - ), - ), - ), - const SizedBox(width: 8), - Text( - dateStr, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.7, - ), - ), - ), - if (item.quality != null && - item.quality!.isNotEmpty) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: item.quality!.startsWith('24') - ? colorScheme.tertiaryContainer - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - item.quality!, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: item.quality!.startsWith('24') - ? colorScheme.onTertiaryContainer - : colorScheme.onSurfaceVariant, - fontSize: 10, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ], - ), - ], - ), - ), - const SizedBox(width: 8), - - if (!_isSelectionMode) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (fileExists) - IconButton( - onPressed: () => _openFile(item.filePath), - icon: Icon( - Icons.play_arrow, - color: colorScheme.primary, - ), - tooltip: 'Play', - style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer - .withValues(alpha: 0.3), - ), - ) - else - Icon( - Icons.error_outline, - color: colorScheme.error, - size: 20, - ), - ], - ), - ], - ), - ), - ), - ); - } - - Widget _buildLocalLibraryItem( - BuildContext context, - LocalLibraryItem item, - ColorScheme colorScheme, - ) { - final fileExists = _checkFileExists(item.filePath); - - // Format quality info - String? qualityStr; - if (item.bitDepth != null && item.sampleRate != null) { - qualityStr = '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; - } - - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: InkWell( - onTap: () => _openFile(item.filePath), - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - // Placeholder for cover (local library doesn't have cover URLs) - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.folder_outlined, - color: colorScheme.onSecondaryContainer, - ), - ), - const SizedBox(width: 12), - - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - item.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 2), - Row( - children: [ - // Source badge - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - context.l10n.librarySourceLocal, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: colorScheme.onSecondaryContainer, - fontSize: 10, - fontWeight: FontWeight.w500, - ), - ), - ), - if (qualityStr != null) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: qualityStr.startsWith('24') - ? colorScheme.tertiaryContainer - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - qualityStr, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: qualityStr.startsWith('24') - ? colorScheme.onTertiaryContainer - : colorScheme.onSurfaceVariant, - fontSize: 10, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ], - ), - ], - ), - ), - const SizedBox(width: 8), - - if (fileExists) - IconButton( - onPressed: () => _openFile(item.filePath), - icon: Icon( - Icons.play_arrow, - color: colorScheme.primary, - ), - tooltip: context.l10n.tooltipPlay, - ) - else - Icon( - Icons.error_outline, - color: colorScheme.error, - ), - ], - ), - ), - ), - ); - } } class _FilterChip extends StatelessWidget { diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index c4a2c35a..bfae0f3f 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -14,6 +14,7 @@ class LocalLibraryItem { final String albumName; final String? albumArtist; final String filePath; + final String? coverPath; // Path to extracted cover art final DateTime scannedAt; final String? isrc; final int? trackNumber; @@ -32,6 +33,7 @@ class LocalLibraryItem { required this.albumName, this.albumArtist, required this.filePath, + this.coverPath, required this.scannedAt, this.isrc, this.trackNumber, @@ -51,6 +53,7 @@ class LocalLibraryItem { 'albumName': albumName, 'albumArtist': albumArtist, 'filePath': filePath, + 'coverPath': coverPath, 'scannedAt': scannedAt.toIso8601String(), 'isrc': isrc, 'trackNumber': trackNumber, @@ -71,6 +74,7 @@ class LocalLibraryItem { albumName: json['albumName'] as String, albumArtist: json['albumArtist'] as String?, filePath: json['filePath'] as String, + coverPath: json['coverPath'] as String?, scannedAt: DateTime.parse(json['scannedAt'] as String), isrc: json['isrc'] as String?, trackNumber: json['trackNumber'] as int?, @@ -109,7 +113,7 @@ class LibraryDatabase { return await openDatabase( path, - version: 1, + version: 2, // Bumped version for cover_path migration onCreate: _createDB, onUpgrade: _upgradeDB, ); @@ -126,6 +130,7 @@ class LibraryDatabase { album_name TEXT NOT NULL, album_artist TEXT, file_path TEXT NOT NULL UNIQUE, + cover_path TEXT, scanned_at TEXT NOT NULL, isrc TEXT, track_number INTEGER, @@ -150,7 +155,12 @@ class LibraryDatabase { Future _upgradeDB(Database db, int oldVersion, int newVersion) async { _log.i('Upgrading library database from v$oldVersion to v$newVersion'); - // Future migrations go here + + if (oldVersion < 2) { + // Add cover_path column + await db.execute('ALTER TABLE library ADD COLUMN cover_path TEXT'); + _log.i('Added cover_path column'); + } } /// Convert JSON format (camelCase) to DB row (snake_case) @@ -162,6 +172,7 @@ class LibraryDatabase { 'album_name': json['albumName'], 'album_artist': json['albumArtist'], 'file_path': json['filePath'], + 'cover_path': json['coverPath'], 'scanned_at': json['scannedAt'], 'isrc': json['isrc'], 'track_number': json['trackNumber'], @@ -184,6 +195,7 @@ class LibraryDatabase { 'albumName': row['album_name'], 'albumArtist': row['album_artist'], 'filePath': row['file_path'], + 'coverPath': row['cover_path'], 'scannedAt': row['scanned_at'], 'isrc': row['isrc'], 'trackNumber': row['track_number'], @@ -333,6 +345,12 @@ class LibraryDatabase { await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]); } + /// Delete by ID + Future delete(String id) async { + final db = await database; + await db.delete('library', where: 'id = ?', whereArgs: [id]); + } + /// Delete items where file no longer exists Future cleanupMissingFiles() async { final db = await database; diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index b2cab24f..13d84e72 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -145,12 +145,20 @@ class NotificationService { required String artistName, int? completedCount, int? totalCount, + bool alreadyInLibrary = false, }) async { if (!_isInitialized) await initialize(); - final title = completedCount != null && totalCount != null - ? 'Download Complete ($completedCount/$totalCount)' - : 'Download Complete'; + String title; + if (alreadyInLibrary) { + title = completedCount != null && totalCount != null + ? 'Already in Library ($completedCount/$totalCount)' + : 'Already in Library'; + } else { + title = completedCount != null && totalCount != null + ? 'Download Complete ($completedCount/$totalCount)' + : 'Download Complete'; + } const androidDetails = AndroidNotificationDetails( channelId, diff --git a/lib/services/palette_service.dart b/lib/services/palette_service.dart index 045a83a9..358221ad 100644 --- a/lib/services/palette_service.dart +++ b/lib/services/palette_service.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:palette_generator/palette_generator.dart'; @@ -46,6 +47,40 @@ class PaletteService { } } + /// Extract dominant color from a local file path + Future extractDominantColorFromFile(String? filePath) async { + if (filePath == null || filePath.isEmpty) return null; + + final cached = _colorCache[filePath]; + if (cached != null) { + return cached; + } + + try { + final file = File(filePath); + if (!await file.exists()) return null; + + final paletteGenerator = await PaletteGenerator.fromImageProvider( + FileImage(file), + size: const Size(64, 64), + maximumColorCount: 8, + ); + + final color = paletteGenerator.dominantColor?.color ?? + paletteGenerator.vibrantColor?.color ?? + paletteGenerator.mutedColor?.color; + + if (color != null) { + _colorCache[filePath] = color; + } + + return color; + } catch (e) { + debugPrint('PaletteService file error: $e'); + return null; + } + } + /// Clear the color cache void clearCache() { _colorCache.clear(); diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 307efd87..88861adf 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -823,6 +823,14 @@ static Future> downloadWithExtensions({ // ==================== LOCAL LIBRARY SCANNING ==================== + /// Set the directory for caching extracted cover art + static Future setLibraryCoverCacheDir(String cacheDir) async { + _log.i('setLibraryCoverCacheDir: $cacheDir'); + await _channel.invokeMethod('setLibraryCoverCacheDir', { + 'cache_dir': cacheDir, + }); + } + /// Scan a folder for audio files and read their metadata /// Returns a list of track metadata static Future>> scanLibraryFolder(String folderPath) async {