mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-12 14:10:21 +02:00
refactor(queue): split _QueueTabState method clusters into part files
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
This commit is contained in:
@@ -0,0 +1,585 @@
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user