mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-26 22:11:03 +02:00
860649e3ac
queue_tab.dart was 7,567 lines with a single 7,300-line State class. Continue the existing part-file split by moving six cohesive method clusters into private extensions on _QueueTabState (same library, so private state access is unchanged): selection mode, navigation/open actions, collection item builders, filter UI, batch actions, and queue/library item builders. setState calls in the parts go through a _setState forwarder since @protected members cannot be called from extensions. queue_tab.dart is now 2,462 lines. # Conflicts: # lib/screens/queue_tab.dart
586 lines
17 KiB
Dart
586 lines
17 KiB
Dart
part of 'queue_tab.dart';
|
|
|
|
extension _QueueTabNavigation on _QueueTabState {
|
|
Future<void> _openFile(
|
|
String filePath, {
|
|
String title = '',
|
|
String artist = '',
|
|
String album = '',
|
|
String coverUrl = '',
|
|
}) async {
|
|
final cleanPath = _cleanFilePath(filePath);
|
|
try {
|
|
final fallbackTitle = cleanPath.split('/').last.split('\\').last;
|
|
await ref
|
|
.read(playbackProvider.notifier)
|
|
.playLocalPath(
|
|
path: cleanPath,
|
|
title: title.isNotEmpty ? title : fallbackTitle,
|
|
artist: artist,
|
|
album: album,
|
|
coverUrl: coverUrl,
|
|
);
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Plays [item] and queues the rest of the merged library (downloaded + local
|
|
/// in display order) so playback continues to the next track. Honors player
|
|
/// mode and shuffle.
|
|
Future<void> _playLibraryItem(
|
|
UnifiedLibraryItem item,
|
|
List<UnifiedLibraryItem> libraryItems,
|
|
) async {
|
|
final playableItems = libraryItems
|
|
.where(
|
|
(u) => u.filePath.trim().isNotEmpty && !isCueVirtualPath(u.filePath),
|
|
)
|
|
.toList();
|
|
if (playableItems.isEmpty) return;
|
|
|
|
var start = playableItems.indexWhere((u) => u.id == item.id);
|
|
if (start < 0) start = 0;
|
|
|
|
try {
|
|
await ref
|
|
.read(playbackProvider.notifier)
|
|
.playMediaQueue(
|
|
playableItems.map(_toPlayableMedia),
|
|
startIndex: start,
|
|
externalPath: item.filePath,
|
|
);
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
PlayableMedia _toPlayableMedia(UnifiedLibraryItem item) {
|
|
final history = item.historyItem;
|
|
if (history != null) return playableFromHistory(history);
|
|
final local = item.localItem;
|
|
if (local != null) return playableFromLocal(local);
|
|
|
|
final cover = item.coverUrl ?? item.localCoverPath ?? '';
|
|
String? art;
|
|
if (cover.isNotEmpty) {
|
|
art =
|
|
(cover.startsWith('http') ||
|
|
cover.startsWith('content://') ||
|
|
cover.startsWith('file://'))
|
|
? cover
|
|
: Uri.file(cover).toString();
|
|
}
|
|
return PlayableMedia(
|
|
id: item.id,
|
|
source: item.filePath,
|
|
title: item.trackName,
|
|
artist: item.artistName,
|
|
album: item.albumName,
|
|
artUri: art,
|
|
);
|
|
}
|
|
|
|
void _precacheCover(String? url) {
|
|
if (url == null || url.isEmpty) return;
|
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
return;
|
|
}
|
|
final dpr = MediaQuery.devicePixelRatioOf(
|
|
context,
|
|
).clamp(1.0, 3.0).toDouble();
|
|
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
|
|
precacheImage(
|
|
ResizeImage(
|
|
cachedCoverImageProvider(url),
|
|
width: targetSize,
|
|
height: targetSize,
|
|
),
|
|
context,
|
|
);
|
|
}
|
|
|
|
Future<void> _navigateToMetadataScreen(DownloadItem item) async {
|
|
final historyItem = ref
|
|
.read(downloadHistoryProvider)
|
|
.items
|
|
.firstWhere(
|
|
(h) => h.filePath == item.filePath,
|
|
orElse: () => DownloadHistoryItem(
|
|
id: item.id,
|
|
trackName: item.track.name,
|
|
artistName: item.track.artistName,
|
|
albumName: item.track.albumName,
|
|
coverUrl: item.track.coverUrl,
|
|
filePath: item.filePath ?? '',
|
|
downloadedAt: DateTime.now(),
|
|
service: item.service,
|
|
),
|
|
);
|
|
|
|
final navigator = Navigator.of(context);
|
|
_precacheCover(historyItem.coverUrl);
|
|
_searchFocusNode.unfocus();
|
|
final beforeModTime = await _readFileModTimeMillis(historyItem.filePath);
|
|
if (!mounted) return;
|
|
final result = await navigator.push(
|
|
slidePageRoute<bool>(page: TrackMetadataScreen(item: historyItem)),
|
|
);
|
|
_searchFocusNode.unfocus();
|
|
if (result == true) {
|
|
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
|
historyItem.filePath,
|
|
beforeModTime: beforeModTime,
|
|
force: true,
|
|
);
|
|
return;
|
|
}
|
|
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
|
historyItem.filePath,
|
|
beforeModTime: beforeModTime,
|
|
);
|
|
}
|
|
|
|
Future<void> _navigateToHistoryMetadataScreen(
|
|
DownloadHistoryItem item, {
|
|
List<DownloadHistoryItem>? navigationItems,
|
|
int? navigationIndex,
|
|
}) async {
|
|
final navigator = Navigator.of(context);
|
|
_precacheCover(item.coverUrl);
|
|
_searchFocusNode.unfocus();
|
|
final beforeModTime = await _readFileModTimeMillis(item.filePath);
|
|
if (!mounted) return;
|
|
final result = await navigator.push(
|
|
slidePageRoute<bool>(
|
|
page: TrackMetadataScreen(
|
|
item: item,
|
|
historyNavigationItems: navigationItems,
|
|
navigationIndex: navigationIndex,
|
|
coverHeroTag: 'cover_lib_dl_${item.id}',
|
|
),
|
|
),
|
|
);
|
|
_searchFocusNode.unfocus();
|
|
if (result == true) {
|
|
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
|
item.filePath,
|
|
beforeModTime: beforeModTime,
|
|
force: true,
|
|
);
|
|
return;
|
|
}
|
|
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
|
item.filePath,
|
|
beforeModTime: beforeModTime,
|
|
);
|
|
}
|
|
|
|
void _navigateToLocalMetadataScreen(
|
|
LocalLibraryItem item, {
|
|
List<LocalLibraryItem>? navigationItems,
|
|
int? navigationIndex,
|
|
}) {
|
|
_searchFocusNode.unfocus();
|
|
Navigator.push(
|
|
context,
|
|
slidePageRoute<void>(
|
|
page: TrackMetadataScreen(
|
|
localItem: item,
|
|
localNavigationItems: navigationItems,
|
|
navigationIndex: navigationIndex,
|
|
coverHeroTag: 'cover_lib_local_${item.id}',
|
|
),
|
|
),
|
|
).then((_) => _searchFocusNode.unfocus());
|
|
}
|
|
|
|
List<DownloadHistoryItem> _filterHistoryItems(
|
|
List<DownloadHistoryItem> items,
|
|
String filterMode,
|
|
Map<String, int> albumCounts, [
|
|
String searchQuery = '',
|
|
]) {
|
|
var filteredItems = items;
|
|
if (searchQuery.isNotEmpty) {
|
|
final query = searchQuery;
|
|
filteredItems = items.where((item) {
|
|
final searchKey = _historySearchKeyForItem(item);
|
|
return searchKey.contains(query);
|
|
}).toList();
|
|
}
|
|
|
|
if (filterMode == 'all') return filteredItems;
|
|
|
|
switch (filterMode) {
|
|
case 'albums':
|
|
return filteredItems.where((item) {
|
|
final key =
|
|
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
|
return (albumCounts[key] ?? 0) > 1;
|
|
}).toList();
|
|
case 'singles':
|
|
return filteredItems.where((item) {
|
|
final key =
|
|
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
|
return (albumCounts[key] ?? 0) == 1;
|
|
}).toList();
|
|
default:
|
|
return filteredItems;
|
|
}
|
|
}
|
|
|
|
void _navigateWithUnfocus(Route<dynamic> route) {
|
|
_searchFocusNode.unfocus();
|
|
Navigator.of(context).push(route).then((_) => _searchFocusNode.unfocus());
|
|
}
|
|
|
|
void _navigateToDownloadedAlbum(_GroupedAlbum album) {
|
|
_navigateWithUnfocus(
|
|
slidePageRoute(
|
|
page: DownloadedAlbumScreen(
|
|
albumName: album.albumName,
|
|
artistName: album.artistName,
|
|
coverUrl: album.coverUrl,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _navigateToLocalAlbum(_GroupedLocalAlbum album) async {
|
|
var tracks = album.tracks;
|
|
if (tracks.isEmpty && album.displayTrackCount > 0) {
|
|
var rows = album.albumKey.isNotEmpty
|
|
? await LibraryDatabase.instance.getQueueLocalAlbumTracksByKey(
|
|
album.albumKey,
|
|
)
|
|
: await LibraryDatabase.instance.getQueueLocalAlbumTracks(
|
|
album.albumName,
|
|
album.artistName,
|
|
);
|
|
if (rows.isEmpty && album.albumKey.isNotEmpty) {
|
|
rows = await LibraryDatabase.instance.getQueueLocalAlbumTracks(
|
|
album.albumName,
|
|
album.artistName,
|
|
);
|
|
}
|
|
tracks = rows.map(LocalLibraryItem.fromJson).toList(growable: false);
|
|
if (!mounted) return;
|
|
}
|
|
_navigateWithUnfocus(
|
|
slidePageRoute(
|
|
page: LocalAlbumScreen(
|
|
albumName: album.albumName,
|
|
artistName: album.artistName,
|
|
coverPath: album.coverPath,
|
|
tracks: tracks,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openWishlistFolder() {
|
|
_navigateWithUnfocus(
|
|
MaterialPageRoute(
|
|
builder: (_) => const LibraryTracksFolderScreen(
|
|
mode: LibraryTracksFolderMode.wishlist,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openLovedFolder() {
|
|
_navigateWithUnfocus(
|
|
MaterialPageRoute(
|
|
builder: (_) => const LibraryTracksFolderScreen(
|
|
mode: LibraryTracksFolderMode.loved,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openFavoriteArtistsFolder() {
|
|
_navigateWithUnfocus(
|
|
MaterialPageRoute(builder: (_) => const FavoriteArtistsScreen()),
|
|
);
|
|
}
|
|
|
|
void _openPlaylistById(String playlistId) {
|
|
_navigateWithUnfocus(
|
|
MaterialPageRoute(
|
|
builder: (_) => LibraryTracksFolderScreen(
|
|
mode: LibraryTracksFolderMode.playlist,
|
|
playlistId: playlistId,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _showCreatePlaylistDialog(BuildContext context) async {
|
|
final controller = TextEditingController();
|
|
final formKey = GlobalKey<FormState>();
|
|
|
|
final playlistName = await showDialog<String>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return AlertDialog(
|
|
title: Text(dialogContext.l10n.collectionCreatePlaylist),
|
|
content: Form(
|
|
key: formKey,
|
|
child: TextFormField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
decoration: InputDecoration(
|
|
hintText: dialogContext.l10n.collectionPlaylistNameHint,
|
|
),
|
|
validator: (value) {
|
|
final trimmed = value?.trim() ?? '';
|
|
if (trimmed.isEmpty) {
|
|
return dialogContext.l10n.collectionPlaylistNameRequired;
|
|
}
|
|
return null;
|
|
},
|
|
onFieldSubmitted: (_) {
|
|
if (formKey.currentState?.validate() != true) return;
|
|
Navigator.of(dialogContext).pop(controller.text.trim());
|
|
},
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
child: Text(dialogContext.l10n.dialogCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
if (formKey.currentState?.validate() != true) return;
|
|
Navigator.of(dialogContext).pop(controller.text.trim());
|
|
},
|
|
child: Text(dialogContext.l10n.actionCreate),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
if (playlistName == null || playlistName.isEmpty) return;
|
|
await ref
|
|
.read(libraryCollectionsProvider.notifier)
|
|
.createPlaylist(playlistName);
|
|
}
|
|
|
|
/// Pass a finite [size] (e.g. 56) for list view, or `null` for grid view
|
|
/// where the widget should expand to fill its parent.
|
|
Widget _buildPlaylistCover(
|
|
BuildContext context,
|
|
UserPlaylistCollection playlist,
|
|
ColorScheme colorScheme, [
|
|
double? size,
|
|
]) {
|
|
final borderRadius = BorderRadius.circular(8);
|
|
final dpr = MediaQuery.devicePixelRatioOf(context);
|
|
final cacheExtent = size != null
|
|
? (size * dpr).round().clamp(64, 1024)
|
|
: 420;
|
|
final placeholder = _playlistIconFallback(colorScheme, size);
|
|
|
|
final customCoverPath = playlist.coverImagePath;
|
|
if (customCoverPath != null && customCoverPath.isNotEmpty) {
|
|
return ClipRRect(
|
|
borderRadius: borderRadius,
|
|
child: Image.file(
|
|
File(customCoverPath),
|
|
width: size,
|
|
height: size,
|
|
fit: BoxFit.cover,
|
|
cacheWidth: cacheExtent,
|
|
gaplessPlayback: true,
|
|
filterQuality: FilterQuality.low,
|
|
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
|
|
if (wasSynchronouslyLoaded || frame != null) return child;
|
|
return placeholder;
|
|
},
|
|
errorBuilder: (_, _, _) => placeholder,
|
|
),
|
|
);
|
|
}
|
|
|
|
final firstCoverUrl = playlist.tracks
|
|
.where((e) => e.track.coverUrl != null && e.track.coverUrl!.isNotEmpty)
|
|
.map((e) => e.track.coverUrl!)
|
|
.firstOrNull;
|
|
|
|
if (firstCoverUrl != null) {
|
|
// Guard against local file paths that may have been stored as coverUrl
|
|
final isLocalPath =
|
|
!firstCoverUrl.startsWith('http://') &&
|
|
!firstCoverUrl.startsWith('https://');
|
|
if (isLocalPath) {
|
|
return ClipRRect(
|
|
borderRadius: borderRadius,
|
|
child: Image.file(
|
|
File(firstCoverUrl),
|
|
width: size,
|
|
height: size,
|
|
fit: BoxFit.cover,
|
|
cacheWidth: cacheExtent,
|
|
gaplessPlayback: true,
|
|
filterQuality: FilterQuality.low,
|
|
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
|
|
if (wasSynchronouslyLoaded || frame != null) return child;
|
|
return placeholder;
|
|
},
|
|
errorBuilder: (_, _, _) => placeholder,
|
|
),
|
|
);
|
|
}
|
|
return CachedCoverImage(
|
|
imageUrl: firstCoverUrl,
|
|
width: size,
|
|
height: size,
|
|
memCacheWidth: cacheExtent,
|
|
borderRadius: borderRadius,
|
|
placeholder: (_, _) => placeholder,
|
|
errorWidget: (_, _, _) => placeholder,
|
|
);
|
|
}
|
|
|
|
return placeholder;
|
|
}
|
|
|
|
/// Icon fallback for playlists with no cover.
|
|
/// When [size] is null the container expands to fill its parent (grid view)
|
|
/// and uses a fixed icon size.
|
|
Widget _playlistIconFallback(ColorScheme colorScheme, [double? size]) {
|
|
return Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF5085A5),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(
|
|
Icons.queue_music,
|
|
color: Colors.white,
|
|
size: size != null ? size * 0.5 : 40,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Handle a track being dropped onto a playlist.
|
|
/// When selection mode is active and the dragged item is among the selected,
|
|
/// all selected tracks are added to the playlist.
|
|
Future<void> _onTrackDroppedOnPlaylist(
|
|
BuildContext context,
|
|
UnifiedLibraryItem item,
|
|
String playlistId,
|
|
String playlistName, {
|
|
List<UnifiedLibraryItem> allItems = const [],
|
|
}) async {
|
|
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
|
|
|
if (_isSelectionMode &&
|
|
_selectedIds.isNotEmpty &&
|
|
_selectedIds.contains(item.id)) {
|
|
final selectedItems = allItems
|
|
.where((e) => _selectedIds.contains(e.id))
|
|
.toList();
|
|
if (selectedItems.isEmpty) {
|
|
selectedItems.add(item);
|
|
}
|
|
|
|
final batchResult = await notifier.addTracksToPlaylist(
|
|
playlistId,
|
|
selectedItems.map((selected) => selected.toTrack()),
|
|
);
|
|
final addedCount = batchResult.addedCount;
|
|
final alreadyCount = batchResult.alreadyInPlaylistCount;
|
|
|
|
if (!context.mounted) return;
|
|
final message = addedCount > 0
|
|
? alreadyCount > 0
|
|
? context.l10n.collectionAddedTracksToPlaylistWithExisting(
|
|
addedCount,
|
|
playlistName,
|
|
alreadyCount,
|
|
)
|
|
: context.l10n.collectionAddedTracksToPlaylist(
|
|
addedCount,
|
|
playlistName,
|
|
)
|
|
: context.l10n.collectionAlreadyInPlaylist(playlistName);
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(message)));
|
|
_exitSelectionMode();
|
|
return;
|
|
}
|
|
|
|
final track = item.toTrack();
|
|
final added = await notifier.addTrackToPlaylist(playlistId, track);
|
|
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
added
|
|
? context.l10n.collectionAddedToPlaylist(playlistName)
|
|
: context.l10n.collectionAlreadyInPlaylist(playlistName),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDragFeedback(
|
|
BuildContext context,
|
|
UnifiedLibraryItem item,
|
|
ColorScheme colorScheme,
|
|
) {
|
|
final isDraggingMultiple =
|
|
_isSelectionMode &&
|
|
_selectedIds.contains(item.id) &&
|
|
_selectedIds.length > 1;
|
|
final count = isDraggingMultiple ? _selectedIds.length : 1;
|
|
|
|
return Material(
|
|
elevation: 6,
|
|
borderRadius: BorderRadius.circular(12),
|
|
color: colorScheme.surfaceContainerHighest,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.playlist_add, size: 18, color: colorScheme.primary),
|
|
const SizedBox(width: 8),
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 180),
|
|
child: Text(
|
|
isDraggingMultiple ? '$count tracks' : item.trackName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
}
|