mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-05-31 19:05:05 +07:00
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:
parent
e39756fa3f
commit
ab72a10578
15 changed files with 1042 additions and 156 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
35
go_backend/output_fd_unix.go
Normal file
35
go_backend/output_fd_unix.go
Normal 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
|
||||
}
|
||||
29
go_backend/output_fd_windows.go
Normal file
29
go_backend/output_fd_windows.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue