diff --git a/go_backend/exports.go b/go_backend/exports.go index 268184b3..dbae733e 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1,5 +1,3 @@ -// Package gobackend provides exported functions for gomobile binding -// These functions are the bridge between Flutter and Go backend package gobackend import ( @@ -464,8 +462,8 @@ func DownloadTrack(requestJSON string) (string, error) { if youtubeErr == nil { result = DownloadResult{ FilePath: youtubeResult.FilePath, - BitDepth: 0, // Lossy format, no bit depth - SampleRate: 0, // Lossy format + BitDepth: 0, + SampleRate: 0, Title: youtubeResult.Title, Artist: youtubeResult.Artist, Album: youtubeResult.Album, @@ -543,6 +541,11 @@ func DownloadByStrategy(requestJSON string) (string, error) { } if req.UseExtensions { + // Respect strict mode when auto fallback is disabled: + // for built-in providers, route directly to selected service only. + if !req.UseFallback && isBuiltInProvider(serviceNormalized) { + return DownloadTrack(normalizedJSON) + } resp, err := DownloadWithExtensionsJSON(normalizedJSON) if err != nil { return errorResponse(err.Error()) @@ -916,7 +919,6 @@ func SetDownloadDirectory(path string) error { return setDownloadDir(path) } -// AllowDownloadDir adds a directory to the extension file sandbox allowlist. func AllowDownloadDir(path string) { if strings.TrimSpace(path) == "" { return @@ -1524,11 +1526,6 @@ func errorResponse(msg string) (string, error) { return string(jsonBytes), nil } -// ==================== YOUTUBE PROVIDER (LOSSY ONLY) ==================== - -// DownloadFromYouTube downloads a track from YouTube via Cobalt API -// This is a lossy-only provider (Opus/MP3 with configurable bitrate) -// It does NOT participate in the lossless fallback chain func DownloadFromYouTube(requestJSON string) (string, error) { var req DownloadRequest if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { @@ -1575,20 +1572,14 @@ func DownloadFromYouTube(requestJSON string) (string, error) { return string(jsonBytes), nil } -// IsYouTubeURLExport checks if a URL is a YouTube URL (exported for Flutter) func IsYouTubeURLExport(urlStr string) bool { return IsYouTubeURL(urlStr) } -// ExtractYouTubeVideoIDExport extracts video ID from YouTube URL (exported for Flutter) func ExtractYouTubeVideoIDExport(urlStr string) (string, error) { return ExtractYouTubeVideoID(urlStr) } -// ==================== COVER & LYRICS SAVE ==================== - -// DownloadCoverToFile downloads cover art from URL and saves to outputPath. -// If maxQuality is true, upgrades to highest available resolution. func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error { if coverURL == "" { return fmt.Errorf("no cover URL provided") @@ -1607,7 +1598,6 @@ func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) er return nil } -// ExtractCoverToFile extracts embedded cover art from audio file and saves to outputPath. func ExtractCoverToFile(audioPath string, outputPath string) error { lower := strings.ToLower(audioPath) @@ -1636,7 +1626,6 @@ func ExtractCoverToFile(audioPath string, outputPath string) error { return nil } -// FetchAndSaveLyrics fetches lyrics from lrclib and saves as .lrc file. func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int64, outputPath string) error { client := NewLyricsClient() durationSec := float64(durationMs) / 1000.0 @@ -1663,9 +1652,6 @@ func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int6 return nil } -// ==================== LYRICS PROVIDER SETTINGS ==================== - -// SetLyricsProvidersJSON sets the lyrics provider order from a JSON array of provider IDs. func SetLyricsProvidersJSON(providersJSON string) error { var providers []string if err := json.Unmarshal([]byte(providersJSON), &providers); err != nil { @@ -1676,7 +1662,6 @@ func SetLyricsProvidersJSON(providersJSON string) error { return nil } -// GetLyricsProvidersJSON returns the current lyrics provider order as JSON. func GetLyricsProvidersJSON() (string, error) { providers := GetLyricsProviderOrder() jsonBytes, err := json.Marshal(providers) @@ -1686,7 +1671,6 @@ func GetLyricsProvidersJSON() (string, error) { return string(jsonBytes), nil } -// GetAvailableLyricsProvidersJSON returns metadata about all available lyrics providers. func GetAvailableLyricsProvidersJSON() (string, error) { providers := GetAvailableLyricsProviders() jsonBytes, err := json.Marshal(providers) @@ -1696,7 +1680,6 @@ func GetAvailableLyricsProvidersJSON() (string, error) { return string(jsonBytes), nil } -// SetLyricsFetchOptionsJSON sets lyrics provider fetch options. func SetLyricsFetchOptionsJSON(optionsJSON string) error { opts := GetLyricsFetchOptions() if strings.TrimSpace(optionsJSON) != "" { @@ -1709,7 +1692,6 @@ func SetLyricsFetchOptionsJSON(optionsJSON string) error { return nil } -// GetLyricsFetchOptionsJSON returns current lyrics provider fetch options. func GetLyricsFetchOptionsJSON() (string, error) { opts := GetLyricsFetchOptions() jsonBytes, err := json.Marshal(opts) diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index daf09111..694354b3 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -1,4 +1,3 @@ -// Package gobackend provides extension provider interfaces package gobackend import ( @@ -15,9 +14,6 @@ import ( "github.com/dop251/goja" ) -// ==================== Metadata Types ==================== - -// ExtTrackMetadata represents track metadata from an extension type ExtTrackMetadata struct { ID string `json:"id"` Name string `json:"name"` @@ -675,8 +671,20 @@ func isBuiltInProvider(providerID string) bool { func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) { priority := GetProviderPriority() extManager := GetExtensionManager() + strictMode := !req.UseFallback + selectedProvider := strings.TrimSpace(req.Service) - if req.Service != "" && isBuiltInProvider(req.Service) { + if strictMode { + if selectedProvider == "" { + selectedProvider = strings.TrimSpace(req.Source) + } + if selectedProvider != "" { + priority = []string{selectedProvider} + GoLog("[DownloadWithExtensionFallback] Strict mode enabled, provider locked to: %s\n", selectedProvider) + } + } + + if !strictMode && req.Service != "" && isBuiltInProvider(strings.ToLower(req.Service)) { GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service) newPriority := []string{req.Service} for _, p := range priority { @@ -691,7 +699,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro var lastErr error var skipBuiltIn bool - if req.Source != "" && !isBuiltInProvider(req.Source) { + if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) { ext, err := extManager.GetExtension(req.Source) if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() { GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source) @@ -754,7 +762,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } } - if req.Source != "" && !isBuiltInProvider(req.Source) { + if req.Source != "" && + !isBuiltInProvider(strings.ToLower(req.Source)) && + (!strictMode || selectedProvider == "" || strings.EqualFold(selectedProvider, req.Source)) { GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source) ext, err := extManager.GetExtension(req.Source) @@ -768,12 +778,29 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn) outputPath := buildOutputPath(req) + if req.ItemID != "" { + StartItemProgress(req.ItemID) + } result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) { if req.ItemID != "" { - SetItemProgress(req.ItemID, float64(percent), 0, 0) + normalized := float64(percent) / 100.0 + if normalized < 0 { + normalized = 0 + } + if normalized > 1 { + normalized = 1 + } + SetItemProgress(req.ItemID, normalized, 0, 0) } }) + if req.ItemID != "" { + if err == nil && result != nil && result.Success { + CompleteItemProgress(req.ItemID) + } else { + RemoveItemProgress(req.ItemID) + } + } if err == nil && result.Success { resp := &DownloadResponse{ @@ -860,18 +887,23 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } for _, providerID := range priority { + providerID = strings.TrimSpace(providerID) + if providerID == "" { + continue + } + providerIDNormalized := strings.ToLower(providerID) if providerID == req.Source { continue } - if skipBuiltIn && isBuiltInProvider(providerID) { + if skipBuiltIn && isBuiltInProvider(providerIDNormalized) { GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID) continue } GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID) - if isBuiltInProvider(providerID) { + if isBuiltInProvider(providerIDNormalized) { if (req.Genre == "" || req.Label == "") && req.ISRC != "" { GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -892,9 +924,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } } - result, err := tryBuiltInProvider(providerID, req) + result, err := tryBuiltInProvider(providerIDNormalized, req) if err == nil && result.Success { - result.Service = providerID + result.Service = providerIDNormalized if req.Label != "" { result.Label = req.Label } @@ -915,11 +947,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro Success: false, Error: "Download cancelled", ErrorType: "cancelled", - Service: providerID, + Service: providerIDNormalized, }, nil } lastErr = err - GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err) + GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerIDNormalized, err) } } else { ext, err := extManager.GetExtension(providerID) @@ -944,12 +976,29 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } outputPath := buildOutputPath(req) + if req.ItemID != "" { + StartItemProgress(req.ItemID) + } result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) { if req.ItemID != "" { - SetItemProgress(req.ItemID, float64(percent), 0, 0) + normalized := float64(percent) / 100.0 + if normalized < 0 { + normalized = 0 + } + if normalized > 1 { + normalized = 1 + } + SetItemProgress(req.ItemID, normalized, 0, 0) } }) + if req.ItemID != "" { + if err == nil && result != nil && result.Success { + CompleteItemProgress(req.ItemID) + } else { + RemoveItemProgress(req.ItemID) + } + } if err == nil && result.Success { resp := &DownloadResponse{ diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 9f87b39d..2e33d227 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -145,7 +145,6 @@ func (e *RedirectBlockedError) Error() string { return "redirect blocked: domain '" + e.Domain + "' not in allowed list" } -// isPrivateIP checks if a hostname resolves to a private/local IP address func isPrivateIP(host string) bool { hostLower := strings.ToLower(strings.TrimSpace(host)) if hostLower == "" { diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index 6195907a..2fa17e84 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -77,7 +77,6 @@ type StoreRegistry struct { Extensions []StoreExtension `json:"extensions"` } -// StoreExtensionResponse is the normalized response sent to Flutter type StoreExtensionResponse struct { ID string `json:"id"` Name string `json:"name"` @@ -421,7 +420,6 @@ func (s *ExtensionStore) ClearCache() { LogInfo("ExtensionStore", "Cache cleared") } -// Helper: case-insensitive contains func containsIgnoreCase(s, substr string) bool { return containsStr(toLower(s), substr) } diff --git a/go_backend/songlink.go b/go_backend/songlink.go index ecc9e35a..62f926f8 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -182,7 +182,6 @@ func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]str return urls, nil } -// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL func extractDeezerIDFromURL(deezerURL string) string { parts := strings.Split(deezerURL, "/") if len(parts) > 0 { @@ -260,10 +259,6 @@ func extractQobuzIDFromURL(qobuzURL string) string { return "" } -// extractTidalIDFromURL extracts Tidal track ID from URL -// URL formats: -// - https://tidal.com/browse/track/12345678 -// - https://listen.tidal.com/track/12345678 func extractTidalIDFromURL(tidalURL string) string { if tidalURL == "" { return "" @@ -289,11 +284,6 @@ func extractTidalIDFromURL(tidalURL string) string { return "" } -// extractYouTubeIDFromURL extracts YouTube video ID from URL -// URL formats: -// - https://www.youtube.com/watch?v=VIDEO_ID -// - https://youtu.be/VIDEO_ID -// - https://music.youtube.com/watch?v=VIDEO_ID func extractYouTubeIDFromURL(youtubeURL string) string { if youtubeURL == "" { return "" @@ -350,7 +340,6 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, return availability.DeezerID, nil } -// GetYouTubeURLFromSpotify converts a Spotify track ID to YouTube URL using SongLink func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string, error) { availability, err := s.CheckTrackAvailability(spotifyTrackID, "") if err != nil { @@ -364,7 +353,6 @@ func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string return availability.YouTubeURL, nil } -// AlbumAvailability represents album availability on different platforms type AlbumAvailability struct { SpotifyID string `json:"spotify_id"` Deezer bool `json:"deezer"` @@ -422,7 +410,6 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv return availability, nil } -// GetDeezerAlbumIDFromSpotify converts a Spotify album ID to Deezer album ID using SongLink func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (string, error) { availability, err := s.CheckAlbumAvailability(spotifyAlbumID) if err != nil { @@ -652,7 +639,6 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit return availability, nil } -// extractSpotifyIDFromURL extracts Spotify track ID from URL func extractSpotifyIDFromURL(spotifyURL string) string { parts := strings.Split(spotifyURL, "/track/") if len(parts) > 1 { @@ -678,7 +664,6 @@ func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, e return availability.SpotifyID, nil } -// GetTidalURLFromDeezer converts a Deezer track ID to Tidal URL using SongLink func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, error) { availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID) if err != nil { @@ -705,7 +690,6 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e return availability.AmazonURL, nil } -// GetYouTubeURLFromDeezer converts a Deezer track ID to YouTube URL using SongLink func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, error) { availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID) if err != nil { diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 4da9e3e5..a41f5484 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -4,13 +4,13 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/services/app_state_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/download_request_payload.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; @@ -691,14 +691,15 @@ class _ProgressUpdate { class DownloadQueueNotifier extends Notifier { Timer? _progressTimer; + Timer? _queuePersistDebounce; int _downloadCount = 0; static const _cleanupInterval = 50; - static const _queueStorageKey = 'download_queue'; static const _progressPollingInterval = Duration(milliseconds: 800); static const _queueSchedulingInterval = Duration(milliseconds: 250); + static const _queuePersistDebounceDuration = Duration(milliseconds: 350); static const _bytesUiStep = 104857; // ~0.1 MiB, matches one-decimal MB UI. final NotificationService _notificationService = NotificationService(); - final Future _prefs = SharedPreferences.getInstance(); + final AppStateDatabase _appStateDb = AppStateDatabase.instance; int _totalQueuedAtStart = 0; int _completedInSession = 0; int _failedInSession = 0; @@ -777,6 +778,13 @@ class DownloadQueueNotifier extends Notifier { ref.onDispose(() { _progressTimer?.cancel(); _progressTimer = null; + if (_queuePersistDebounce?.isActive == true) { + _queuePersistDebounce?.cancel(); + unawaited(_flushQueueToStorage()); + } else { + _queuePersistDebounce?.cancel(); + } + _queuePersistDebounce = null; }); Future.microtask(() async { @@ -792,46 +800,56 @@ class DownloadQueueNotifier extends Notifier { _isLoaded = true; try { - final prefs = await _prefs; - final jsonStr = prefs.getString(_queueStorageKey); - if (jsonStr != null && jsonStr.isNotEmpty) { - final List jsonList = jsonDecode(jsonStr); - final items = jsonList - .map((e) => DownloadItem.fromJson(e as Map)) - .toList(); - - final restoredItems = items.map((item) { - if (item.status == DownloadStatus.downloading) { - return item.copyWith(status: DownloadStatus.queued, progress: 0); - } - return item; - }).toList(); - - final pendingItems = restoredItems - .where((item) => item.status == DownloadStatus.queued) - .toList(); - - if (pendingItems.isNotEmpty) { - state = state.copyWith(items: pendingItems); - _log.i('Restored ${pendingItems.length} pending items from storage'); - - Future.microtask(() => _processQueue()); - } else { - _log.d('No pending items to restore'); - await prefs.remove(_queueStorageKey); - } - } else { + await _appStateDb.migrateQueueFromSharedPreferences(); + final rows = await _appStateDb.getPendingDownloadQueueRows(); + if (rows.isEmpty) { _log.d('No queue found in storage'); + return; } + + final pendingItems = []; + for (final row in rows) { + final itemJson = row['item_json'] as String?; + if (itemJson == null || itemJson.isEmpty) continue; + + try { + final decoded = jsonDecode(itemJson); + if (decoded is! Map) continue; + var item = DownloadItem.fromJson(Map.from(decoded)); + if (item.status == DownloadStatus.downloading) { + item = item.copyWith(status: DownloadStatus.queued, progress: 0); + } + if (item.status == DownloadStatus.queued) { + pendingItems.add(item); + } + } catch (_) { + continue; + } + } + + if (pendingItems.isEmpty) { + _log.d('No pending items to restore'); + await _appStateDb.replacePendingDownloadQueueRows(const []); + return; + } + + state = state.copyWith(items: pendingItems); + _log.i('Restored ${pendingItems.length} pending items from storage'); + Future.microtask(() => _processQueue()); } catch (e) { _log.e('Failed to load queue from storage: $e'); } } - Future _saveQueueToStorage() async { - try { - final prefs = await _prefs; + void _saveQueueToStorage() { + _queuePersistDebounce?.cancel(); + _queuePersistDebounce = Timer(_queuePersistDebounceDuration, () { + _flushQueueToStorage(); + }); + } + Future _flushQueueToStorage() async { + try { final pendingItems = state.items .where( (item) => @@ -841,11 +859,22 @@ class DownloadQueueNotifier extends Notifier { .toList(); if (pendingItems.isEmpty) { - await prefs.remove(_queueStorageKey); + await _appStateDb.replacePendingDownloadQueueRows(const []); _log.d('Cleared queue storage (no pending items)'); } else { - final jsonList = pendingItems.map((e) => e.toJson()).toList(); - await prefs.setString(_queueStorageKey, jsonEncode(jsonList)); + final nowIso = DateTime.now().toIso8601String(); + final rows = pendingItems + .map( + (item) => { + 'id': item.id, + 'item_json': jsonEncode(item.toJson()), + 'status': item.status.name, + 'created_at': item.createdAt.toIso8601String(), + 'updated_at': nowIso, + }, + ) + .toList(growable: false); + await _appStateDb.replacePendingDownloadQueueRows(rows); _log.d('Saved ${pendingItems.length} pending items to storage'); } } catch (e) { @@ -1977,26 +2006,37 @@ class DownloadQueueNotifier extends Notifier { _log.d('Metadata map content: $metadata'); - try { - final durationMs = track.duration * 1000; + final lyricsMode = settings.lyricsMode; + final shouldEmbedLyrics = + settings.embedLyrics && + (lyricsMode == 'embed' || lyricsMode == 'both'); - final lrcContent = await PlatformBridge.getLyricsLRC( - track.id, - track.name, - track.artistName, - filePath: '', - durationMs: durationMs, - ); + if (shouldEmbedLyrics) { + try { + final durationMs = track.duration * 1000; - if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') { - metadata['LYRICS'] = lrcContent; - metadata['UNSYNCEDLYRICS'] = lrcContent; - _log.d('Lyrics fetched for embedding (${lrcContent.length} chars)'); - } else if (lrcContent == '[instrumental:true]') { - _log.d('Track is instrumental, skipping lyrics embedding'); + final lrcContent = await PlatformBridge.getLyricsLRC( + track.id, + track.name, + track.artistName, + filePath: '', + durationMs: durationMs, + ); + + if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') { + metadata['LYRICS'] = lrcContent; + metadata['UNSYNCEDLYRICS'] = lrcContent; + _log.d('Lyrics fetched for embedding (${lrcContent.length} chars)'); + } else if (lrcContent == '[instrumental:true]') { + _log.d('Track is instrumental, skipping lyrics embedding'); + } + } catch (e) { + _log.w('Failed to fetch lyrics for embedding: $e'); } - } catch (e) { - _log.w('Failed to fetch lyrics for embedding: $e'); + } else { + metadata['LYRICS'] = ''; + metadata['UNSYNCEDLYRICS'] = ''; + _log.d('Lyrics embedding disabled by settings, skipping lyric fetch'); } _log.d('Generating tags for FLAC: $metadata'); @@ -3012,8 +3052,7 @@ class DownloadQueueNotifier extends Notifier { final outputExt = useSaf ? safOutputExt : ''; final isYouTube = item.service == 'youtube'; final shouldUseExtensions = !isYouTube && useExtensions; - final shouldUseFallback = - !isYouTube && !shouldUseExtensions && state.autoFallback; + final shouldUseFallback = !isYouTube && state.autoFallback; if (isYouTube) { _log.d('Using YouTube/Cobalt provider for download'); diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart index e1d7ca1f..485e3482 100644 --- a/lib/providers/library_collections_provider.dart +++ b/lib/providers/library_collections_provider.dart @@ -4,10 +4,8 @@ import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/models/track.dart'; - -const _collectionsStorageKey = 'library_collections_v1'; +import 'package:spotiflac_android/services/library_collections_database.dart'; String trackCollectionKey(Track track) { final isrc = track.isrc?.trim(); @@ -54,15 +52,17 @@ class UserPlaylistCollection { final DateTime createdAt; final DateTime updatedAt; final List tracks; + final Set _trackKeys; - const UserPlaylistCollection({ + UserPlaylistCollection({ required this.id, required this.name, this.coverImagePath, required this.createdAt, required this.updatedAt, required this.tracks, - }); + Set? trackKeys, + }) : _trackKeys = trackKeys ?? tracks.map((entry) => entry.key).toSet(); UserPlaylistCollection copyWith({ String? id, @@ -72,20 +72,28 @@ class UserPlaylistCollection { DateTime? updatedAt, List? tracks, }) { + final nextTracks = tracks ?? this.tracks; + final keepTrackIndex = identical(nextTracks, this.tracks); return UserPlaylistCollection( id: id ?? this.id, name: name ?? this.name, - coverImagePath: - coverImagePath != null ? coverImagePath() : this.coverImagePath, + coverImagePath: coverImagePath != null + ? coverImagePath() + : this.coverImagePath, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, - tracks: tracks ?? this.tracks, + tracks: nextTracks, + trackKeys: keepTrackIndex ? _trackKeys : null, ); } bool containsTrack(Track track) { final key = trackCollectionKey(track); - return tracks.any((entry) => entry.key == key); + return _trackKeys.contains(key); + } + + bool containsTrackKey(String trackKey) { + return _trackKeys.contains(trackKey); } Map toJson() => { @@ -124,13 +132,26 @@ class LibraryCollectionsState { final List loved; final List playlists; final bool isLoaded; + final Set _wishlistKeys; + final Set _lovedKeys; + final Map _playlistsById; - const LibraryCollectionsState({ + LibraryCollectionsState({ this.wishlist = const [], this.loved = const [], this.playlists = const [], this.isLoaded = false, - }); + Set? wishlistKeys, + Set? lovedKeys, + Map? playlistsById, + }) : _wishlistKeys = + wishlistKeys ?? wishlist.map((entry) => entry.key).toSet(), + _lovedKeys = lovedKeys ?? loved.map((entry) => entry.key).toSet(), + _playlistsById = + playlistsById ?? + Map.fromEntries( + playlists.map((playlist) => MapEntry(playlist.id, playlist)), + ); int get wishlistCount => wishlist.length; int get lovedCount => loved.length; @@ -138,19 +159,30 @@ class LibraryCollectionsState { bool isInWishlist(Track track) { final key = trackCollectionKey(track); - return wishlist.any((entry) => entry.key == key); + return _wishlistKeys.contains(key); } bool isLoved(Track track) { final key = trackCollectionKey(track); - return loved.any((entry) => entry.key == key); + return _lovedKeys.contains(key); + } + + bool containsWishlistKey(String trackKey) { + return _wishlistKeys.contains(trackKey); + } + + bool containsLovedKey(String trackKey) { + return _lovedKeys.contains(trackKey); } UserPlaylistCollection? playlistById(String playlistId) { - for (final playlist in playlists) { - if (playlist.id == playlistId) return playlist; - } - return null; + return _playlistsById[playlistId]; + } + + bool playlistContainsTrack(String playlistId, String trackKey) { + final playlist = _playlistsById[playlistId]; + if (playlist == null) return false; + return playlist.containsTrackKey(trackKey); } LibraryCollectionsState copyWith({ @@ -159,11 +191,21 @@ class LibraryCollectionsState { List? playlists, bool? isLoaded, }) { + final nextWishlist = wishlist ?? this.wishlist; + final nextLoved = loved ?? this.loved; + final nextPlaylists = playlists ?? this.playlists; + final keepWishlistIndex = identical(nextWishlist, this.wishlist); + final keepLovedIndex = identical(nextLoved, this.loved); + final keepPlaylistIndex = identical(nextPlaylists, this.playlists); + return LibraryCollectionsState( - wishlist: wishlist ?? this.wishlist, - loved: loved ?? this.loved, - playlists: playlists ?? this.playlists, + wishlist: nextWishlist, + loved: nextLoved, + playlists: nextPlaylists, isLoaded: isLoaded ?? this.isLoaded, + wishlistKeys: keepWishlistIndex ? _wishlistKeys : null, + lovedKeys: keepLovedIndex ? _lovedKeys : null, + playlistsById: keepPlaylistIndex ? _playlistsById : null, ); } @@ -203,56 +245,145 @@ class LibraryCollectionsState { } } +class PlaylistAddBatchResult { + final int addedCount; + final int alreadyInPlaylistCount; + + const PlaylistAddBatchResult({ + required this.addedCount, + required this.alreadyInPlaylistCount, + }); +} + class LibraryCollectionsNotifier extends Notifier { - final Future _prefs = SharedPreferences.getInstance(); + final LibraryCollectionsDatabase _db = LibraryCollectionsDatabase.instance; Future? _loadFuture; @override LibraryCollectionsState build() { _loadFuture = _load(); - return const LibraryCollectionsState(); + return LibraryCollectionsState(); } Future _load() async { - final prefs = await _prefs; - final raw = prefs.getString(_collectionsStorageKey); - - if (raw == null || raw.isEmpty) { - state = state.copyWith(isLoaded: true); - return; - } - try { - final parsed = jsonDecode(raw); - if (parsed is Map) { - state = LibraryCollectionsState.fromJson(parsed); - } else { - state = state.copyWith(isLoaded: true); + await _db.migrateFromSharedPreferences(); + final snapshot = await _db.loadSnapshot(); + + final wishlist = []; + for (final row in snapshot.wishlistRows) { + final parsed = _parseTrackEntryRow(row); + if (parsed != null) { + wishlist.add(parsed); + } } + + final loved = []; + for (final row in snapshot.lovedRows) { + final parsed = _parseTrackEntryRow(row); + if (parsed != null) { + loved.add(parsed); + } + } + + final tracksByPlaylist = >{}; + for (final row in snapshot.playlistTrackRows) { + final playlistId = row['playlist_id'] as String?; + if (playlistId == null || playlistId.isEmpty) continue; + final parsed = _parseTrackEntryRow(row); + if (parsed == null) continue; + tracksByPlaylist.putIfAbsent(playlistId, () => []).add(parsed); + } + + final playlists = []; + for (final row in snapshot.playlistRows) { + final id = row['id'] as String?; + if (id == null || id.isEmpty) continue; + + final createdAtRaw = row['created_at'] as String?; + final updatedAtRaw = row['updated_at'] as String?; + final createdAt = + DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now(); + final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt; + + playlists.add( + UserPlaylistCollection( + id: id, + name: row['name'] as String? ?? '', + coverImagePath: row['cover_image_path'] as String?, + createdAt: createdAt, + updatedAt: updatedAt, + tracks: tracksByPlaylist[id] ?? const [], + ), + ); + } + + state = LibraryCollectionsState( + wishlist: wishlist, + loved: loved, + playlists: playlists, + isLoaded: true, + ); } catch (_) { state = state.copyWith(isLoaded: true); } } - Future _save() async { - final prefs = await _prefs; - await prefs.setString(_collectionsStorageKey, jsonEncode(state.toJson())); - } - Future _ensureLoaded() async { if (state.isLoaded) return; await (_loadFuture ?? _load()); } + CollectionTrackEntry? _parseTrackEntryRow(Map row) { + final key = row['track_key'] as String?; + final trackJson = row['track_json'] as String?; + if (key == null || key.isEmpty || trackJson == null || trackJson.isEmpty) { + return null; + } + + try { + final decoded = jsonDecode(trackJson); + if (decoded is! Map) return null; + final track = Track.fromJson(Map.from(decoded)); + final addedAtRaw = row['added_at'] as String?; + return CollectionTrackEntry( + key: key, + track: track, + addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(), + ); + } catch (_) { + return null; + } + } + + bool _replacePlaylistById( + String playlistId, + UserPlaylistCollection Function(UserPlaylistCollection playlist) update, + ) { + final playlist = state.playlistById(playlistId); + if (playlist == null) return false; + + final playlistIndex = state.playlists.indexWhere((p) => p.id == playlistId); + if (playlistIndex < 0) return false; + + final nextPlaylist = update(playlist); + if (identical(nextPlaylist, playlist)) return false; + + final updatedPlaylists = [...state.playlists]; + updatedPlaylists[playlistIndex] = nextPlaylist; + state = state.copyWith(playlists: updatedPlaylists); + return true; + } + Future toggleWishlist(Track track) async { await _ensureLoaded(); final key = trackCollectionKey(track); - final index = state.wishlist.indexWhere((entry) => entry.key == key); - - if (index >= 0) { - final updated = [...state.wishlist]..removeAt(index); + if (state.containsWishlistKey(key)) { + await _db.deleteWishlistEntry(key); + final updated = state.wishlist + .where((entry) => entry.key != key) + .toList(growable: false); state = state.copyWith(wishlist: updated); - await _save(); return false; } @@ -261,21 +392,25 @@ class LibraryCollectionsNotifier extends Notifier { track: track, addedAt: DateTime.now(), ); + await _db.upsertWishlistEntry( + trackKey: key, + trackJson: jsonEncode(track.toJson()), + addedAt: entry.addedAt.toIso8601String(), + ); final updated = [entry, ...state.wishlist]; state = state.copyWith(wishlist: updated); - await _save(); return true; } Future toggleLoved(Track track) async { await _ensureLoaded(); final key = trackCollectionKey(track); - final index = state.loved.indexWhere((entry) => entry.key == key); - - if (index >= 0) { - final updated = [...state.loved]..removeAt(index); + if (state.containsLovedKey(key)) { + await _db.deleteLovedEntry(key); + final updated = state.loved + .where((entry) => entry.key != key) + .toList(growable: false); state = state.copyWith(loved: updated); - await _save(); return false; } @@ -284,30 +419,36 @@ class LibraryCollectionsNotifier extends Notifier { track: track, addedAt: DateTime.now(), ); + await _db.upsertLovedEntry( + trackKey: key, + trackJson: jsonEncode(track.toJson()), + addedAt: entry.addedAt.toIso8601String(), + ); final updated = [entry, ...state.loved]; state = state.copyWith(loved: updated); - await _save(); return true; } Future removeFromWishlist(String trackKey) async { await _ensureLoaded(); + if (!state.containsWishlistKey(trackKey)) return; + + await _db.deleteWishlistEntry(trackKey); final updated = state.wishlist .where((entry) => entry.key != trackKey) .toList(growable: false); - if (updated.length == state.wishlist.length) return; state = state.copyWith(wishlist: updated); - await _save(); } Future removeFromLoved(String trackKey) async { await _ensureLoaded(); + if (!state.containsLovedKey(trackKey)) return; + + await _db.deleteLovedEntry(trackKey); final updated = state.loved .where((entry) => entry.key != trackKey) .toList(growable: false); - if (updated.length == state.loved.length) return; state = state.copyWith(loved: updated); - await _save(); } Future createPlaylist(String name) async { @@ -324,8 +465,14 @@ class LibraryCollectionsNotifier extends Notifier { tracks: const [], ); + await _db.upsertPlaylist( + id: id, + name: trimmedName, + coverImagePath: null, + createdAt: now.toIso8601String(), + updatedAt: now.toIso8601String(), + ); state = state.copyWith(playlists: [playlist, ...state.playlists]); - await _save(); return id; } @@ -333,90 +480,149 @@ class LibraryCollectionsNotifier extends Notifier { await _ensureLoaded(); final trimmed = newName.trim(); if (trimmed.isEmpty) return; + final playlist = state.playlistById(playlistId); + if (playlist == null || playlist.name == trimmed) return; final now = DateTime.now(); - final updated = state.playlists - .map((playlist) { - if (playlist.id != playlistId) return playlist; - return playlist.copyWith(name: trimmed, updatedAt: now); - }) - .toList(growable: false); - - state = state.copyWith(playlists: updated); - await _save(); + await _db.renamePlaylist( + playlistId: playlistId, + name: trimmed, + updatedAt: now.toIso8601String(), + ); + _replacePlaylistById(playlistId, (playlist) { + return playlist.copyWith(name: trimmed, updatedAt: now); + }); } Future deletePlaylist(String playlistId) async { await _ensureLoaded(); - final updated = state.playlists - .where((playlist) => playlist.id != playlistId) - .toList(growable: false); - if (updated.length == state.playlists.length) return; - state = state.copyWith(playlists: updated); - await _save(); + final playlistIndex = state.playlists.indexWhere((p) => p.id == playlistId); + if (playlistIndex < 0) return; + + await _db.deletePlaylist(playlistId); + final updatedPlaylists = [...state.playlists]..removeAt(playlistIndex); + state = state.copyWith(playlists: updatedPlaylists); } Future addTrackToPlaylist(String playlistId, Track track) async { await _ensureLoaded(); + final playlist = state.playlistById(playlistId); + if (playlist == null) return false; + final key = trackCollectionKey(track); + if (playlist.containsTrackKey(key)) return false; + final now = DateTime.now(); - var changed = false; - - final updated = state.playlists - .map((playlist) { - if (playlist.id != playlistId) return playlist; - final alreadyInPlaylist = playlist.tracks.any( - (entry) => entry.key == key, - ); - if (alreadyInPlaylist) return playlist; - changed = true; - final entry = CollectionTrackEntry( - key: key, - track: track, - addedAt: now, - ); - return playlist.copyWith( - tracks: [entry, ...playlist.tracks], - updatedAt: now, - ); - }) - .toList(growable: false); - + final entry = CollectionTrackEntry(key: key, track: track, addedAt: now); + await _db.upsertPlaylistTrack( + playlistId: playlistId, + trackKey: key, + trackJson: jsonEncode(track.toJson()), + addedAt: entry.addedAt.toIso8601String(), + playlistUpdatedAt: now.toIso8601String(), + ); + final changed = _replacePlaylistById(playlistId, (playlist) { + if (playlist.containsTrackKey(key)) return playlist; + return playlist.copyWith( + tracks: [entry, ...playlist.tracks], + updatedAt: now, + ); + }); if (!changed) return false; - - state = state.copyWith(playlists: updated); - await _save(); return true; } + Future addTracksToPlaylist( + String playlistId, + Iterable tracks, + ) async { + await _ensureLoaded(); + final playlist = state.playlistById(playlistId); + if (playlist == null) { + return const PlaylistAddBatchResult( + addedCount: 0, + alreadyInPlaylistCount: 0, + ); + } + + final now = DateTime.now(); + final knownKeys = {...playlist._trackKeys}; + final entriesToAdd = []; + var alreadyInPlaylistCount = 0; + + for (final track in tracks) { + final key = trackCollectionKey(track); + if (!knownKeys.add(key)) { + alreadyInPlaylistCount++; + continue; + } + + entriesToAdd.add( + CollectionTrackEntry(key: key, track: track, addedAt: now), + ); + } + + if (entriesToAdd.isEmpty) { + return PlaylistAddBatchResult( + addedCount: 0, + alreadyInPlaylistCount: alreadyInPlaylistCount, + ); + } + + await _db.upsertPlaylistTracksBatch( + playlistId: playlistId, + playlistUpdatedAt: now.toIso8601String(), + tracks: entriesToAdd + .map( + (entry) => { + 'track_key': entry.key, + 'track_json': jsonEncode(entry.track.toJson()), + 'added_at': entry.addedAt.toIso8601String(), + }, + ) + .toList(growable: false), + ); + final changed = _replacePlaylistById(playlistId, (current) { + return current.copyWith( + tracks: [...entriesToAdd.reversed, ...current.tracks], + updatedAt: now, + ); + }); + if (!changed) { + return PlaylistAddBatchResult( + addedCount: 0, + alreadyInPlaylistCount: alreadyInPlaylistCount, + ); + } + return PlaylistAddBatchResult( + addedCount: entriesToAdd.length, + alreadyInPlaylistCount: alreadyInPlaylistCount, + ); + } + Future removeTrackFromPlaylist( String playlistId, String trackKey, ) async { await _ensureLoaded(); + final playlist = state.playlistById(playlistId); + if (playlist == null || !playlist.containsTrackKey(trackKey)) return; + final now = DateTime.now(); - var changed = false; - - final updated = state.playlists - .map((playlist) { - if (playlist.id != playlistId) return playlist; - final nextTracks = playlist.tracks - .where((entry) => entry.key != trackKey) - .toList(growable: false); - if (nextTracks.length == playlist.tracks.length) return playlist; - changed = true; - return playlist.copyWith(tracks: nextTracks, updatedAt: now); - }) - .toList(growable: false); - - if (!changed) return; - - state = state.copyWith(playlists: updated); - await _save(); + await _db.deletePlaylistTrack( + playlistId: playlistId, + trackKey: trackKey, + playlistUpdatedAt: now.toIso8601String(), + ); + _replacePlaylistById(playlistId, (playlist) { + final nextTracks = playlist.tracks + .where((entry) => entry.key != trackKey) + .toList(growable: false); + if (nextTracks.length == playlist.tracks.length) return playlist; + return playlist.copyWith(tracks: nextTracks, updatedAt: now); + }); } - /// Returns the directory for storing playlist cover images, creating it - /// if necessary. Future _playlistCoversDir() async { final appDir = await getApplicationSupportDirectory(); final dir = Directory(p.join(appDir.path, 'playlist_covers')); @@ -426,41 +632,38 @@ class LibraryCollectionsNotifier extends Notifier { return dir; } - /// Sets a custom cover image for a playlist by copying the source file - /// into the app's persistent storage. Future setPlaylistCover( String playlistId, String sourceFilePath, ) async { await _ensureLoaded(); + final playlist = state.playlistById(playlistId); + if (playlist == null) return; + final coversDir = await _playlistCoversDir(); final ext = p.extension(sourceFilePath).toLowerCase(); final destPath = p.join(coversDir.path, '$playlistId$ext'); + if (playlist.coverImagePath == destPath) return; // Copy image to persistent location await File(sourceFilePath).copy(destPath); final now = DateTime.now(); - final updated = state.playlists - .map((playlist) { - if (playlist.id != playlistId) return playlist; - return playlist.copyWith( - coverImagePath: () => destPath, - updatedAt: now, - ); - }) - .toList(growable: false); - - state = state.copyWith(playlists: updated); - await _save(); + await _db.updatePlaylistCover( + playlistId: playlistId, + coverImagePath: destPath, + updatedAt: now.toIso8601String(), + ); + _replacePlaylistById(playlistId, (playlist) { + if (playlist.coverImagePath == destPath) return playlist; + return playlist.copyWith(coverImagePath: () => destPath, updatedAt: now); + }); } - /// Removes the custom cover image for a playlist (falls back to first - /// track's cover). Future removePlaylistCover(String playlistId) async { await _ensureLoaded(); final playlist = state.playlistById(playlistId); - if (playlist == null) return; + if (playlist == null || playlist.coverImagePath == null) return; // Delete the file if it exists final path = playlist.coverImagePath; @@ -472,15 +675,15 @@ class LibraryCollectionsNotifier extends Notifier { } final now = DateTime.now(); - final updated = state.playlists - .map((pl) { - if (pl.id != playlistId) return pl; - return pl.copyWith(coverImagePath: () => null, updatedAt: now); - }) - .toList(growable: false); - - state = state.copyWith(playlists: updated); - await _save(); + await _db.updatePlaylistCover( + playlistId: playlistId, + coverImagePath: null, + updatedAt: now.toIso8601String(), + ); + _replacePlaylistById(playlistId, (playlist) { + if (playlist.coverImagePath == null) return playlist; + return playlist.copyWith(coverImagePath: () => null, updatedAt: now); + }); } } diff --git a/lib/providers/recent_access_provider.dart b/lib/providers/recent_access_provider.dart index e0475aef..8a2ba671 100644 --- a/lib/providers/recent_access_provider.dart +++ b/lib/providers/recent_access_provider.dart @@ -1,18 +1,12 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/services/app_state_database.dart'; -const _recentAccessKey = 'recent_access_history'; -const _hiddenDownloadsKey = 'hidden_downloads_in_recents'; const _maxRecentItems = 20; /// Types of items that can be accessed -enum RecentAccessType { - artist, - album, - track, - playlist, -} +enum RecentAccessType { artist, album, track, playlist } /// Represents a recently accessed item class RecentAccessItem { @@ -100,7 +94,7 @@ class RecentAccessState { /// Provider for managing recent access history class RecentAccessNotifier extends Notifier { - final Future _prefs = SharedPreferences.getInstance(); + final AppStateDatabase _appStateDb = AppStateDatabase.instance; @override RecentAccessState build() { @@ -109,40 +103,36 @@ class RecentAccessNotifier extends Notifier { } Future _loadHistory() async { - final prefs = await _prefs; - final json = prefs.getString(_recentAccessKey); - final hiddenJson = prefs.getStringList(_hiddenDownloadsKey); - - List items = []; - Set hiddenIds = {}; - - if (json != null) { - try { - final List decoded = jsonDecode(json); - items = decoded - .map((e) => RecentAccessItem.fromJson(e as Map)) - .toList(); - } catch (_) { - // Ignore JSON parse errors, use empty list + try { + await _appStateDb.migrateRecentAccessFromSharedPreferences(); + final rows = await _appStateDb.getRecentAccessRows( + limit: _maxRecentItems, + ); + final hiddenIds = await _appStateDb.getHiddenRecentDownloadIds(); + + final items = []; + for (final row in rows) { + final itemJson = row['item_json'] as String?; + if (itemJson == null || itemJson.isEmpty) continue; + try { + final decoded = jsonDecode(itemJson); + if (decoded is! Map) continue; + items.add( + RecentAccessItem.fromJson(Map.from(decoded)), + ); + } catch (_) { + continue; + } } - } - - if (hiddenJson != null) { - hiddenIds = hiddenJson.toSet(); - } - - state = state.copyWith(items: items, hiddenDownloadIds: hiddenIds, isLoaded: true); - } - Future _saveHistory() async { - final prefs = await _prefs; - final json = jsonEncode(state.items.map((e) => e.toJson()).toList()); - await prefs.setString(_recentAccessKey, json); - } - - Future _saveHiddenDownloads() async { - final prefs = await _prefs; - await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList()); + state = state.copyWith( + items: items, + hiddenDownloadIds: hiddenIds, + isLoaded: true, + ); + } catch (_) { + state = state.copyWith(isLoaded: true); + } } /// Record an access to an artist @@ -152,14 +142,16 @@ class RecentAccessNotifier extends Notifier { String? imageUrl, String? providerId, }) { - _recordAccess(RecentAccessItem( - id: id, - name: name, - imageUrl: imageUrl, - type: RecentAccessType.artist, - accessedAt: DateTime.now(), - providerId: providerId, - )); + _recordAccess( + RecentAccessItem( + id: id, + name: name, + imageUrl: imageUrl, + type: RecentAccessType.artist, + accessedAt: DateTime.now(), + providerId: providerId, + ), + ); } /// Record an access to an album @@ -170,15 +162,17 @@ class RecentAccessNotifier extends Notifier { String? imageUrl, String? providerId, }) { - _recordAccess(RecentAccessItem( - id: id, - name: name, - subtitle: artistName, - imageUrl: imageUrl, - type: RecentAccessType.album, - accessedAt: DateTime.now(), - providerId: providerId, - )); + _recordAccess( + RecentAccessItem( + id: id, + name: name, + subtitle: artistName, + imageUrl: imageUrl, + type: RecentAccessType.album, + accessedAt: DateTime.now(), + providerId: providerId, + ), + ); } /// Record an access to a track @@ -189,15 +183,17 @@ class RecentAccessNotifier extends Notifier { String? imageUrl, String? providerId, }) { - _recordAccess(RecentAccessItem( - id: id, - name: name, - subtitle: artistName, - imageUrl: imageUrl, - type: RecentAccessType.track, - accessedAt: DateTime.now(), - providerId: providerId, - )); + _recordAccess( + RecentAccessItem( + id: id, + name: name, + subtitle: artistName, + imageUrl: imageUrl, + type: RecentAccessType.track, + accessedAt: DateTime.now(), + providerId: providerId, + ), + ); } /// Record an access to a playlist @@ -208,30 +204,42 @@ class RecentAccessNotifier extends Notifier { String? imageUrl, String? providerId, }) { - _recordAccess(RecentAccessItem( - id: id, - name: name, - subtitle: ownerName, - imageUrl: imageUrl, - type: RecentAccessType.playlist, - accessedAt: DateTime.now(), - providerId: providerId, - )); + _recordAccess( + RecentAccessItem( + id: id, + name: name, + subtitle: ownerName, + imageUrl: imageUrl, + type: RecentAccessType.playlist, + accessedAt: DateTime.now(), + providerId: providerId, + ), + ); } void _recordAccess(RecentAccessItem item) { final updatedItems = state.items .where((e) => e.uniqueKey != item.uniqueKey) .toList(); - + updatedItems.insert(0, item); - + + RecentAccessItem? removedTail; if (updatedItems.length > _maxRecentItems) { - updatedItems.removeRange(_maxRecentItems, updatedItems.length); + removedTail = updatedItems.removeLast(); } - + state = state.copyWith(items: updatedItems); - _saveHistory(); + unawaited( + _appStateDb.upsertRecentAccessRow( + uniqueKey: item.uniqueKey, + itemJson: jsonEncode(item.toJson()), + accessedAt: item.accessedAt.toIso8601String(), + ), + ); + if (removedTail != null) { + unawaited(_appStateDb.deleteRecentAccessRow(removedTail.uniqueKey)); + } } /// Remove a specific item from history @@ -240,14 +248,14 @@ class RecentAccessNotifier extends Notifier { .where((e) => e.uniqueKey != item.uniqueKey) .toList(); state = state.copyWith(items: updatedItems); - _saveHistory(); + unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey)); } /// Hide a download item from recents (without deleting the actual download) void hideDownloadFromRecents(String downloadId) { final updatedHidden = {...state.hiddenDownloadIds, downloadId}; state = state.copyWith(hiddenDownloadIds: updatedHidden); - _saveHiddenDownloads(); + unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId)); } /// Check if a download is hidden from recents @@ -258,16 +266,17 @@ class RecentAccessNotifier extends Notifier { /// Clear all history void clearHistory() { state = state.copyWith(items: []); - _saveHistory(); + unawaited(_appStateDb.clearRecentAccessRows()); } /// Clear hidden downloads (show all again) void clearHiddenDownloads() { state = state.copyWith(hiddenDownloadIds: {}); - _saveHiddenDownloads(); + unawaited(_appStateDb.clearHiddenRecentDownloadIds()); } } -final recentAccessProvider = NotifierProvider( - RecentAccessNotifier.new, -); +final recentAccessProvider = + NotifierProvider( + RecentAccessNotifier.new, + ); diff --git a/lib/screens/library_playlists_screen.dart b/lib/screens/library_playlists_screen.dart index e1ad14b9..5a2eaf7f 100644 --- a/lib/screens/library_playlists_screen.dart +++ b/lib/screens/library_playlists_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; class LibraryPlaylistsScreen extends ConsumerWidget { @@ -47,10 +48,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { return FlexibleSpaceBar( expandedTitleScale: 1.0, - titlePadding: EdgeInsets.only( - left: leftPadding, - bottom: 16, - ), + titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), title: Text( context.l10n.collectionPlaylists, style: TextStyle( @@ -87,10 +85,9 @@ class LibraryPlaylistsScreen extends ConsumerWidget { Text( context.l10n.collectionNoPlaylistsSubtitle, textAlign: TextAlign.center, - style: - Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ], ), @@ -99,42 +96,39 @@ class LibraryPlaylistsScreen extends ConsumerWidget { ) else SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - // Even indices = playlist tiles, odd indices = dividers - if (index.isOdd) { - return const Divider(height: 1); - } - final playlistIndex = index ~/ 2; - final playlist = playlists[playlistIndex]; - return ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 2, + delegate: SliverChildBuilderDelegate((context, index) { + // Even indices = playlist tiles, odd indices = dividers + if (index.isOdd) { + return const Divider(height: 1); + } + final playlistIndex = index ~/ 2; + final playlist = playlists[playlistIndex]; + return ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 2, + ), + leading: _buildPlaylistThumbnail(context, playlist), + title: Text(playlist.name), + subtitle: Text( + context.l10n.collectionPlaylistTracks( + playlist.tracks.length, ), - leading: _buildPlaylistThumbnail(context, playlist), - title: Text(playlist.name), - subtitle: Text( - context.l10n.collectionPlaylistTracks( - playlist.tracks.length, - ), - ), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => LibraryTracksFolderScreen( - mode: LibraryTracksFolderMode.playlist, - playlistId: playlist.id, - ), + ), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.playlist, + playlistId: playlist.id, ), - ); - }, - onLongPress: () => - _showPlaylistOptionsSheet(context, ref, playlist), - ); - }, - childCount: playlists.length * 2 - 1, - ), + ), + ); + }, + onLongPress: () => + _showPlaylistOptionsSheet(context, ref, playlist), + ); + }, childCount: playlists.length * 2 - 1), ), ], ), @@ -171,8 +165,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { width: 40, height: 4, decoration: BoxDecoration( - color: - colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2), ), ), @@ -188,9 +181,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { children: [ Text( playlist.name, - style: Theme.of(context) - .textTheme - .titleMedium + style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.w600), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -200,9 +191,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { context.l10n.collectionPlaylistTracks( playlist.tracks.length, ), - style: Theme.of(context) - .textTheme - .bodyMedium + style: Theme.of(context).textTheme.bodyMedium ?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -291,12 +280,33 @@ class LibraryPlaylistsScreen extends ConsumerWidget { ); } - final firstCoverUrl = playlist.tracks - .where((e) => e.track.coverUrl != null && e.track.coverUrl!.isNotEmpty) - .map((e) => e.track.coverUrl!) - .firstOrNull; + String? firstCoverUrl; + for (final entry in playlist.tracks) { + final coverUrl = entry.track.coverUrl; + if (coverUrl != null && coverUrl.isNotEmpty) { + firstCoverUrl = coverUrl; + break; + } + } if (firstCoverUrl != null) { + final isLocalPath = + !firstCoverUrl.startsWith('http://') && + !firstCoverUrl.startsWith('https://'); + + if (isLocalPath) { + return ClipRRect( + borderRadius: borderRadius, + child: Image.file( + File(firstCoverUrl), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size), + ), + ); + } + return ClipRRect( borderRadius: borderRadius, child: CachedNetworkImage( @@ -304,6 +314,8 @@ class LibraryPlaylistsScreen extends ConsumerWidget { width: size, height: size, fit: BoxFit.cover, + memCacheWidth: (size * 2).toInt(), + cacheManager: CoverCacheManager.instance, placeholder: (_, _) => _playlistIconFallback(colorScheme, size), errorWidget: (_, _, _) => _playlistIconFallback(colorScheme, size), ), @@ -321,10 +333,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), - child: Icon( - Icons.queue_music, - color: colorScheme.onSurfaceVariant, - ), + child: Icon(Icons.queue_music, color: colorScheme.onSurfaceVariant), ); } diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index e606460f..8968c851 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -9,7 +9,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; -import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; @@ -104,19 +104,34 @@ class _LibraryTracksFolderScreenState @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final state = ref.watch(libraryCollectionsProvider); - final playlist = - widget.mode == LibraryTracksFolderMode.playlist && - widget.playlistId != null - ? state.playlistById(widget.playlistId!) - : null; + final UserPlaylistCollection? playlist; + final List entries; - final entries = switch (widget.mode) { - LibraryTracksFolderMode.wishlist => state.wishlist, - LibraryTracksFolderMode.loved => state.loved, - LibraryTracksFolderMode.playlist => - playlist?.tracks ?? const [], - }; + switch (widget.mode) { + case LibraryTracksFolderMode.wishlist: + playlist = null; + entries = ref.watch( + libraryCollectionsProvider.select((state) => state.wishlist), + ); + break; + case LibraryTracksFolderMode.loved: + playlist = null; + entries = ref.watch( + libraryCollectionsProvider.select((state) => state.loved), + ); + break; + case LibraryTracksFolderMode.playlist: + final playlistId = widget.playlistId; + playlist = playlistId == null + ? null + : ref.watch( + libraryCollectionsProvider.select( + (state) => state.playlistById(playlistId), + ), + ); + entries = playlist?.tracks ?? const []; + break; + } final title = switch (widget.mode) { LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist, @@ -157,10 +172,11 @@ class _LibraryTracksFolderScreenState ) else SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final entry = entries[index]; - return Column( + delegate: SliverChildBuilderDelegate((context, index) { + final entry = entries[index]; + return KeyedSubtree( + key: ValueKey(entry.key), + child: Column( mainAxisSize: MainAxisSize.min, children: [ _CollectionTrackTile( @@ -168,13 +184,11 @@ class _LibraryTracksFolderScreenState mode: widget.mode, playlistId: widget.playlistId, ), - if (index < entries.length - 1) - const Divider(height: 1), + if (index < entries.length - 1) const Divider(height: 1), ], - ); - }, - childCount: entries.length, - ), + ), + ); + }, childCount: entries.length), ), const SliverToBoxAdapter(child: SizedBox(height: 32)), ], @@ -299,8 +313,7 @@ class _LibraryTracksFolderScreenState Container(color: colorScheme.surface), ) : CachedNetworkImage( - imageUrl: - _highResCoverUrl(coverUrl) ?? coverUrl, + imageUrl: _highResCoverUrl(coverUrl) ?? coverUrl, fit: BoxFit.cover, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => @@ -541,9 +554,7 @@ class _CollectionTrackTile extends ConsumerWidget { ), onTap: mode == LibraryTracksFolderMode.wishlist ? () => _downloadTrack(context, ref) - : mode == LibraryTracksFolderMode.playlist - ? () => _openInMusicPlayer(context, ref) - : null, + : () => _navigateToMetadata(context, ref), onLongPress: () => _showTrackOptionsSheet(context, ref), ); } @@ -613,8 +624,7 @@ class _CollectionTrackTile extends ConsumerWidget { width: 40, height: 4, decoration: BoxDecoration( - color: - colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2), ), ), @@ -624,8 +634,8 @@ class _CollectionTrackTile extends ConsumerWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(8), - child: track.coverUrl != null && - track.coverUrl!.isNotEmpty + child: + track.coverUrl != null && track.coverUrl!.isNotEmpty ? _buildTrackCover(context, track.coverUrl!, 56) : Container( width: 56, @@ -644,9 +654,7 @@ class _CollectionTrackTile extends ConsumerWidget { children: [ Text( track.name, - style: Theme.of(context) - .textTheme - .titleMedium + style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.w600), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -654,9 +662,7 @@ class _CollectionTrackTile extends ConsumerWidget { const SizedBox(height: 2), Text( track.artistName, - style: Theme.of(context) - .textTheme - .bodyMedium + style: Theme.of(context).textTheme.bodyMedium ?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -762,14 +768,12 @@ class _CollectionTrackTile extends ConsumerWidget { .read(downloadQueueProvider.notifier) .addToQueue(track, settings.defaultService); ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAddedToQueue(track.name)), - ), + SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), ); } } - Future _openInMusicPlayer(BuildContext context, WidgetRef ref) async { + Future _navigateToMetadata(BuildContext context, WidgetRef ref) async { final track = entry.track; final historyItem = ref .read(downloadHistoryProvider.notifier) @@ -777,29 +781,16 @@ class _CollectionTrackTile extends ConsumerWidget { if (historyItem == null) return; - final exists = await fileExists(historyItem.filePath); - if (!exists) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarCannotOpenFile('File not found'), - ), - ), - ); - return; - } - - try { - await openFile(historyItem.filePath); - } catch (e) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarCannotOpenFile(e.toString())), - ), - ); - } + await Navigator.of(context).push( + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => + TrackMetadataScreen(item: historyItem), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), + ), + ); } } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index f26a8b2f..66efe2b6 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -28,10 +28,8 @@ import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; -/// Represents the source of a library item enum LibraryItemSource { downloaded, local } -/// Unified library item that can come from download history or local library class UnifiedLibraryItem { final String id; final String trackName; @@ -107,7 +105,6 @@ class UnifiedLibraryItem { ); } - /// Returns true if this item has a cover (either URL or local path) bool get hasCover => coverUrl != null || (localCoverPath != null && localCoverPath!.isNotEmpty); @@ -209,7 +206,6 @@ class _GroupedAlbum { String get key => '$albumName|$artistName'; } -/// Grouped album from local library class _GroupedLocalAlbum { final String albumName; final String artistName; @@ -251,10 +247,8 @@ class _HistoryStats { 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; } @@ -852,7 +846,6 @@ class _QueueTabState extends ConsumerState { ); } - /// Bottom action bar for playlist selection mode. Widget _buildPlaylistSelectionBottomBar( BuildContext context, ColorScheme colorScheme, @@ -1252,7 +1245,6 @@ class _QueueTabState extends ConsumerState { return _applySorting(filtered); } - /// Apply current sort mode to a list of unified items List _applySorting(List items) { if (_sortMode == 'latest') { return items; // Already sorted newest first from _getUnifiedItems @@ -1275,7 +1267,6 @@ class _QueueTabState extends ConsumerState { return sorted; } - /// Check if a quality string passes the current quality filter bool _passesQualityFilter(String? quality) { if (_filterQuality == null) return true; if (quality == null) return _filterQuality == 'lossy'; @@ -1292,13 +1283,11 @@ class _QueueTabState extends ConsumerState { } } - /// Check if a file path passes the current format filter bool _passesFormatFilter(String filePath) { if (_filterFormat == null) return true; return _fileExtLower(filePath) == _filterFormat; } - /// Filter grouped download albums by search query + advanced filters List<_GroupedAlbum> _filterGroupedAlbums( List<_GroupedAlbum> albums, String searchQuery, @@ -1355,7 +1344,6 @@ class _QueueTabState extends ConsumerState { return result; } - /// Filter grouped local albums by search query + advanced filters List<_GroupedLocalAlbum> _filterGroupedLocalAlbums( List<_GroupedLocalAlbum> albums, String searchQuery, @@ -2085,8 +2073,7 @@ class _QueueTabState extends ConsumerState { width: size, height: size, fit: BoxFit.cover, - errorBuilder: (_, _, _) => - _playlistIconFallback(colorScheme, size), + errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size), ), ); } @@ -2098,7 +2085,8 @@ class _QueueTabState extends ConsumerState { if (firstCoverUrl != null) { // Guard against local file paths that may have been stored as coverUrl - final isLocalPath = !firstCoverUrl.startsWith('http://') && + final isLocalPath = + !firstCoverUrl.startsWith('http://') && !firstCoverUrl.startsWith('https://'); if (isLocalPath) { return ClipRRect( @@ -2108,8 +2096,7 @@ class _QueueTabState extends ConsumerState { width: size, height: size, fit: BoxFit.cover, - errorBuilder: (_, _, _) => - _playlistIconFallback(colorScheme, size), + errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size), ), ); } @@ -2120,10 +2107,8 @@ class _QueueTabState extends ConsumerState { width: size, height: size, fit: BoxFit.cover, - placeholder: (_, _) => - _playlistIconFallback(colorScheme, size), - errorWidget: (_, _, _) => - _playlistIconFallback(colorScheme, size), + placeholder: (_, _) => _playlistIconFallback(colorScheme, size), + errorWidget: (_, _, _) => _playlistIconFallback(colorScheme, size), ), ); } @@ -2174,26 +2159,21 @@ class _QueueTabState extends ConsumerState { selectedItems.add(item); } - int addedCount = 0; - int alreadyCount = 0; - for (final selected in selectedItems) { - final track = selected.toTrack(); - final added = await notifier.addTrackToPlaylist(playlistId, track); - if (added) { - addedCount++; - } else { - alreadyCount++; - } - } + final batchResult = await notifier.addTracksToPlaylist( + playlistId, + selectedItems.map((selected) => selected.toTrack()), + ); + final addedCount = batchResult.addedCount; + final alreadyCount = batchResult.alreadyInPlaylistCount; if (!context.mounted) return; final message = addedCount > 0 ? 'Added $addedCount ${addedCount == 1 ? 'track' : 'tracks'} to $playlistName' - '${alreadyCount > 0 ? ' ($alreadyCount already in playlist)' : ''}' + '${alreadyCount > 0 ? ' ($alreadyCount already in playlist)' : ''}' : context.l10n.collectionAlreadyInPlaylist(playlistName); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); _exitSelectionMode(); return; } @@ -2221,7 +2201,8 @@ class _QueueTabState extends ConsumerState { UnifiedLibraryItem item, ColorScheme colorScheme, ) { - final isDraggingMultiple = _isSelectionMode && + final isDraggingMultiple = + _isSelectionMode && _selectedIds.contains(item.id) && _selectedIds.length > 1; final count = isDraggingMultiple ? _selectedIds.length : 1; @@ -2240,14 +2221,12 @@ class _QueueTabState extends ConsumerState { ConstrainedBox( constraints: const BoxConstraints(maxWidth: 180), child: Text( - isDraggingMultiple - ? '$count tracks' - : item.trackName, + isDraggingMultiple ? '$count tracks' : item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), ), ), ], @@ -2859,7 +2838,8 @@ class _QueueTabState extends ConsumerState { required VoidCallback onTap, VoidCallback? onLongPress, }) { - final cover = coverWidget ?? + final cover = + coverWidget ?? Container( width: 56, height: 56, @@ -2867,7 +2847,11 @@ class _QueueTabState extends ConsumerState { color: iconBgColor ?? colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), - child: Icon(icon ?? Icons.folder, color: iconColor ?? Colors.white, size: 28), + child: Icon( + icon ?? Icons.folder, + color: iconColor ?? Colors.white, + size: 28, + ), ); return InkWell( @@ -2922,13 +2906,18 @@ class _QueueTabState extends ConsumerState { required VoidCallback onTap, VoidCallback? onLongPress, }) { - final cover = coverWidget ?? + final cover = + coverWidget ?? Container( decoration: BoxDecoration( color: iconBgColor ?? colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), - child: Icon(icon ?? Icons.folder, color: iconColor ?? Colors.white, size: 40), + child: Icon( + icon ?? Icons.folder, + color: iconColor ?? Colors.white, + size: 40, + ), ); return GestureDetector( @@ -2949,9 +2938,9 @@ class _QueueTabState extends ConsumerState { title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w500, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), ), Text( '$count ${count == 1 ? 'item' : 'items'}', @@ -3018,22 +3007,10 @@ class _QueueTabState extends ConsumerState { decoration: isHovering ? BoxDecoration( borderRadius: BorderRadius.circular(12), - border: Border.all( - color: colorScheme.primary, - width: 2, - ), + border: Border.all(color: colorScheme.primary, width: 2), color: colorScheme.primary.withValues(alpha: 0.1), ) - : isSelected - ? BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: colorScheme.primary, - width: 2, - ), - color: colorScheme.primary.withValues(alpha: 0.08), - ) - : null, + : null, child: Stack( children: [ _buildCollectionGridItem( @@ -3067,8 +3044,11 @@ class _QueueTabState extends ConsumerState { ), ), child: isSelected - ? Icon(Icons.check, size: 16, - color: colorScheme.onPrimary) + ? Icon( + Icons.check, + size: 16, + color: colorScheme.onPrimary, + ) : const SizedBox(width: 16, height: 16), ), ), @@ -3134,22 +3114,10 @@ class _QueueTabState extends ConsumerState { decoration: isHovering ? BoxDecoration( borderRadius: BorderRadius.circular(12), - border: Border.all( - color: colorScheme.primary, - width: 2, - ), + border: Border.all(color: colorScheme.primary, width: 2), color: colorScheme.primary.withValues(alpha: 0.1), ) - : isSelected - ? BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: colorScheme.primary, - width: 2, - ), - color: colorScheme.primary.withValues(alpha: 0.08), - ) - : null, + : null, child: Row( children: [ if (_isPlaylistSelectionMode) @@ -3169,8 +3137,11 @@ class _QueueTabState extends ConsumerState { ), ), child: isSelected - ? Icon(Icons.check, size: 18, - color: colorScheme.onPrimary) + ? Icon( + Icons.check, + size: 18, + color: colorScheme.onPrimary, + ) : const SizedBox(width: 18, height: 18), ), ), @@ -3268,7 +3239,6 @@ class _QueueTabState extends ConsumerState { // Collection folders as list items (Spotify-style) in "All" tab // are now rendered inline with tracks below (unified sliver) - if ((filteredGroupedAlbums.isNotEmpty || filteredGroupedLocalAlbums.isNotEmpty) && filterMode == 'albums') @@ -3422,18 +3392,69 @@ class _QueueTabState extends ConsumerState { 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 collectionCount = - 2 + collectionState.playlists.length; + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 0.75, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final collectionCount = + 2 + collectionState.playlists.length; + if (index < collectionCount) { + return _buildAllTabGridCollectionItem( + context: context, + colorScheme: colorScheme, + index: index, + collectionState: collectionState, + filteredUnifiedItems: filteredUnifiedItems, + ); + } + final trackIndex = index - collectionCount; + if (trackIndex < filteredUnifiedItems.length) { + final item = filteredUnifiedItems[trackIndex]; + return KeyedSubtree( + key: ValueKey(item.id), + child: LongPressDraggable( + data: item, + feedback: _buildDragFeedback( + context, + item, + colorScheme, + ), + childWhenDragging: Opacity( + opacity: 0.4, + child: _buildUnifiedGridItem( + context, + item, + colorScheme, + ), + ), + child: _buildUnifiedGridItem( + context, + item, + colorScheme, + ), + ), + ); + } + return const SizedBox.shrink(); + }, + childCount: + 2 + + collectionState.playlists.length + + filteredUnifiedItems.length, + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final collectionCount = 2 + collectionState.playlists.length; if (index < collectionCount) { - return _buildAllTabGridCollectionItem( + return _buildAllTabListCollectionItem( context: context, colorScheme: colorScheme, index: index, @@ -3455,13 +3476,13 @@ class _QueueTabState extends ConsumerState { ), childWhenDragging: Opacity( opacity: 0.4, - child: _buildUnifiedGridItem( + child: _buildUnifiedLibraryItem( context, item, colorScheme, ), ), - child: _buildUnifiedGridItem( + child: _buildUnifiedLibraryItem( context, item, colorScheme, @@ -3475,57 +3496,6 @@ class _QueueTabState extends ConsumerState { 2 + collectionState.playlists.length + filteredUnifiedItems.length, - ), - ), - ) - else - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final collectionCount = - 2 + collectionState.playlists.length; - if (index < collectionCount) { - return _buildAllTabListCollectionItem( - context: context, - colorScheme: colorScheme, - index: index, - collectionState: collectionState, - filteredUnifiedItems: filteredUnifiedItems, - ); - } - final trackIndex = index - collectionCount; - if (trackIndex < filteredUnifiedItems.length) { - final item = filteredUnifiedItems[trackIndex]; - return KeyedSubtree( - key: ValueKey(item.id), - child: LongPressDraggable( - data: item, - feedback: _buildDragFeedback( - context, - item, - colorScheme, - ), - childWhenDragging: Opacity( - opacity: 0.4, - child: _buildUnifiedLibraryItem( - context, - item, - colorScheme, - ), - ), - child: _buildUnifiedLibraryItem( - context, - item, - colorScheme, - ), - ), - ); - } - return const SizedBox.shrink(); - }, - childCount: - 2 + - collectionState.playlists.length + - filteredUnifiedItems.length, ), ), ], @@ -5521,9 +5491,8 @@ class _QueueTabState extends ConsumerState { overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall ?.copyWith( - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.7, - ), + color: colorScheme.onSurfaceVariant + .withValues(alpha: 0.7), ), ), ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 29bf2b21..e5c70e8a 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -51,6 +51,7 @@ class _TrackMetadataScreenState extends ConsumerState { _embeddedCoverPreviewCache = {}; bool _fileExists = false; + bool _hasCheckedFile = false; int? _fileSize; String? _lyrics; // Cleaned lyrics for display (no timestamps) String? _rawLyrics; // Raw LRC with timestamps for embedding @@ -232,10 +233,12 @@ class _TrackMetadataScreenState extends ConsumerState { } } catch (_) {} - if (mounted && (exists != _fileExists || size != _fileSize)) { + if (mounted && + (exists != _fileExists || size != _fileSize || !_hasCheckedFile)) { setState(() { _fileExists = exists; _fileSize = size; + _hasCheckedFile = true; }); } @@ -818,7 +821,7 @@ class _TrackMetadataScreenState extends ConsumerState { ], ), ), - if (!_fileExists) + if (_hasCheckedFile && !_fileExists) Container( padding: const EdgeInsets.symmetric( horizontal: 12, diff --git a/lib/services/app_state_database.dart b/lib/services/app_state_database.dart new file mode 100644 index 00000000..e5ca600c --- /dev/null +++ b/lib/services/app_state_database.dart @@ -0,0 +1,309 @@ +import 'dart:convert'; + +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('AppStateDb'); + +const _dbFileName = 'app_state.db'; +const _dbVersion = 1; + +const _queueTable = 'download_queue_items'; +const _recentTable = 'recent_access_items'; +const _hiddenRecentTable = 'hidden_recent_downloads'; + +const _legacyQueueKey = 'download_queue'; +const _legacyRecentAccessKey = 'recent_access_history'; +const _legacyHiddenDownloadsKey = 'hidden_downloads_in_recents'; + +const _queueMigrationKey = 'app_state_migrated_queue_to_sqlite_v1'; +const _recentMigrationKey = 'app_state_migrated_recent_to_sqlite_v1'; + +class AppStateDatabase { + static final AppStateDatabase instance = AppStateDatabase._init(); + static Database? _database; + + final Future _prefs = SharedPreferences.getInstance(); + + AppStateDatabase._init(); + + Future get database async { + if (_database != null) return _database!; + _database = await _initDb(); + return _database!; + } + + Future _initDb() async { + final dbPath = await getApplicationDocumentsDirectory(); + final path = join(dbPath.path, _dbFileName); + + _log.i('Initializing app state database at: $path'); + + return openDatabase( + path, + version: _dbVersion, + onCreate: _createDb, + onUpgrade: _upgradeDb, + ); + } + + Future _createDb(Database db, int version) async { + _log.i('Creating app state database schema v$version'); + + await db.execute(''' + CREATE TABLE $_queueTable ( + id TEXT PRIMARY KEY, + item_json TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + await db.execute( + 'CREATE INDEX idx_${_queueTable}_status ON $_queueTable(status)', + ); + await db.execute( + 'CREATE INDEX idx_${_queueTable}_created ON $_queueTable(created_at ASC)', + ); + + await db.execute(''' + CREATE TABLE $_recentTable ( + unique_key TEXT PRIMARY KEY, + item_json TEXT NOT NULL, + accessed_at TEXT NOT NULL + ) + '''); + await db.execute( + 'CREATE INDEX idx_${_recentTable}_accessed ON $_recentTable(accessed_at DESC)', + ); + + await db.execute(''' + CREATE TABLE $_hiddenRecentTable ( + download_id TEXT PRIMARY KEY, + updated_at TEXT NOT NULL + ) + '''); + } + + Future _upgradeDb(Database db, int oldVersion, int newVersion) async { + _log.i('Upgrading app state database from v$oldVersion to v$newVersion'); + } + + Future migrateQueueFromSharedPreferences() async { + final prefs = await _prefs; + if (prefs.getBool(_queueMigrationKey) == true) { + return false; + } + + final raw = prefs.getString(_legacyQueueKey); + if (raw == null || raw.isEmpty) { + await prefs.setBool(_queueMigrationKey, true); + return false; + } + + try { + final decoded = jsonDecode(raw); + if (decoded is! List) { + await prefs.setBool(_queueMigrationKey, true); + return false; + } + + final nowIso = DateTime.now().toIso8601String(); + final db = await database; + await db.transaction((txn) async { + final batch = txn.batch(); + for (final entry in decoded.whereType()) { + final map = Map.from(entry); + final id = map['id'] as String?; + if (id == null || id.isEmpty) continue; + + final status = map['status'] as String? ?? 'queued'; + if (status != 'queued' && status != 'downloading') { + continue; + } + + if (status == 'downloading') { + map['status'] = 'queued'; + map['progress'] = 0.0; + map['speedMBps'] = 0.0; + map['bytesReceived'] = 0; + } + + final createdAt = map['createdAt'] as String? ?? nowIso; + batch.insert(_queueTable, { + 'id': id, + 'item_json': jsonEncode(map), + 'status': 'queued', + 'created_at': createdAt, + 'updated_at': nowIso, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + }); + + await prefs.setBool(_queueMigrationKey, true); + _log.i('Migrated legacy queue data to SQLite'); + return true; + } catch (e, stack) { + _log.e('Failed queue migration to SQLite: $e', e, stack); + return false; + } + } + + Future migrateRecentAccessFromSharedPreferences() async { + final prefs = await _prefs; + if (prefs.getBool(_recentMigrationKey) == true) { + return false; + } + + final rawRecent = prefs.getString(_legacyRecentAccessKey); + final hiddenIds = prefs.getStringList(_legacyHiddenDownloadsKey); + if ((rawRecent == null || rawRecent.isEmpty) && + (hiddenIds == null || hiddenIds.isEmpty)) { + await prefs.setBool(_recentMigrationKey, true); + return false; + } + + try { + final nowIso = DateTime.now().toIso8601String(); + final db = await database; + await db.transaction((txn) async { + if (rawRecent != null && rawRecent.isNotEmpty) { + final decoded = jsonDecode(rawRecent); + if (decoded is List) { + final batch = txn.batch(); + for (final entry in decoded.whereType()) { + final map = Map.from(entry); + final type = map['type'] as String?; + final id = map['id'] as String?; + final providerId = map['providerId'] as String?; + if (type == null || id == null || type.isEmpty || id.isEmpty) { + continue; + } + final uniqueKey = '$type:${providerId ?? 'default'}:$id'; + final accessedAt = map['accessedAt'] as String? ?? nowIso; + batch.insert(_recentTable, { + 'unique_key': uniqueKey, + 'item_json': jsonEncode(map), + 'accessed_at': accessedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + } + + if (hiddenIds != null && hiddenIds.isNotEmpty) { + final batch = txn.batch(); + for (final id in hiddenIds) { + if (id.isEmpty) continue; + batch.insert(_hiddenRecentTable, { + 'download_id': id, + 'updated_at': nowIso, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + }); + + await prefs.setBool(_recentMigrationKey, true); + _log.i('Migrated legacy recent-access data to SQLite'); + return true; + } catch (e, stack) { + _log.e('Failed recent-access migration to SQLite: $e', e, stack); + return false; + } + } + + Future>> getPendingDownloadQueueRows() async { + final db = await database; + return db.query( + _queueTable, + where: 'status = ? OR status = ?', + whereArgs: ['queued', 'downloading'], + orderBy: 'created_at ASC, rowid ASC', + ); + } + + Future replacePendingDownloadQueueRows( + List> rows, + ) async { + final db = await database; + await db.transaction((txn) async { + await txn.delete(_queueTable); + if (rows.isEmpty) return; + + final batch = txn.batch(); + for (final row in rows) { + batch.insert( + _queueTable, + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + }); + } + + Future>> getRecentAccessRows({int? limit}) async { + final db = await database; + return db.query( + _recentTable, + orderBy: 'accessed_at DESC, rowid DESC', + limit: limit, + ); + } + + Future upsertRecentAccessRow({ + required String uniqueKey, + required String itemJson, + required String accessedAt, + }) async { + final db = await database; + await db.insert(_recentTable, { + 'unique_key': uniqueKey, + 'item_json': itemJson, + 'accessed_at': accessedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future deleteRecentAccessRow(String uniqueKey) async { + final db = await database; + await db.delete( + _recentTable, + where: 'unique_key = ?', + whereArgs: [uniqueKey], + ); + } + + Future clearRecentAccessRows() async { + final db = await database; + await db.delete(_recentTable); + } + + Future> getHiddenRecentDownloadIds() async { + final db = await database; + final rows = await db.query(_hiddenRecentTable, columns: ['download_id']); + return rows + .map((row) => row['download_id'] as String?) + .whereType() + .toSet(); + } + + Future addHiddenRecentDownloadId(String downloadId) async { + final id = downloadId.trim(); + if (id.isEmpty) return; + final db = await database; + await db.insert(_hiddenRecentTable, { + 'download_id': id, + 'updated_at': DateTime.now().toIso8601String(), + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future clearHiddenRecentDownloadIds() async { + final db = await database; + await db.delete(_hiddenRecentTable); + } +} diff --git a/lib/services/library_collections_database.dart b/lib/services/library_collections_database.dart new file mode 100644 index 00000000..ae1b3fdf --- /dev/null +++ b/lib/services/library_collections_database.dart @@ -0,0 +1,411 @@ +import 'dart:convert'; + +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('LibraryCollectionsDb'); + +const _dbFileName = 'library_collections.db'; +const _dbVersion = 1; + +const _tableWishlist = 'wishlist_tracks'; +const _tableLoved = 'loved_tracks'; +const _tablePlaylists = 'playlists'; +const _tablePlaylistTracks = 'playlist_tracks'; + +const _legacyCollectionsStorageKey = 'library_collections_v1'; +const _migrationDoneKey = 'library_collections_migrated_to_sqlite_v1'; + +class LibraryCollectionsSnapshot { + final List> wishlistRows; + final List> lovedRows; + final List> playlistRows; + final List> playlistTrackRows; + + const LibraryCollectionsSnapshot({ + required this.wishlistRows, + required this.lovedRows, + required this.playlistRows, + required this.playlistTrackRows, + }); +} + +class LibraryCollectionsDatabase { + static final LibraryCollectionsDatabase instance = + LibraryCollectionsDatabase._init(); + static Database? _database; + + final Future _prefs = SharedPreferences.getInstance(); + + LibraryCollectionsDatabase._init(); + + Future get database async { + if (_database != null) return _database!; + _database = await _initDb(); + return _database!; + } + + Future _initDb() async { + final dbPath = await getApplicationDocumentsDirectory(); + final path = join(dbPath.path, _dbFileName); + + _log.i('Initializing collections database at: $path'); + + return openDatabase( + path, + version: _dbVersion, + onConfigure: (db) async { + await db.execute('PRAGMA foreign_keys = ON'); + }, + onCreate: _createDb, + onUpgrade: _upgradeDb, + ); + } + + Future _createDb(Database db, int version) async { + _log.i('Creating collections database schema v$version'); + + await db.execute(''' + CREATE TABLE $_tableWishlist ( + track_key TEXT PRIMARY KEY, + track_json TEXT NOT NULL, + added_at TEXT NOT NULL + ) + '''); + + await db.execute(''' + CREATE TABLE $_tableLoved ( + track_key TEXT PRIMARY KEY, + track_json TEXT NOT NULL, + added_at TEXT NOT NULL + ) + '''); + + await db.execute(''' + CREATE TABLE $_tablePlaylists ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + cover_image_path TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + + await db.execute(''' + CREATE TABLE $_tablePlaylistTracks ( + playlist_id TEXT NOT NULL, + track_key TEXT NOT NULL, + track_json TEXT NOT NULL, + added_at TEXT NOT NULL, + PRIMARY KEY (playlist_id, track_key), + FOREIGN KEY (playlist_id) REFERENCES $_tablePlaylists(id) ON DELETE CASCADE + ) + '''); + + await db.execute( + 'CREATE INDEX idx_${_tableWishlist}_added_at ON $_tableWishlist(added_at DESC)', + ); + await db.execute( + 'CREATE INDEX idx_${_tableLoved}_added_at ON $_tableLoved(added_at DESC)', + ); + await db.execute( + 'CREATE INDEX idx_${_tablePlaylists}_created_at ON $_tablePlaylists(created_at DESC)', + ); + await db.execute( + 'CREATE INDEX idx_${_tablePlaylistTracks}_playlist_id ON $_tablePlaylistTracks(playlist_id)', + ); + await db.execute( + 'CREATE INDEX idx_${_tablePlaylistTracks}_added_at ON $_tablePlaylistTracks(added_at DESC)', + ); + } + + Future _upgradeDb(Database db, int oldVersion, int newVersion) async { + _log.i('Upgrading collections database from v$oldVersion to v$newVersion'); + } + + Future migrateFromSharedPreferences() async { + final prefs = await _prefs; + if (prefs.getBool(_migrationDoneKey) == true) { + return false; + } + + final raw = prefs.getString(_legacyCollectionsStorageKey); + if (raw == null || raw.isEmpty) { + await prefs.setBool(_migrationDoneKey, true); + return false; + } + + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + await prefs.setBool(_migrationDoneKey, true); + return false; + } + + final root = Map.from(decoded); + final wishlistRaw = (root['wishlist'] as List?) ?? const []; + final lovedRaw = (root['loved'] as List?) ?? const []; + final playlistsRaw = (root['playlists'] as List?) ?? const []; + final nowIso = DateTime.now().toIso8601String(); + + final db = await database; + await db.transaction((txn) async { + for (final entry in wishlistRaw.whereType()) { + final map = Map.from(entry); + final trackKey = map['key'] as String?; + final track = map['track']; + if (trackKey == null || track is! Map) continue; + final addedAt = (map['addedAt'] as String?) ?? nowIso; + await txn.insert(_tableWishlist, { + 'track_key': trackKey, + 'track_json': jsonEncode(track), + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + for (final entry in lovedRaw.whereType()) { + final map = Map.from(entry); + final trackKey = map['key'] as String?; + final track = map['track']; + if (trackKey == null || track is! Map) continue; + final addedAt = (map['addedAt'] as String?) ?? nowIso; + await txn.insert(_tableLoved, { + 'track_key': trackKey, + 'track_json': jsonEncode(track), + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + for (final playlistEntry in playlistsRaw.whereType()) { + final playlist = Map.from(playlistEntry); + final playlistId = playlist['id'] as String?; + if (playlistId == null || playlistId.isEmpty) continue; + + final createdAt = (playlist['createdAt'] as String?) ?? nowIso; + final updatedAt = (playlist['updatedAt'] as String?) ?? createdAt; + await txn.insert(_tablePlaylists, { + 'id': playlistId, + 'name': (playlist['name'] as String?) ?? '', + 'cover_image_path': playlist['coverImagePath'] as String?, + 'created_at': createdAt, + 'updated_at': updatedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + + final tracksRaw = (playlist['tracks'] as List?) ?? const []; + for (final trackEntry in tracksRaw.whereType()) { + final trackMap = Map.from(trackEntry); + final trackKey = trackMap['key'] as String?; + final track = trackMap['track']; + if (trackKey == null || track is! Map) continue; + final addedAt = (trackMap['addedAt'] as String?) ?? nowIso; + await txn.insert(_tablePlaylistTracks, { + 'playlist_id': playlistId, + 'track_key': trackKey, + 'track_json': jsonEncode(track), + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + } + }); + + await prefs.setBool(_migrationDoneKey, true); + _log.i('Migrated legacy collections data to SQLite'); + return true; + } catch (e, stack) { + _log.e('Failed migrating collections to SQLite: $e', e, stack); + return false; + } + } + + Future loadSnapshot() async { + final db = await database; + final wishlistRows = await db.query( + _tableWishlist, + orderBy: 'added_at DESC, rowid DESC', + ); + final lovedRows = await db.query( + _tableLoved, + orderBy: 'added_at DESC, rowid DESC', + ); + final playlistRows = await db.query( + _tablePlaylists, + orderBy: 'created_at DESC, rowid DESC', + ); + final playlistTrackRows = await db.query( + _tablePlaylistTracks, + orderBy: 'playlist_id ASC, added_at DESC, rowid DESC', + ); + + return LibraryCollectionsSnapshot( + wishlistRows: wishlistRows, + lovedRows: lovedRows, + playlistRows: playlistRows, + playlistTrackRows: playlistTrackRows, + ); + } + + Future upsertWishlistEntry({ + required String trackKey, + required String trackJson, + required String addedAt, + }) async { + final db = await database; + await db.insert(_tableWishlist, { + 'track_key': trackKey, + 'track_json': trackJson, + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future deleteWishlistEntry(String trackKey) async { + final db = await database; + await db.delete( + _tableWishlist, + where: 'track_key = ?', + whereArgs: [trackKey], + ); + } + + Future upsertLovedEntry({ + required String trackKey, + required String trackJson, + required String addedAt, + }) async { + final db = await database; + await db.insert(_tableLoved, { + 'track_key': trackKey, + 'track_json': trackJson, + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future deleteLovedEntry(String trackKey) async { + final db = await database; + await db.delete(_tableLoved, where: 'track_key = ?', whereArgs: [trackKey]); + } + + Future upsertPlaylist({ + required String id, + required String name, + required String createdAt, + required String updatedAt, + String? coverImagePath, + }) async { + final db = await database; + await db.insert(_tablePlaylists, { + 'id': id, + 'name': name, + 'cover_image_path': coverImagePath, + 'created_at': createdAt, + 'updated_at': updatedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future renamePlaylist({ + required String playlistId, + required String name, + required String updatedAt, + }) async { + final db = await database; + await db.update( + _tablePlaylists, + {'name': name, 'updated_at': updatedAt}, + where: 'id = ?', + whereArgs: [playlistId], + ); + } + + Future updatePlaylistCover({ + required String playlistId, + required String updatedAt, + String? coverImagePath, + }) async { + final db = await database; + await db.update( + _tablePlaylists, + {'cover_image_path': coverImagePath, 'updated_at': updatedAt}, + where: 'id = ?', + whereArgs: [playlistId], + ); + } + + Future deletePlaylist(String playlistId) async { + final db = await database; + await db.delete(_tablePlaylists, where: 'id = ?', whereArgs: [playlistId]); + } + + Future upsertPlaylistTrack({ + required String playlistId, + required String trackKey, + required String trackJson, + required String addedAt, + required String playlistUpdatedAt, + }) async { + final db = await database; + await db.transaction((txn) async { + await txn.insert(_tablePlaylistTracks, { + 'playlist_id': playlistId, + 'track_key': trackKey, + 'track_json': trackJson, + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + await txn.update( + _tablePlaylists, + {'updated_at': playlistUpdatedAt}, + where: 'id = ?', + whereArgs: [playlistId], + ); + }); + } + + Future upsertPlaylistTracksBatch({ + required String playlistId, + required String playlistUpdatedAt, + required List> tracks, + }) async { + if (tracks.isEmpty) return; + final db = await database; + await db.transaction((txn) async { + final batch = txn.batch(); + for (final track in tracks) { + batch.insert(_tablePlaylistTracks, { + 'playlist_id': playlistId, + 'track_key': track['track_key'], + 'track_json': track['track_json'], + 'added_at': track['added_at'], + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + batch.update( + _tablePlaylists, + {'updated_at': playlistUpdatedAt}, + where: 'id = ?', + whereArgs: [playlistId], + ); + await batch.commit(noResult: true); + }); + } + + Future deletePlaylistTrack({ + required String playlistId, + required String trackKey, + required String playlistUpdatedAt, + }) async { + final db = await database; + await db.transaction((txn) async { + await txn.delete( + _tablePlaylistTracks, + where: 'playlist_id = ? AND track_key = ?', + whereArgs: [playlistId, trackKey], + ); + await txn.update( + _tablePlaylists, + {'updated_at': playlistUpdatedAt}, + where: 'id = ?', + whereArgs: [playlistId], + ); + }); + } +} diff --git a/lib/widgets/playlist_picker_sheet.dart b/lib/widgets/playlist_picker_sheet.dart index 7eb7ee42..dc9b5256 100644 --- a/lib/widgets/playlist_picker_sheet.dart +++ b/lib/widgets/playlist_picker_sheet.dart @@ -18,7 +18,9 @@ Future showAddTrackToPlaylistSheet( context: context, showDragHandle: true, builder: (sheetContext) { - final playlists = ref.watch(libraryCollectionsProvider).playlists; + final playlists = ref.watch( + libraryCollectionsProvider.select((state) => state.playlists), + ); return SafeArea( child: Column( mainAxisSize: MainAxisSize.min,