mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-28 10:31:27 +02:00
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:
+7
-25
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user