feat: add multi-select to library folders, batch playlist picker, and Go backend FD safety

- Add multi-select support to library_tracks_folder_screen (wishlist, loved,
  playlist) with long-press to enter selection mode, animated bottom bar with
  batch remove/download/add-to-playlist actions, and PopScope exit handling
- Create batch showAddTracksToPlaylistSheet in playlist_picker_sheet with
  playlist thumbnail widget and cover image support
- Add playlist grid selection tint overlay in queue_tab
- Optimize collection lookups with pre-built _allPlaylistTrackKeys index and
  isTrackInAnyPlaylist/hasPlaylistTracks accessors
- Eagerly initialize localLibraryProvider and libraryCollectionsProvider
- Enable SQLite WAL mode and PRAGMA synchronous=NORMAL across all databases
- Go backend: duplicate SAF output FDs before provider attempts to prevent
  fdsan abort on fallback retries; close detached FDs after download completes
- Go backend: rewrite compatibilityTransport to try HTTPS first and only
  fallback to HTTP on transport-level failures, preventing redirect loops
- Go backend: enforce HTTPS-only for extension sandbox HTTP clients
This commit is contained in:
zarzet 2026-02-19 18:27:14 +07:00
parent e39756fa3f
commit ab72a10578
15 changed files with 1042 additions and 156 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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 {

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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<void> _initializeAppServices() async {

View file

@ -135,6 +135,7 @@ class LibraryCollectionsState {
final Set<String> _wishlistKeys;
final Set<String> _lovedKeys;
final Map<String, UserPlaylistCollection> _playlistsById;
final Set<String> _allPlaylistTrackKeys;
LibraryCollectionsState({
this.wishlist = const [],
@ -144,6 +145,7 @@ class LibraryCollectionsState {
Set<String>? wishlistKeys,
Set<String>? lovedKeys,
Map<String, UserPlaylistCollection>? playlistsById,
Set<String>? 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<CollectionTrackEntry>? wishlist,
List<CollectionTrackEntry>? 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<String> _buildPlaylistTrackKeys(List<UserPlaylistCollection> playlists) {
final keys = <String>{};
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;

View file

@ -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<String> _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<CollectionTrackEntry> entries) {
setState(() {
_selectedKeys.addAll(entries.map((e) => e.key));
});
}
// Batch actions
Future<void> _removeSelected(List<CollectionTrackEntry> 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<CollectionTrackEntry> 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<CollectionTrackEntry> 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<CollectionTrackEntry> 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;

View file

@ -2643,16 +2643,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
// Apply advanced filters to match what's displayed
final filtered = _applyAdvancedFilters(unifiedItems);
// Exclude tracks already in a playlist
final playlistTrackKeys = <String>{};
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<QueueTab> {
// 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 = <String>{};
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<QueueTab> {
? () => _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,

View file

@ -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,
);

View file

@ -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,
);

View file

@ -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,

View file

@ -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<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB('local_library.db');
return _database!;
}
Future<Database> _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<void> _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<void> _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<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
return {
'id': json['id'],
@ -205,7 +217,7 @@ class LibraryDatabase {
'format': json['format'],
};
}
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
return {
'id': row['id'],
@ -229,9 +241,9 @@ class LibraryDatabase {
'format': row['format'],
};
}
// CRUD Operations
Future<void> upsert(Map<String, dynamic> json) async {
final db = await database;
await db.insert(
@ -240,12 +252,12 @@ class LibraryDatabase {
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<void> upsertBatch(List<Map<String, dynamic>> 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<List<Map<String, dynamic>>> 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<Map<String, dynamic>?> 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<Map<String, dynamic>?> 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<bool> existsByIsrc(String isrc) async {
final db = await database;
final result = await db.rawQuery(
@ -301,7 +313,7 @@ class LibraryDatabase {
);
return result.isNotEmpty;
}
Future<List<Map<String, dynamic>>> findByTrackAndArtist(
String trackName,
String artistName,
@ -314,7 +326,7 @@ class LibraryDatabase {
);
return rows.map(_dbRowToJson).toList();
}
Future<Map<String, dynamic>?> 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<Set<String>> 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<Set<String>> 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<void> deleteByPath(String filePath) async {
final db = await database;
await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]);
}
Future<void> delete(String id) async {
final db = await database;
await db.delete('library', where: 'id = ?', whereArgs: [id]);
}
Future<int> 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<void> clearAll() async {
final db = await database;
await db.delete('library');
_log.i('Cleared all library data');
}
Future<int> getCount() async {
final db = await database;
final result = await db.rawQuery('SELECT COUNT(*) as count FROM library');
return Sqflite.firstIntValue(result) ?? 0;
}
Future<List<Map<String, dynamic>>> search(String query, {int limit = 50}) async {
Future<List<Map<String, dynamic>>> 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<void> 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<Map<String, int>> 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 = <String, int>{};
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<void> updateFileModTimes(Map<String, int> 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<Set<String>> 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<int> deleteByPaths(List<String> filePaths) async {
if (filePaths.isEmpty) return 0;

View file

@ -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<void> showAddTrackToPlaylistSheet(
BuildContext context,
@ -79,10 +83,9 @@ Future<void> 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<void> showAddTrackToPlaylistSheet(
}
}
/// Batch version: add multiple tracks to a chosen playlist.
Future<void> showAddTracksToPlaylistSheet(
BuildContext context,
WidgetRef ref,
List<Track> tracks,
) async {
if (tracks.isEmpty) return;
final notifier = ref.read(libraryCollectionsProvider.notifier);
if (!context.mounted) return;
await showModalBottomSheet<void>(
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<String?> _promptPlaylistName(BuildContext context) async {
final controller = TextEditingController();
final formKey = GlobalKey<FormState>();
@ -187,3 +311,125 @@ Future<String?> _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),
);
}
}