diff --git a/go_backend/exports.go b/go_backend/exports.go index dbae733e..1315280d 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -383,6 +383,7 @@ func DownloadTrack(requestJSON string) (string, error) { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } + defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) @@ -565,6 +566,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } + defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) @@ -1531,6 +1533,7 @@ func DownloadFromYouTube(requestJSON string) (string, error) { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } + defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) @@ -2248,6 +2251,7 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return "", fmt.Errorf("invalid request: %w", err) } + defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 2e33d227..ad2610c0 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -102,8 +102,15 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { vm: ext.VM, } - client := NewHTTPClientWithTimeout(30 * time.Second) - client.Jar = jar + // Extension sandbox enforces HTTPS-only domains. Do not apply global + // allow_http scheme downgrade here, because some extension APIs (e.g. + // spotify-web) will redirect http -> https and can end up in 301 loops. + // We still reuse sharedTransport so insecure TLS compatibility mode remains effective. + client := &http.Client{ + Transport: sharedTransport, + Timeout: 30 * time.Second, + Jar: jar, + } client.CheckRedirect = func(req *http.Request, via []*http.Request) error { if req.URL.Scheme != "https" { GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme) diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 991904d0..08137fa8 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -170,34 +170,71 @@ func newCompatibilityTransport(base http.RoundTripper) http.RoundTripper { } func (t *compatibilityTransport) RoundTrip(req *http.Request) (*http.Response, error) { - reqCompat := applyCompatibilityToRequest(req) - return t.base.RoundTrip(reqCompat) -} - -func applyCompatibilityToRequest(req *http.Request) *http.Request { if req == nil || req.URL == nil { - return req + return t.base.RoundTrip(req) } opts := GetNetworkCompatibilityOptions() if !opts.AllowHTTP || req.URL.Scheme != "https" { - return req + return t.base.RoundTrip(req) } + // Compatibility mode should prefer HTTPS and only fallback to HTTP on + // transport-level failures. Forcing HTTP unconditionally can trigger + // redirect loops (http -> https) on providers that enforce HTTPS. + resp, err := t.base.RoundTrip(req) + if err == nil { + return resp, nil + } + + if !canFallbackToHTTP(req) { + return nil, err + } + + fallbackReq, cloneErr := cloneRequestWithHTTPScheme(req, "http") + if cloneErr != nil { + return nil, err + } + + GoLog("[HTTP] HTTPS request failed for %s, retrying over HTTP: %v\n", req.URL.Host, err) + return t.base.RoundTrip(fallbackReq) +} + +func canFallbackToHTTP(req *http.Request) bool { + if req == nil { + return false + } + + switch strings.ToUpper(req.Method) { + case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodDelete: + return true + default: + return req.GetBody != nil + } +} + +func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request, error) { reqCopy := req.Clone(req.Context()) + if req.Body != nil && req.GetBody != nil { + bodyCopy, err := req.GetBody() + if err != nil { + return nil, err + } + reqCopy.Body = bodyCopy + } + urlCopy := *req.URL - urlCopy.Scheme = "http" + urlCopy.Scheme = scheme reqCopy.URL = &urlCopy - return reqCopy + return reqCopy, nil } // Also checks for ISP blocking on errors func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) { req.Header.Set("User-Agent", getRandomUserAgent()) - reqToSend := applyCompatibilityToRequest(req) - resp, err := client.Do(reqToSend) + resp, err := client.Do(req) if err != nil { - CheckAndLogISPBlocking(err, reqToSend.URL.String(), "HTTP") + CheckAndLogISPBlocking(err, req.URL.String(), "HTTP") } return resp, err } @@ -226,7 +263,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf for attempt := 0; attempt <= config.MaxRetries; attempt++ { reqCopy := req.Clone(req.Context()) reqCopy.Header.Set("User-Agent", getRandomUserAgent()) - reqCopy = applyCompatibilityToRequest(reqCopy) resp, err := client.Do(reqCopy) if err != nil { diff --git a/go_backend/output_fd.go b/go_backend/output_fd.go index fed09bd5..d5519dce 100644 --- a/go_backend/output_fd.go +++ b/go_backend/output_fd.go @@ -12,7 +12,19 @@ func isFDOutput(outputFD int) bool { func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) { if isFDOutput(outputFD) { - return os.NewFile(uintptr(outputFD), fmt.Sprintf("saf_fd_%d", outputFD)), nil + // Never hand the original detached FD directly to a provider attempt. + // Fallback chains may retry with another provider after a failure. + // If the first attempt closes the original FD, its numeric ID can be + // reused by unrelated resources and a later close may trigger fdsan abort. + dupFD, err := dupOutputFD(outputFD) + if err != nil { + return nil, fmt.Errorf("failed to duplicate output fd %d: %w", outputFD, err) + } + if err := prepareDupFDForWrite(dupFD, outputFD); err != nil { + _ = closeFD(dupFD) + return nil, err + } + return os.NewFile(uintptr(dupFD), fmt.Sprintf("saf_fd_%d_dup_%d", outputFD, dupFD)), nil } path := strings.TrimSpace(outputPath) @@ -32,6 +44,36 @@ func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) { return os.Create(outputPath) } +func prepareDupFDForWrite(dupFD, originalFD int) error { + // Best-effort reset so retries start writing from byte 0. + if err := truncateFD(dupFD); err != nil { + if isBestEffortTruncateError(err) { + GoLog("[OutputFD] truncate not supported on fd %d (dup of %d): %v\n", dupFD, originalFD, err) + } else { + return fmt.Errorf("failed to truncate output fd %d (dup of %d): %w", dupFD, originalFD, err) + } + } + if err := seekFDStart(dupFD); err != nil { + GoLog("[OutputFD] seek reset failed on fd %d (dup of %d): %v\n", dupFD, originalFD, err) + } + return nil +} + +func closeOwnedOutputFD(outputFD int) { + if !isFDOutput(outputFD) { + return + } + + if err := closeFD(outputFD); err != nil { + if !isBadFD(err) { + GoLog("[OutputFD] failed to close detached fd %d: %v\n", outputFD, err) + } + return + } + + GoLog("[OutputFD] closed detached fd %d\n", outputFD) +} + func cleanupOutputOnError(outputPath string, outputFD int) { if isFDOutput(outputFD) { return diff --git a/go_backend/output_fd_unix.go b/go_backend/output_fd_unix.go new file mode 100644 index 00000000..a9eb76aa --- /dev/null +++ b/go_backend/output_fd_unix.go @@ -0,0 +1,35 @@ +//go:build !windows + +package gobackend + +import "syscall" + +func dupOutputFD(fd int) (int, error) { + return syscall.Dup(fd) +} + +func truncateFD(fd int) error { + return syscall.Ftruncate(fd, 0) +} + +func seekFDStart(fd int) error { + _, err := syscall.Seek(fd, 0, 0) + return err +} + +func closeFD(fd int) error { + return syscall.Close(fd) +} + +func isBestEffortTruncateError(err error) bool { + switch err { + case syscall.EPERM, syscall.EACCES, syscall.EINVAL, syscall.ESPIPE, syscall.ENOSYS: + return true + default: + return false + } +} + +func isBadFD(err error) bool { + return err == syscall.EBADF +} diff --git a/go_backend/output_fd_windows.go b/go_backend/output_fd_windows.go new file mode 100644 index 00000000..a0cedd95 --- /dev/null +++ b/go_backend/output_fd_windows.go @@ -0,0 +1,29 @@ +//go:build windows + +package gobackend + +func dupOutputFD(fd int) (int, error) { + // Windows build is primarily for local tooling/tests. + // Android runtime uses the !windows implementation. + return fd, nil +} + +func truncateFD(fd int) error { + return nil +} + +func seekFDStart(fd int) error { + return nil +} + +func closeFD(fd int) error { + return nil +} + +func isBestEffortTruncateError(err error) bool { + return true +} + +func isBadFD(err error) bool { + return false +} diff --git a/lib/main.dart b/lib/main.dart index 1fc79299..6963a9ee 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,8 @@ import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/app.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; @@ -93,6 +95,8 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> { _initializeAppServices(); _initializeExtensions(); ref.read(downloadHistoryProvider); + ref.read(localLibraryProvider); + ref.read(libraryCollectionsProvider); } Future _initializeAppServices() async { diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart index 485e3482..a7a3e373 100644 --- a/lib/providers/library_collections_provider.dart +++ b/lib/providers/library_collections_provider.dart @@ -135,6 +135,7 @@ class LibraryCollectionsState { final Set _wishlistKeys; final Set _lovedKeys; final Map _playlistsById; + final Set _allPlaylistTrackKeys; LibraryCollectionsState({ this.wishlist = const [], @@ -144,6 +145,7 @@ class LibraryCollectionsState { Set? wishlistKeys, Set? lovedKeys, Map? playlistsById, + Set? allPlaylistTrackKeys, }) : _wishlistKeys = wishlistKeys ?? wishlist.map((entry) => entry.key).toSet(), _lovedKeys = lovedKeys ?? loved.map((entry) => entry.key).toSet(), @@ -151,7 +153,9 @@ class LibraryCollectionsState { playlistsById ?? Map.fromEntries( playlists.map((playlist) => MapEntry(playlist.id, playlist)), - ); + ), + _allPlaylistTrackKeys = + allPlaylistTrackKeys ?? _buildPlaylistTrackKeys(playlists); int get wishlistCount => wishlist.length; int get lovedCount => loved.length; @@ -185,6 +189,12 @@ class LibraryCollectionsState { return playlist.containsTrackKey(trackKey); } + bool isTrackInAnyPlaylist(String trackKey) { + return _allPlaylistTrackKeys.contains(trackKey); + } + + bool get hasPlaylistTracks => _allPlaylistTrackKeys.isNotEmpty; + LibraryCollectionsState copyWith({ List? wishlist, List? loved, @@ -206,6 +216,7 @@ class LibraryCollectionsState { wishlistKeys: keepWishlistIndex ? _wishlistKeys : null, lovedKeys: keepLovedIndex ? _lovedKeys : null, playlistsById: keepPlaylistIndex ? _playlistsById : null, + allPlaylistTrackKeys: keepPlaylistIndex ? _allPlaylistTrackKeys : null, ); } @@ -245,6 +256,16 @@ class LibraryCollectionsState { } } +Set _buildPlaylistTrackKeys(List playlists) { + final keys = {}; + for (final playlist in playlists) { + for (final entry in playlist.tracks) { + keys.add(entry.key); + } + } + return keys; +} + class PlaylistAddBatchResult { final int addedCount; final int alreadyInPlaylistCount; diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 8968c851..00a0665f 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; @@ -33,6 +34,10 @@ class _LibraryTracksFolderScreenState bool _showTitleInAppBar = false; final ScrollController _scrollController = ScrollController(); + // ── Multi-select state ── + bool _isSelectionMode = false; + final Set _selectedKeys = {}; + @override void initState() { super.initState(); @@ -101,6 +106,112 @@ class _LibraryTracksFolderScreenState return url; } + // ── Selection helpers ── + + void _enterSelectionMode(String key) { + HapticFeedback.mediumImpact(); + setState(() { + _isSelectionMode = true; + _selectedKeys.add(key); + }); + } + + void _exitSelectionMode() { + setState(() { + _isSelectionMode = false; + _selectedKeys.clear(); + }); + } + + void _toggleSelection(String key) { + setState(() { + if (_selectedKeys.contains(key)) { + _selectedKeys.remove(key); + if (_selectedKeys.isEmpty) { + _isSelectionMode = false; + } + } else { + _selectedKeys.add(key); + } + }); + } + + void _selectAll(List entries) { + setState(() { + _selectedKeys.addAll(entries.map((e) => e.key)); + }); + } + + // ── Batch actions ── + + Future _removeSelected(List entries) async { + final keysToRemove = _selectedKeys.toSet(); + if (keysToRemove.isEmpty) return; + + final count = keysToRemove.length; + final notifier = ref.read(libraryCollectionsProvider.notifier); + + for (final key in keysToRemove) { + switch (widget.mode) { + case LibraryTracksFolderMode.wishlist: + await notifier.removeFromWishlist(key); + break; + case LibraryTracksFolderMode.loved: + await notifier.removeFromLoved(key); + break; + case LibraryTracksFolderMode.playlist: + if (widget.playlistId != null) { + await notifier.removeTrackFromPlaylist(widget.playlistId!, key); + } + break; + } + } + + _exitSelectionMode(); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.selectionSelected(count), + ), + ), + ); + } + + void _downloadSelected(List entries) { + final settings = ref.read(settingsProvider); + final queueNotifier = ref.read(downloadQueueProvider.notifier); + var count = 0; + + for (final entry in entries) { + if (!_selectedKeys.contains(entry.key)) continue; + queueNotifier.addToQueue(entry.track, settings.defaultService); + count++; + } + + _exitSelectionMode(); + + if (!mounted || count == 0) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.selectionSelected(count), + ), + ), + ); + } + + void _addSelectedToPlaylist(List entries) { + final selectedTracks = entries + .where((e) => _selectedKeys.contains(e.key)) + .map((e) => e.track) + .toList(growable: false); + if (selectedTracks.isEmpty) return; + + showAddTracksToPlaylistSheet(context, ref, selectedTracks); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -133,6 +244,17 @@ class _LibraryTracksFolderScreenState break; } + // Stale selection cleanup + if (_isSelectionMode) { + final validKeys = entries.map((e) => e.key).toSet(); + _selectedKeys.removeWhere((key) => !validKeys.contains(key)); + if (_selectedKeys.isEmpty && _isSelectionMode) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _isSelectionMode = false); + }); + } + } + final title = switch (widget.mode) { LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist, LibraryTracksFolderMode.loved => context.l10n.collectionLoved, @@ -157,42 +279,247 @@ class _LibraryTracksFolderScreenState context.l10n.collectionPlaylistEmptySubtitle, }; - return Scaffold( - body: CustomScrollView( - controller: _scrollController, - slivers: [ - _buildAppBar(context, colorScheme, title, entries, playlist), - if (entries.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: _EmptyFolderState( - title: emptyTitle, - subtitle: emptySubtitle, - ), - ) - else - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final entry = entries[index]; - return KeyedSubtree( - key: ValueKey(entry.key), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _CollectionTrackTile( - entry: entry, - mode: widget.mode, - playlistId: widget.playlistId, - ), - if (index < entries.length - 1) const Divider(height: 1), - ], + final bottomPadding = MediaQuery.of(context).padding.bottom; + + return PopScope( + canPop: !_isSelectionMode, + onPopInvokedWithResult: (didPop, result) { + if (!didPop && _isSelectionMode) { + _exitSelectionMode(); + } + }, + child: Scaffold( + body: Stack( + children: [ + CustomScrollView( + controller: _scrollController, + slivers: [ + _buildAppBar(context, colorScheme, title, entries, playlist), + if (entries.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _EmptyFolderState( + title: emptyTitle, + subtitle: emptySubtitle, + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final entry = entries[index]; + final isSelected = _selectedKeys.contains(entry.key); + return KeyedSubtree( + key: ValueKey(entry.key), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _CollectionTrackTile( + entry: entry, + mode: widget.mode, + playlistId: widget.playlistId, + isSelectionMode: _isSelectionMode, + isSelected: isSelected, + onTap: _isSelectionMode + ? () => _toggleSelection(entry.key) + : null, + onLongPress: _isSelectionMode + ? null + : () => _enterSelectionMode(entry.key), + ), + if (index < entries.length - 1) + const Divider(height: 1), + ], + ), + ); + }, childCount: entries.length), ), - ); - }, childCount: entries.length), + SliverToBoxAdapter( + child: SizedBox(height: _isSelectionMode ? 200 : 32), + ), + ], ), - const SliverToBoxAdapter(child: SizedBox(height: 32)), + + // Selection bottom bar + AnimatedPositioned( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + left: 0, + right: 0, + bottom: _isSelectionMode ? 0 : -(280 + bottomPadding), + child: _buildSelectionBottomBar( + context, + colorScheme, + entries, + bottomPadding, + ), + ), + ], + ), + ), + ); + } + + Widget _buildSelectionBottomBar( + BuildContext context, + ColorScheme colorScheme, + List entries, + double bottomPadding, + ) { + final selectedCount = _selectedKeys.length; + final allSelected = selectedCount == entries.length && entries.isNotEmpty; + final isWishlist = widget.mode == LibraryTracksFolderMode.wishlist; + + return Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 12, + offset: const Offset(0, -4), + ), ], ), + child: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + + // Header: [X close] [count] [Select All / Deselect] + Row( + children: [ + IconButton.filledTonal( + onPressed: _exitSelectionMode, + icon: const Icon(Icons.close), + style: IconButton.styleFrom( + backgroundColor: colorScheme.surfaceContainerHighest, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.selectionSelected(selectedCount), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + allSelected + ? context.l10n.selectionAllSelected + : context.l10n.selectionSelectToDelete, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + TextButton.icon( + onPressed: () { + if (allSelected) { + _exitSelectionMode(); + } else { + _selectAll(entries); + } + }, + icon: Icon( + allSelected ? Icons.deselect : Icons.select_all, + size: 20, + ), + label: Text( + allSelected + ? context.l10n.actionDeselect + : context.l10n.actionSelectAll, + ), + style: TextButton.styleFrom( + foregroundColor: colorScheme.primary, + ), + ), + ], + ), + + const SizedBox(height: 12), + + // Action buttons row + Row( + children: [ + if (isWishlist) + Expanded( + child: _SelectionActionButton( + icon: Icons.download, + label: + '${context.l10n.settingsDownload} ($selectedCount)', + onPressed: selectedCount > 0 + ? () => _downloadSelected(entries) + : null, + colorScheme: colorScheme, + ), + ), + if (isWishlist) const SizedBox(width: 8), + Expanded( + child: _SelectionActionButton( + icon: Icons.playlist_add, + label: + '${context.l10n.collectionAddToPlaylist} ($selectedCount)', + onPressed: selectedCount > 0 + ? () => _addSelectedToPlaylist(entries) + : null, + colorScheme: colorScheme, + ), + ), + ], + ), + + const SizedBox(height: 8), + + // Remove button (full width, red) + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: selectedCount > 0 + ? () => _removeSelected(entries) + : null, + icon: const Icon(Icons.remove_circle_outline), + label: Text( + selectedCount > 0 + ? '${widget.mode == LibraryTracksFolderMode.playlist ? context.l10n.collectionRemoveFromPlaylist : context.l10n.collectionRemoveFromFolder} ($selectedCount)' + : widget.mode == LibraryTracksFolderMode.playlist + ? context.l10n.collectionRemoveFromPlaylist + : context.l10n.collectionRemoveFromFolder, + ), + style: FilledButton.styleFrom( + backgroundColor: selectedCount > 0 + ? colorScheme.error + : colorScheme.surfaceContainerHighest, + foregroundColor: selectedCount > 0 + ? colorScheme.onError + : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ], + ), + ), + ), ); } @@ -250,7 +577,9 @@ class _LibraryTracksFolderScreenState duration: const Duration(milliseconds: 200), opacity: _showTitleInAppBar ? 1.0 : 0.0, child: Text( - title, + _isSelectionMode + ? context.l10n.selectionSelected(_selectedKeys.length) + : title, style: TextStyle( color: colorScheme.onSurface, fontWeight: FontWeight.w600, @@ -261,7 +590,7 @@ class _LibraryTracksFolderScreenState ), ), actions: [ - if (isPlaylistMode) + if (isPlaylistMode && !_isSelectionMode) IconButton( icon: Container( padding: const EdgeInsets.all(8), @@ -422,9 +751,14 @@ class _LibraryTracksFolderScreenState color: Colors.black.withValues(alpha: 0.4), shape: BoxShape.circle, ), - child: const Icon(Icons.arrow_back, color: Colors.white), + child: Icon( + _isSelectionMode ? Icons.close : Icons.arrow_back, + color: Colors.white, + ), ), - onPressed: () => Navigator.pop(context), + onPressed: _isSelectionMode + ? _exitSelectionMode + : () => Navigator.pop(context), ), ); } @@ -511,51 +845,110 @@ class _CollectionTrackTile extends ConsumerWidget { final CollectionTrackEntry entry; final LibraryTracksFolderMode mode; final String? playlistId; + final bool isSelectionMode; + final bool isSelected; + final VoidCallback? onTap; + final VoidCallback? onLongPress; const _CollectionTrackTile({ required this.entry, required this.mode, required this.playlistId, + this.isSelectionMode = false, + this.isSelected = false, + this.onTap, + this.onLongPress, }); @override Widget build(BuildContext context, WidgetRef ref) { final track = entry.track; + final colorScheme = Theme.of(context).colorScheme; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - leading: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: track.coverUrl != null && track.coverUrl!.isNotEmpty - ? _buildTrackCover(context, track.coverUrl!, 52) - : Container( - width: 52, - height: 52, - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: Theme.of(context).colorScheme.onSurfaceVariant, + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Card( + elevation: 0, + color: isSelected + ? colorScheme.primaryContainer.withValues(alpha: 0.3) + : Colors.transparent, + margin: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSelectionMode) ...[ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), + ), + child: isSelected + ? Icon( + Icons.check, + color: colorScheme.onPrimary, + size: 16, + ) + : null, ), + const SizedBox(width: 12), + ], + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: track.coverUrl != null && track.coverUrl!.isNotEmpty + ? _buildTrackCover(context, track.coverUrl!, 52) + : Container( + width: 52, + height: 52, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), ), - ), - title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text( - track.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: IconButton( - icon: Icon( - Icons.more_vert, - color: Theme.of(context).colorScheme.onSurfaceVariant, - size: 20, + ], + ), + title: + Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: isSelectionMode + ? null + : IconButton( + icon: Icon( + Icons.more_vert, + color: colorScheme.onSurfaceVariant, + size: 20, + ), + onPressed: () => _showTrackOptionsSheet(context, ref), + ), + onTap: isSelectionMode + ? onTap + : mode == LibraryTracksFolderMode.wishlist + ? () => _downloadTrack(context, ref) + : () => _navigateToMetadata(context, ref), + onLongPress: isSelectionMode ? onTap : onLongPress, ), - onPressed: () => _showTrackOptionsSheet(context, ref), ), - onTap: mode == LibraryTracksFolderMode.wishlist - ? () => _downloadTrack(context, ref) - : () => _navigateToMetadata(context, ref), - onLongPress: () => _showTrackOptionsSheet(context, ref), ); } @@ -831,6 +1224,41 @@ class _CollectionOptionTile extends StatelessWidget { } } +class _SelectionActionButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback? onPressed; + final ColorScheme colorScheme; + + const _SelectionActionButton({ + required this.icon, + required this.label, + required this.onPressed, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + return FilledButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 18), + label: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + style: FilledButton.styleFrom( + backgroundColor: onPressed != null + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + foregroundColor: onPressed != null + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ); + } +} + class _EmptyFolderState extends StatelessWidget { final String title; final String subtitle; diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 66efe2b6..6a5390cd 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -2643,16 +2643,11 @@ class _QueueTabState extends ConsumerState { // Apply advanced filters to match what's displayed final filtered = _applyAdvancedFilters(unifiedItems); - // Exclude tracks already in a playlist - final playlistTrackKeys = {}; - for (final playlist in collectionState.playlists) { - for (final entry in playlist.tracks) { - playlistTrackKeys.add(entry.key); - } - } - if (playlistTrackKeys.isEmpty) return filtered; + if (!collectionState.hasPlaylistTracks) return filtered; return filtered - .where((item) => !playlistTrackKeys.contains(item.collectionKey)) + .where( + (item) => !collectionState.isTrackInAnyPlaylist(item.collectionKey), + ) .toList(growable: false); } @@ -2741,17 +2736,13 @@ class _QueueTabState extends ConsumerState { // in the main tracks list. When a track is removed from a playlist (or // the playlist is deleted) it will automatically reappear here because it // will no longer be in the set. - final playlistTrackKeys = {}; - for (final playlist in collectionState.playlists) { - for (final entry in playlist.tracks) { - playlistTrackKeys.add(entry.key); - } - } - - final filteredUnifiedItems = playlistTrackKeys.isEmpty + final filteredUnifiedItems = !collectionState.hasPlaylistTracks ? filtered : filtered - .where((item) => !playlistTrackKeys.contains(item.collectionKey)) + .where( + (item) => + !collectionState.isTrackInAnyPlaylist(item.collectionKey), + ) .toList(growable: false); return _FilterContentData( @@ -3026,6 +3017,23 @@ class _QueueTabState extends ConsumerState { ? () => _togglePlaylistSelection(playlist.id) : () => _enterPlaylistSelectionMode(playlist.id), ), + if (_isPlaylistSelectionMode) + Positioned( + left: 0, + top: 0, + right: 0, + child: AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.3) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), if (_isPlaylistSelectionMode) Positioned( top: 4, diff --git a/lib/services/app_state_database.dart b/lib/services/app_state_database.dart index e5ca600c..92ca49f8 100644 --- a/lib/services/app_state_database.dart +++ b/lib/services/app_state_database.dart @@ -45,6 +45,10 @@ class AppStateDatabase { return openDatabase( path, version: _dbVersion, + onConfigure: (db) async { + await db.rawQuery('PRAGMA journal_mode = WAL'); + await db.execute('PRAGMA synchronous = NORMAL'); + }, onCreate: _createDb, onUpgrade: _upgradeDb, ); diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index b20dd7bc..5a3c1739 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -35,6 +35,10 @@ class HistoryDatabase { return await openDatabase( path, version: 3, + onConfigure: (db) async { + await db.rawQuery('PRAGMA journal_mode = WAL'); + await db.execute('PRAGMA synchronous = NORMAL'); + }, onCreate: _createDB, onUpgrade: _upgradeDB, ); diff --git a/lib/services/library_collections_database.dart b/lib/services/library_collections_database.dart index ae1b3fdf..65577cce 100644 --- a/lib/services/library_collections_database.dart +++ b/lib/services/library_collections_database.dart @@ -59,6 +59,8 @@ class LibraryCollectionsDatabase { version: _dbVersion, onConfigure: (db) async { await db.execute('PRAGMA foreign_keys = ON'); + await db.rawQuery('PRAGMA journal_mode = WAL'); + await db.execute('PRAGMA synchronous = NORMAL'); }, onCreate: _createDb, onUpgrade: _upgradeDb, diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index b2243e2b..c92fb7a3 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -95,39 +95,45 @@ class LocalLibraryItem { ); /// Create a unique key for matching tracks - String get matchKey => '${trackName.toLowerCase()}|${artistName.toLowerCase()}'; - String get albumKey => '${albumName.toLowerCase()}|${(albumArtist ?? artistName).toLowerCase()}'; + String get matchKey => + '${trackName.toLowerCase()}|${artistName.toLowerCase()}'; + String get albumKey => + '${albumName.toLowerCase()}|${(albumArtist ?? artistName).toLowerCase()}'; } class LibraryDatabase { static final LibraryDatabase instance = LibraryDatabase._init(); static Database? _database; - + LibraryDatabase._init(); - + Future get database async { if (_database != null) return _database!; _database = await _initDB('local_library.db'); return _database!; } - + Future _initDB(String fileName) async { final dbPath = await getApplicationDocumentsDirectory(); final path = join(dbPath.path, fileName); - + _log.i('Initializing library database at: $path'); - + return await openDatabase( path, version: 4, // Bumped version for bitrate column + onConfigure: (db) async { + await db.rawQuery('PRAGMA journal_mode = WAL'); + await db.execute('PRAGMA synchronous = NORMAL'); + }, onCreate: _createDB, onUpgrade: _upgradeDB, ); } - + Future _createDB(Database db, int version) async { _log.i('Creating library database schema v$version'); - + await db.execute(''' CREATE TABLE library ( id TEXT PRIMARY KEY, @@ -151,37 +157,43 @@ class LibraryDatabase { format TEXT ) '''); - + await db.execute('CREATE INDEX idx_library_isrc ON library(isrc)'); - await db.execute('CREATE INDEX idx_library_track_artist ON library(track_name, artist_name)'); - await db.execute('CREATE INDEX idx_library_album ON library(album_name, album_artist)'); - await db.execute('CREATE INDEX idx_library_file_path ON library(file_path)'); - + await db.execute( + 'CREATE INDEX idx_library_track_artist ON library(track_name, artist_name)', + ); + await db.execute( + 'CREATE INDEX idx_library_album ON library(album_name, album_artist)', + ); + await db.execute( + 'CREATE INDEX idx_library_file_path ON library(file_path)', + ); + _log.i('Library database schema created with indexes'); } - + Future _upgradeDB(Database db, int oldVersion, int newVersion) async { _log.i('Upgrading library database from v$oldVersion to v$newVersion'); - + if (oldVersion < 2) { // Add cover_path column await db.execute('ALTER TABLE library ADD COLUMN cover_path TEXT'); _log.i('Added cover_path column'); } - + if (oldVersion < 3) { // Add file_mod_time column for incremental scanning await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER'); _log.i('Added file_mod_time column for incremental scanning'); } - + if (oldVersion < 4) { // Add bitrate column for lossy format quality info await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER'); _log.i('Added bitrate column for lossy format quality'); } } - + Map _jsonToDbRow(Map json) { return { 'id': json['id'], @@ -205,7 +217,7 @@ class LibraryDatabase { 'format': json['format'], }; } - + Map _dbRowToJson(Map row) { return { 'id': row['id'], @@ -229,9 +241,9 @@ class LibraryDatabase { 'format': row['format'], }; } - + // CRUD Operations - + Future upsert(Map json) async { final db = await database; await db.insert( @@ -240,12 +252,12 @@ class LibraryDatabase { conflictAlgorithm: ConflictAlgorithm.replace, ); } - + Future upsertBatch(List> items) async { if (items.isEmpty) return; final db = await database; final batch = db.batch(); - + for (final json in items) { batch.insert( 'library', @@ -253,11 +265,11 @@ class LibraryDatabase { conflictAlgorithm: ConflictAlgorithm.replace, ); } - + await batch.commit(noResult: true); _log.i('Batch inserted ${items.length} items'); } - + Future>> getAll({int? limit, int? offset}) async { final db = await database; final rows = await db.query( @@ -268,7 +280,7 @@ class LibraryDatabase { ); return rows.map(_dbRowToJson).toList(); } - + Future?> getById(String id) async { final db = await database; final rows = await db.query( @@ -280,7 +292,7 @@ class LibraryDatabase { if (rows.isEmpty) return null; return _dbRowToJson(rows.first); } - + Future?> getByIsrc(String isrc) async { final db = await database; final rows = await db.query( @@ -292,7 +304,7 @@ class LibraryDatabase { if (rows.isEmpty) return null; return _dbRowToJson(rows.first); } - + Future existsByIsrc(String isrc) async { final db = await database; final result = await db.rawQuery( @@ -301,7 +313,7 @@ class LibraryDatabase { ); return result.isNotEmpty; } - + Future>> findByTrackAndArtist( String trackName, String artistName, @@ -314,7 +326,7 @@ class LibraryDatabase { ); return rows.map(_dbRowToJson).toList(); } - + Future?> findExisting({ String? isrc, String? trackName, @@ -325,42 +337,42 @@ class LibraryDatabase { final byIsrc = await getByIsrc(isrc); if (byIsrc != null) return byIsrc; } - + // Then try name matching if (trackName != null && artistName != null) { final matches = await findByTrackAndArtist(trackName, artistName); if (matches.isNotEmpty) return matches.first; } - + return null; } - + Future> getAllIsrcs() async { final db = await database; final rows = await db.rawQuery( - 'SELECT isrc FROM library WHERE isrc IS NOT NULL AND isrc != ""' + 'SELECT isrc FROM library WHERE isrc IS NOT NULL AND isrc != ""', ); return rows.map((r) => r['isrc'] as String).toSet(); } - + Future> getAllTrackKeys() async { final db = await database; final rows = await db.rawQuery( - 'SELECT LOWER(track_name) || "|" || LOWER(artist_name) as match_key FROM library' + 'SELECT LOWER(track_name) || "|" || LOWER(artist_name) as match_key FROM library', ); return rows.map((r) => r['match_key'] as String).toSet(); } - + Future deleteByPath(String filePath) async { final db = await database; await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]); } - + Future delete(String id) async { final db = await database; await db.delete('library', where: 'id = ?', whereArgs: [id]); } - + Future cleanupMissingFiles() async { final db = await database; final rows = await db.query('library', columns: ['id', 'file_path']); @@ -409,44 +421,48 @@ class LibraryDatabase { } return removed; } - + Future clearAll() async { final db = await database; await db.delete('library'); _log.i('Cleared all library data'); } - + Future getCount() async { final db = await database; final result = await db.rawQuery('SELECT COUNT(*) as count FROM library'); return Sqflite.firstIntValue(result) ?? 0; } - - Future>> search(String query, {int limit = 50}) async { + + Future>> search( + String query, { + int limit = 50, + }) async { final db = await database; final searchQuery = '%${query.toLowerCase()}%'; final rows = await db.query( 'library', - where: 'LOWER(track_name) LIKE ? OR LOWER(artist_name) LIKE ? OR LOWER(album_name) LIKE ?', + where: + 'LOWER(track_name) LIKE ? OR LOWER(artist_name) LIKE ? OR LOWER(album_name) LIKE ?', whereArgs: [searchQuery, searchQuery, searchQuery], orderBy: 'track_name', limit: limit, ); return rows.map(_dbRowToJson).toList(); } - + Future close() async { final db = await database; await db.close(); _database = null; } - + /// Get all file paths with their modification times for incremental scanning /// Returns a map of filePath -> fileModTime (unix timestamp in milliseconds) Future> getFileModTimes() async { final db = await database; final rows = await db.rawQuery( - 'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library' + 'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library', ); final result = {}; for (final row in rows) { @@ -456,7 +472,7 @@ class LibraryDatabase { } return result; } - + /// Update file_mod_time for existing rows using file_path as key. Future updateFileModTimes(Map fileModTimes) async { if (fileModTimes.isEmpty) return; @@ -472,14 +488,14 @@ class LibraryDatabase { } await batch.commit(noResult: true); } - + /// Get all file paths in the library (for detecting deleted files) Future> getAllFilePaths() async { final db = await database; final rows = await db.rawQuery('SELECT file_path FROM library'); return rows.map((r) => r['file_path'] as String).toSet(); } - + /// Delete multiple items by their file paths Future deleteByPaths(List filePaths) async { if (filePaths.isEmpty) return 0; diff --git a/lib/widgets/playlist_picker_sheet.dart b/lib/widgets/playlist_picker_sheet.dart index dc9b5256..525d3b28 100644 --- a/lib/widgets/playlist_picker_sheet.dart +++ b/lib/widgets/playlist_picker_sheet.dart @@ -1,8 +1,12 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; Future showAddTrackToPlaylistSheet( BuildContext context, @@ -79,10 +83,9 @@ Future showAddTrackToPlaylistSheet( final playlist = playlists[index]; final alreadyInPlaylist = playlist.containsTrack(track); return ListTile( - leading: Icon( - alreadyInPlaylist - ? Icons.playlist_add_check - : Icons.queue_music, + leading: _PlaylistPickerThumbnail( + playlist: playlist, + isSelected: alreadyInPlaylist, ), title: Text(playlist.name), subtitle: Text( @@ -137,6 +140,127 @@ Future showAddTrackToPlaylistSheet( } } +/// Batch version: add multiple tracks to a chosen playlist. +Future showAddTracksToPlaylistSheet( + BuildContext context, + WidgetRef ref, + List tracks, +) async { + if (tracks.isEmpty) return; + + final notifier = ref.read(libraryCollectionsProvider.notifier); + + if (!context.mounted) return; + + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + final playlists = ref.watch( + libraryCollectionsProvider.select((state) => state.playlists), + ); + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.playlist_add), + title: Text(sheetContext.l10n.collectionAddToPlaylist), + subtitle: Text( + '${tracks.length} ${tracks.length == 1 ? 'track' : 'tracks'}', + ), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.add_circle_outline), + title: Text(sheetContext.l10n.collectionCreatePlaylist), + onTap: () async { + Navigator.of(sheetContext).pop(); + final name = await _promptPlaylistName(context); + if (name == null || name.trim().isEmpty || !context.mounted) { + return; + } + final playlistId = await notifier.createPlaylist(name.trim()); + final result = await notifier.addTracksToPlaylist( + playlistId, + tracks, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.addedCount > 0 + ? context.l10n.collectionAddedToPlaylist(name.trim()) + : context.l10n.collectionAlreadyInPlaylist( + name.trim(), + ), + ), + ), + ); + }, + ), + if (playlists.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Text( + sheetContext.l10n.collectionNoPlaylistsYet, + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( + color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, + ), + ), + ) + else + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 320), + child: ListView.builder( + shrinkWrap: true, + itemCount: playlists.length, + itemBuilder: (context, index) { + final playlist = playlists[index]; + return ListTile( + leading: _PlaylistPickerThumbnail( + playlist: playlist, + isSelected: false, + ), + title: Text(playlist.name), + subtitle: Text( + context.l10n.collectionPlaylistTracks( + playlist.tracks.length, + ), + ), + onTap: () async { + final result = await notifier.addTracksToPlaylist( + playlist.id, + tracks, + ); + if (!context.mounted) return; + Navigator.of(sheetContext).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.addedCount > 0 + ? context.l10n.collectionAddedToPlaylist( + playlist.name, + ) + : context.l10n.collectionAlreadyInPlaylist( + playlist.name, + ), + ), + ), + ); + }, + ); + }, + ), + ), + const SizedBox(height: 8), + ], + ), + ); + }, + ); +} + Future _promptPlaylistName(BuildContext context) async { final controller = TextEditingController(); final formKey = GlobalKey(); @@ -187,3 +311,125 @@ Future _promptPlaylistName(BuildContext context) async { return result; } + +class _PlaylistPickerThumbnail extends StatelessWidget { + final UserPlaylistCollection playlist; + final bool isSelected; + + const _PlaylistPickerThumbnail({ + required this.playlist, + required this.isSelected, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + const double size = 48; + final borderRadius = BorderRadius.circular(8); + + return SizedBox( + width: size, + height: size, + child: Stack( + children: [ + ClipRRect( + borderRadius: borderRadius, + child: _buildCoverImage(colorScheme, size), + ), + if (isSelected) ...[ + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.3), + borderRadius: borderRadius, + ), + ), + ), + Positioned( + right: 2, + top: 2, + child: Container( + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + border: Border.all( + color: colorScheme.primary, + width: 1.5, + ), + ), + child: Icon( + Icons.check, + color: colorScheme.onPrimary, + size: 14, + ), + ), + ), + ], + ], + ), + ); + } + + Widget _buildCoverImage(ColorScheme colorScheme, double size) { + final customCoverPath = playlist.coverImagePath; + if (customCoverPath != null && customCoverPath.isNotEmpty) { + return Image.file( + File(customCoverPath), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _iconFallback(colorScheme, size), + ); + } + + 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 Image.file( + File(firstCoverUrl), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _iconFallback(colorScheme, size), + ); + } + + return CachedNetworkImage( + imageUrl: firstCoverUrl, + width: size, + height: size, + fit: BoxFit.cover, + memCacheWidth: (size * 2).toInt(), + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => _iconFallback(colorScheme, size), + errorWidget: (_, _, _) => _iconFallback(colorScheme, size), + ); + } + + return _iconFallback(colorScheme, size); + } + + Widget _iconFallback(ColorScheme colorScheme, double size) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.queue_music, color: colorScheme.onSurfaceVariant), + ); + } +}