refactor: migrate persistence to SQLite, add strict provider mode, and optimize collection lookups

- Replace SharedPreferences with SQLite (AppStateDatabase, LibraryCollectionsDatabase) for download queue, library collections, and recent access history
- Add Set-based O(1) track containment checks for wishlist, loved, and playlist tracks
- Add batch addTracksToPlaylist with PlaylistAddBatchResult
- Go backend: strict mode locks download to selected provider when auto fallback is off
- Go backend: fix extension progress normalization (percent/100) and lifecycle tracking
- Go backend: case-insensitive provider ID matching throughout fallback chain
- Lyrics embedding now respects lyricsMode setting (embed/both/off)
- Debounced queue persistence to reduce write frequency
- Fix shouldUseFallback logic to not be gated by useExtensions
This commit is contained in:
zarzet
2026-02-19 16:40:03 +07:00
parent 8e794e1ef1
commit e39756fa3f
15 changed files with 1581 additions and 624 deletions
+7 -25
View File
@@ -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)
+64 -15
View File
@@ -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{
-1
View File
@@ -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 == "" {
-2
View File
@@ -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)
}
-16
View File
@@ -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 {
+96 -57
View File
@@ -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<DownloadQueueState> {
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<SharedPreferences> _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<DownloadQueueState> {
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<DownloadQueueState> {
_isLoaded = true;
try {
final prefs = await _prefs;
final jsonStr = prefs.getString(_queueStorageKey);
if (jsonStr != null && jsonStr.isNotEmpty) {
final List<dynamic> jsonList = jsonDecode(jsonStr);
final items = jsonList
.map((e) => DownloadItem.fromJson(e as Map<String, dynamic>))
.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 = <DownloadItem>[];
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<String, dynamic>.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<void> _saveQueueToStorage() async {
try {
final prefs = await _prefs;
void _saveQueueToStorage() {
_queuePersistDebounce?.cancel();
_queuePersistDebounce = Timer(_queuePersistDebounceDuration, () {
_flushQueueToStorage();
});
}
Future<void> _flushQueueToStorage() async {
try {
final pendingItems = state.items
.where(
(item) =>
@@ -841,11 +859,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
.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) => <String, dynamic>{
'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<DownloadQueueState> {
_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<DownloadQueueState> {
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');
+346 -143
View File
@@ -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<CollectionTrackEntry> tracks;
final Set<String> _trackKeys;
const UserPlaylistCollection({
UserPlaylistCollection({
required this.id,
required this.name,
this.coverImagePath,
required this.createdAt,
required this.updatedAt,
required this.tracks,
});
Set<String>? trackKeys,
}) : _trackKeys = trackKeys ?? tracks.map((entry) => entry.key).toSet();
UserPlaylistCollection copyWith({
String? id,
@@ -72,20 +72,28 @@ class UserPlaylistCollection {
DateTime? updatedAt,
List<CollectionTrackEntry>? 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<String, dynamic> toJson() => {
@@ -124,13 +132,26 @@ class LibraryCollectionsState {
final List<CollectionTrackEntry> loved;
final List<UserPlaylistCollection> playlists;
final bool isLoaded;
final Set<String> _wishlistKeys;
final Set<String> _lovedKeys;
final Map<String, UserPlaylistCollection> _playlistsById;
const LibraryCollectionsState({
LibraryCollectionsState({
this.wishlist = const [],
this.loved = const [],
this.playlists = const [],
this.isLoaded = false,
});
Set<String>? wishlistKeys,
Set<String>? lovedKeys,
Map<String, UserPlaylistCollection>? 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<UserPlaylistCollection>? 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<LibraryCollectionsState> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final LibraryCollectionsDatabase _db = LibraryCollectionsDatabase.instance;
Future<void>? _loadFuture;
@override
LibraryCollectionsState build() {
_loadFuture = _load();
return const LibraryCollectionsState();
return LibraryCollectionsState();
}
Future<void> _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<String, dynamic>) {
state = LibraryCollectionsState.fromJson(parsed);
} else {
state = state.copyWith(isLoaded: true);
await _db.migrateFromSharedPreferences();
final snapshot = await _db.loadSnapshot();
final wishlist = <CollectionTrackEntry>[];
for (final row in snapshot.wishlistRows) {
final parsed = _parseTrackEntryRow(row);
if (parsed != null) {
wishlist.add(parsed);
}
}
final loved = <CollectionTrackEntry>[];
for (final row in snapshot.lovedRows) {
final parsed = _parseTrackEntryRow(row);
if (parsed != null) {
loved.add(parsed);
}
}
final tracksByPlaylist = <String, List<CollectionTrackEntry>>{};
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 = <UserPlaylistCollection>[];
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 <CollectionTrackEntry>[],
),
);
}
state = LibraryCollectionsState(
wishlist: wishlist,
loved: loved,
playlists: playlists,
isLoaded: true,
);
} catch (_) {
state = state.copyWith(isLoaded: true);
}
}
Future<void> _save() async {
final prefs = await _prefs;
await prefs.setString(_collectionsStorageKey, jsonEncode(state.toJson()));
}
Future<void> _ensureLoaded() async {
if (state.isLoaded) return;
await (_loadFuture ?? _load());
}
CollectionTrackEntry? _parseTrackEntryRow(Map<String, dynamic> 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<String, dynamic>.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<bool> 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<LibraryCollectionsState> {
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<bool> 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<LibraryCollectionsState> {
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<void> 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<void> 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<String> createPlaylist(String name) async {
@@ -324,8 +465,14 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
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<LibraryCollectionsState> {
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<void> 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<bool> 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<PlaylistAddBatchResult> addTracksToPlaylist(
String playlistId,
Iterable<Track> 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 = <String>{...playlist._trackKeys};
final entriesToAdd = <CollectionTrackEntry>[];
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) => <String, String>{
'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<void> 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<Directory> _playlistCoversDir() async {
final appDir = await getApplicationSupportDirectory();
final dir = Directory(p.join(appDir.path, 'playlist_covers'));
@@ -426,41 +632,38 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
return dir;
}
/// Sets a custom cover image for a playlist by copying the source file
/// into the app's persistent storage.
Future<void> 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<void> 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<LibraryCollectionsState> {
}
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);
});
}
}
+98 -89
View File
@@ -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<RecentAccessState> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final AppStateDatabase _appStateDb = AppStateDatabase.instance;
@override
RecentAccessState build() {
@@ -109,40 +103,36 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
}
Future<void> _loadHistory() async {
final prefs = await _prefs;
final json = prefs.getString(_recentAccessKey);
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
List<RecentAccessItem> items = [];
Set<String> hiddenIds = {};
if (json != null) {
try {
final List<dynamic> decoded = jsonDecode(json);
items = decoded
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
.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 = <RecentAccessItem>[];
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<String, dynamic>.from(decoded)),
);
} catch (_) {
continue;
}
}
}
if (hiddenJson != null) {
hiddenIds = hiddenJson.toSet();
}
state = state.copyWith(items: items, hiddenDownloadIds: hiddenIds, isLoaded: true);
}
Future<void> _saveHistory() async {
final prefs = await _prefs;
final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
await prefs.setString(_recentAccessKey, json);
}
Future<void> _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<RecentAccessState> {
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<RecentAccessState> {
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<RecentAccessState> {
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<RecentAccessState> {
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<RecentAccessState> {
.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<RecentAccessState> {
/// 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, RecentAccessState>(
RecentAccessNotifier.new,
);
final recentAccessProvider =
NotifierProvider<RecentAccessNotifier, RecentAccessState>(
RecentAccessNotifier.new,
);
+67 -58
View File
@@ -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),
);
}
+56 -65
View File
@@ -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<CollectionTrackEntry> entries;
final entries = switch (widget.mode) {
LibraryTracksFolderMode.wishlist => state.wishlist,
LibraryTracksFolderMode.loved => state.loved,
LibraryTracksFolderMode.playlist =>
playlist?.tracks ?? const <CollectionTrackEntry>[],
};
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 <CollectionTrackEntry>[];
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<void> _openInMusicPlayer(BuildContext context, WidgetRef ref) async {
Future<void> _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),
),
);
}
}
+119 -150
View File
@@ -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<QueueTab> {
);
}
/// Bottom action bar for playlist selection mode.
Widget _buildPlaylistSelectionBottomBar(
BuildContext context,
ColorScheme colorScheme,
@@ -1252,7 +1245,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return _applySorting(filtered);
}
/// Apply current sort mode to a list of unified items
List<UnifiedLibraryItem> _applySorting(List<UnifiedLibraryItem> items) {
if (_sortMode == 'latest') {
return items; // Already sorted newest first from _getUnifiedItems
@@ -1275,7 +1267,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
}
}
/// 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<QueueTab> {
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<QueueTab> {
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, _, _) =>
_playlistIconFallback(colorScheme, size),
errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size),
),
);
}
@@ -2098,7 +2085,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, _, _) =>
_playlistIconFallback(colorScheme, size),
errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size),
),
);
}
@@ -2120,10 +2107,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
required VoidCallback onTap,
VoidCallback? onLongPress,
}) {
final cover = coverWidget ??
final cover =
coverWidget ??
Container(
width: 56,
height: 56,
@@ -2867,7 +2847,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
),
),
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<QueueTab> {
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<QueueTab> {
),
),
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<QueueTab> {
// 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<QueueTab> {
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<UnifiedLibraryItem>(
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<QueueTab> {
),
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<QueueTab> {
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<UnifiedLibraryItem>(
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<QueueTab> {
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.7,
),
color: colorScheme.onSurfaceVariant
.withValues(alpha: 0.7),
),
),
),
+5 -2
View File
@@ -51,6 +51,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_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<TrackMetadataScreen> {
}
} 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<TrackMetadataScreen> {
],
),
),
if (!_fileExists)
if (_hasCheckedFile && !_fileExists)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
+309
View File
@@ -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<SharedPreferences> _prefs = SharedPreferences.getInstance();
AppStateDatabase._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDb();
return _database!;
}
Future<Database> _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<void> _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<void> _upgradeDb(Database db, int oldVersion, int newVersion) async {
_log.i('Upgrading app state database from v$oldVersion to v$newVersion');
}
Future<bool> 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<Map>()) {
final map = Map<String, dynamic>.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<bool> 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<Map>()) {
final map = Map<String, dynamic>.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<List<Map<String, dynamic>>> getPendingDownloadQueueRows() async {
final db = await database;
return db.query(
_queueTable,
where: 'status = ? OR status = ?',
whereArgs: ['queued', 'downloading'],
orderBy: 'created_at ASC, rowid ASC',
);
}
Future<void> replacePendingDownloadQueueRows(
List<Map<String, dynamic>> 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<List<Map<String, dynamic>>> getRecentAccessRows({int? limit}) async {
final db = await database;
return db.query(
_recentTable,
orderBy: 'accessed_at DESC, rowid DESC',
limit: limit,
);
}
Future<void> 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<void> deleteRecentAccessRow(String uniqueKey) async {
final db = await database;
await db.delete(
_recentTable,
where: 'unique_key = ?',
whereArgs: [uniqueKey],
);
}
Future<void> clearRecentAccessRows() async {
final db = await database;
await db.delete(_recentTable);
}
Future<Set<String>> 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<String>()
.toSet();
}
Future<void> 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<void> clearHiddenRecentDownloadIds() async {
final db = await database;
await db.delete(_hiddenRecentTable);
}
}
@@ -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<Map<String, dynamic>> wishlistRows;
final List<Map<String, dynamic>> lovedRows;
final List<Map<String, dynamic>> playlistRows;
final List<Map<String, dynamic>> 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<SharedPreferences> _prefs = SharedPreferences.getInstance();
LibraryCollectionsDatabase._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDb();
return _database!;
}
Future<Database> _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<void> _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<void> _upgradeDb(Database db, int oldVersion, int newVersion) async {
_log.i('Upgrading collections database from v$oldVersion to v$newVersion');
}
Future<bool> 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<String, dynamic>.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<Map>()) {
final map = Map<String, dynamic>.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<Map>()) {
final map = Map<String, dynamic>.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<Map>()) {
final playlist = Map<String, dynamic>.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<Map>()) {
final trackMap = Map<String, dynamic>.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<LibraryCollectionsSnapshot> 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<void> 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<void> deleteWishlistEntry(String trackKey) async {
final db = await database;
await db.delete(
_tableWishlist,
where: 'track_key = ?',
whereArgs: [trackKey],
);
}
Future<void> 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<void> deleteLovedEntry(String trackKey) async {
final db = await database;
await db.delete(_tableLoved, where: 'track_key = ?', whereArgs: [trackKey]);
}
Future<void> 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<void> 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<void> 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<void> deletePlaylist(String playlistId) async {
final db = await database;
await db.delete(_tablePlaylists, where: 'id = ?', whereArgs: [playlistId]);
}
Future<void> 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<void> upsertPlaylistTracksBatch({
required String playlistId,
required String playlistUpdatedAt,
required List<Map<String, String>> 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<void> 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],
);
});
}
}
+3 -1
View File
@@ -18,7 +18,9 @@ Future<void> 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,