From 3a7419ec9fbd8eafb6c800f475c964de817af32a Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 2 May 2026 00:27:51 +0700 Subject: [PATCH] refactor: split large screen files into part files and DRY platform bridge - Extract home_tab.dart helpers/widgets into home_tab_helpers.dart and home_tab_widgets.dart using Dart part files - Extract queue_tab.dart helpers/widgets into queue_tab_helpers.dart and queue_tab_widgets.dart using Dart part files - Extract track_metadata_edit_sheet.dart from track_metadata_screen.dart using Dart part file - Refactor _FileExistsListenableCache into a standalone class in queue_tab_helpers.dart - Fix artist_screen.dart: replace unreliable findAncestorStateOfType with GlobalKey for _FetchingProgressDialog progress updates - DRY platform_bridge.dart: extract common JSON decode patterns into reusable helper methods (_decodeRequiredMapResult, _decodeNullableMapResult, _decodeMapListResult, _decodeStringListResult) --- lib/screens/artist_screen.dart | 14 +- lib/screens/home_tab.dart | 1927 +------------------- lib/screens/home_tab_helpers.dart | 188 ++ lib/screens/home_tab_widgets.dart | 1738 ++++++++++++++++++ lib/screens/queue_tab.dart | 1300 +------------ lib/screens/queue_tab_helpers.dart | 1101 +++++++++++ lib/screens/queue_tab_widgets.dart | 200 ++ lib/screens/track_metadata_edit_sheet.dart | 1695 +++++++++++++++++ lib/screens/track_metadata_screen.dart | 1696 +---------------- lib/services/platform_bridge.dart | 235 ++- 10 files changed, 5081 insertions(+), 5013 deletions(-) create mode 100644 lib/screens/home_tab_helpers.dart create mode 100644 lib/screens/home_tab_widgets.dart create mode 100644 lib/screens/queue_tab_helpers.dart create mode 100644 lib/screens/queue_tab_widgets.dart create mode 100644 lib/screens/track_metadata_edit_sheet.dart diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 757bba59..196ebcf1 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -926,10 +926,12 @@ class _ArtistScreenState extends ConsumerState { return; } + final progressDialogKey = GlobalKey<_FetchingProgressDialogState>(); showDialog( context: context, barrierDismissible: false, builder: (ctx) => _FetchingProgressDialog( + key: progressDialogKey, totalAlbums: albums.length, onCancel: () { setState(() => _isFetchingDiscography = false); @@ -955,8 +957,7 @@ class _ArtistScreenState extends ConsumerState { fetchedCount++; if (mounted) { - _FetchingProgressDialog.updateProgress( - context, + progressDialogKey.currentState?.updateProgress( fetchedCount, albums.length, ); @@ -2001,16 +2002,11 @@ class _FetchingProgressDialog extends StatefulWidget { final VoidCallback onCancel; const _FetchingProgressDialog({ + super.key, required this.totalAlbums, required this.onCancel, }); - static void updateProgress(BuildContext context, int current, int total) { - final state = context - .findAncestorStateOfType<_FetchingProgressDialogState>(); - state?._updateProgress(current, total); - } - @override State<_FetchingProgressDialog> createState() => _FetchingProgressDialogState(); @@ -2026,7 +2022,7 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> { _total = widget.totalAlbums; } - void _updateProgress(int current, int total) { + void updateProgress(int current, int total) { if (mounted) { setState(() { _current = current; diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 77d15963..aea8e83b 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -34,199 +34,15 @@ import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/provider_ui_utils.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; +part 'home_tab_helpers.dart'; +part 'home_tab_widgets.dart'; + class HomeTab extends ConsumerStatefulWidget { const HomeTab({super.key}); @override ConsumerState createState() => _HomeTabState(); } -class _RecentAccessView { - final List uniqueItems; - final List downloadIds; - final Map downloadFilePathByRecentKey; - final bool hasHiddenDownloads; - - const _RecentAccessView({ - required this.uniqueItems, - required this.downloadIds, - required this.downloadFilePathByRecentKey, - required this.hasHiddenDownloads, - }); -} - -class _RecentAlbumAggregate { - int count; - DownloadHistoryItem mostRecent; - - _RecentAlbumAggregate({required this.count, required this.mostRecent}); -} - -class _CsvImportOptions { - final bool confirmed; - final bool skipDownloaded; - - const _CsvImportOptions({ - required this.confirmed, - required this.skipDownloaded, - }); -} - -class _SearchResultBuckets { - final List realTracks; - final List realTrackIndexes; - final List albumItems; - final List playlistItems; - final List artistItems; - - const _SearchResultBuckets({ - required this.realTracks, - required this.realTrackIndexes, - required this.albumItems, - required this.playlistItems, - required this.artistItems, - }); -} - -enum _SearchSortOption { - defaultOrder, - titleAsc, - titleDesc, - artistAsc, - artistDesc, - durationAsc, - durationDesc, - dateAsc, - dateDesc, -} - -const _homeHistoryPreviewLimit = 48; - -class _HomeHistoryPreview { - final List items; - - const _HomeHistoryPreview(this.items); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is _HomeHistoryPreview && listEquals(items, other.items); - - @override - int get hashCode => Object.hashAll(items); -} - -final _homeHistoryPreviewProvider = Provider>((ref) { - final preview = ref.watch( - downloadHistoryProvider.select((s) { - final items = s.items; - if (items.length <= _homeHistoryPreviewLimit) { - return _HomeHistoryPreview(items); - } - return _HomeHistoryPreview( - items.take(_homeHistoryPreviewLimit).toList(growable: false), - ); - }), - ); - return preview.items; -}); - -_RecentAccessView _buildRecentAccessViewData( - List items, - List historyItems, - Set hiddenIds, -) { - final albumGroups = {}; - for (final h in historyItems) { - final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty) - ? h.albumArtist! - : h.artistName; - final albumKey = '${h.albumName}|$artistForKey'; - final existing = albumGroups[albumKey]; - if (existing == null) { - albumGroups[albumKey] = _RecentAlbumAggregate(count: 1, mostRecent: h); - } else { - existing.count++; - if (h.downloadedAt.isAfter(existing.mostRecent.downloadedAt)) { - existing.mostRecent = h; - } - } - } - - final downloadIds = []; - final visibleDownloads = []; - final downloadFilePathByRecentKey = {}; - for (final aggregate in albumGroups.values) { - final mostRecent = aggregate.mostRecent; - final artistForKey = - (mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty) - ? mostRecent.albumArtist! - : mostRecent.artistName; - - final isSingleTrack = aggregate.count == 1; - final recentId = isSingleTrack - ? (mostRecent.spotifyId ?? mostRecent.id) - : '${mostRecent.albumName}|$artistForKey'; - final recent = RecentAccessItem( - id: recentId, - name: isSingleTrack ? mostRecent.trackName : mostRecent.albumName, - subtitle: isSingleTrack ? mostRecent.artistName : artistForKey, - imageUrl: mostRecent.coverUrl, - type: isSingleTrack ? RecentAccessType.track : RecentAccessType.album, - accessedAt: mostRecent.downloadedAt, - providerId: 'download', - ); - - downloadIds.add(recentId); - downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] = - mostRecent.filePath; - if (!hiddenIds.contains(recentId)) { - visibleDownloads.add(recent); - } - } - - visibleDownloads.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); - if (visibleDownloads.length > 10) { - visibleDownloads.removeRange(10, visibleDownloads.length); - } - - final allItems = [...items, ...visibleDownloads]; - allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); - - final seen = {}; - final uniqueItems = []; - for (final item in allItems) { - final key = '${item.type.name}:${item.id}'; - if (seen.add(key)) { - uniqueItems.add(item); - if (uniqueItems.length >= 10) { - break; - } - } - } - - return _RecentAccessView( - uniqueItems: uniqueItems, - downloadIds: downloadIds, - downloadFilePathByRecentKey: downloadFilePathByRecentKey, - hasHiddenDownloads: hiddenIds.isNotEmpty, - ); -} - -final recentAccessViewProvider = Provider<_RecentAccessView>((ref) { - final historyItems = ref.watch(_homeHistoryPreviewProvider); - final recentAccessItems = ref.watch( - recentAccessProvider.select((s) => s.items), - ); - final hiddenDownloadIds = ref.watch( - recentAccessProvider.select((s) => s.hiddenDownloadIds), - ); - return _buildRecentAccessViewData( - recentAccessItems, - historyItems, - hiddenDownloadIds, - ); -}); - class _HomeTabState extends ConsumerState with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { final _urlController = TextEditingController(); @@ -3701,1740 +3517,3 @@ class _HomeTabState extends ConsumerState _searchFocusNode.unfocus(); } } - -/// Dropdown widget for quick search provider switching -class _SearchProviderDropdown extends ConsumerWidget { - final VoidCallback? onProviderChanged; - - const _SearchProviderDropdown({this.onProviderChanged}); - - Extension? _defaultSearchExtension(List extensions) { - return extensions - .where( - (ext) => - ext.enabled && - ext.hasCustomSearch && - ext.searchBehavior?.primary == true, - ) - .firstOrNull ?? - extensions - .where((ext) => ext.enabled && ext.hasCustomSearch) - .firstOrNull; - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final rawCurrentProvider = ref.watch( - settingsProvider.select((s) => s.searchProvider), - ); - final extensionState = ref.watch(extensionProvider); - final extensions = extensionState.extensions; - final colorScheme = Theme.of(context).colorScheme; - - final searchProviders = extensions - .where((ext) => ext.enabled && ext.hasCustomSearch) - .toList(); - final builtInProviders = builtInSearchProviderSpecs; - final hasAnyProvider = - searchProviders.isNotEmpty || builtInProviders.isNotEmpty; - final isProviderLoading = - !extensionState.isInitialized && extensionState.error == null; - - if (!hasAnyProvider) { - return Padding( - padding: const EdgeInsets.only(left: 12, right: 8), - child: SizedBox( - width: 28, - height: 28, - child: Center( - child: isProviderLoading - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: colorScheme.primary, - ), - ) - : Icon( - Icons.search_off, - size: 20, - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ); - } - - final resolvedCurrentProvider = - rawCurrentProvider != null && - rawCurrentProvider.isNotEmpty && - (isBuiltInSearchProvider(rawCurrentProvider) || - searchProviders.any((e) => e.id == rawCurrentProvider)) - ? rawCurrentProvider - : _defaultSearchExtension(searchProviders)?.id ?? - defaultBuiltInSearchProviderId; - final currentProvider = - resolvedCurrentProvider != null && resolvedCurrentProvider.isNotEmpty - ? resolvedCurrentProvider - : null; - - Extension? currentExt; - if (currentProvider != null && currentProvider.isNotEmpty) { - currentExt = searchProviders - .where((e) => e.id == currentProvider) - .firstOrNull; - } - - final isBuiltInProvider = - currentProvider != null && isBuiltInSearchProvider(currentProvider); - - IconData displayIcon = Icons.search; - String? iconPath; - if (currentExt != null) { - iconPath = currentExt.iconPath; - if (currentExt.searchBehavior?.icon != null) { - displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!); - } - } else if (isBuiltInProvider) { - displayIcon = resolveProviderIcon(currentProvider); - } - - return Padding( - padding: const EdgeInsets.only(left: 8), - child: PopupMenuButton( - icon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (iconPath != null && iconPath.isNotEmpty) - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image.file( - File(iconPath), - width: 20, - height: 20, - fit: BoxFit.cover, - errorBuilder: (_, e, st) => Icon(displayIcon, size: 20), - ), - ) - else - Icon(displayIcon, size: 20), - const SizedBox(width: 2), - Icon( - Icons.arrow_drop_down, - size: 16, - color: colorScheme.onSurfaceVariant, - ), - ], - ), - tooltip: context.l10n.homeChangeSearchProviderTooltip, - offset: const Offset(0, 40), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - onSelected: (String providerId) { - ref.read(settingsProvider.notifier).setSearchProvider(providerId); - onProviderChanged?.call(); - }, - itemBuilder: (context) => [ - ...builtInProviders.map( - (provider) => PopupMenuItem( - value: provider.id, - child: Row( - children: [ - Icon( - resolveProviderIcon(provider.id), - size: 20, - color: currentProvider == provider.id - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - provider.displayName, - style: TextStyle( - fontWeight: currentProvider == provider.id - ? FontWeight.w600 - : FontWeight.normal, - ), - ), - ), - if (currentProvider == provider.id) - Icon(Icons.check, size: 18, color: colorScheme.primary), - ], - ), - ), - ), - if (searchProviders.isNotEmpty) const PopupMenuDivider(), - ...searchProviders.map( - (ext) => PopupMenuItem( - value: ext.id, - child: Row( - children: [ - if (ext.iconPath != null && ext.iconPath!.isNotEmpty) - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image.file( - File(ext.iconPath!), - width: 20, - height: 20, - fit: BoxFit.cover, - errorBuilder: (_, e, st) => Icon( - _getIconFromName(ext.searchBehavior?.icon), - size: 20, - color: currentProvider == ext.id - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - ), - ) - else - Icon( - _getIconFromName(ext.searchBehavior?.icon), - size: 20, - color: currentProvider == ext.id - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - ext.displayName, - style: TextStyle( - fontWeight: currentProvider == ext.id - ? FontWeight.w600 - : FontWeight.normal, - ), - ), - ), - if (currentProvider == ext.id) - Icon(Icons.check, size: 18, color: colorScheme.primary), - ], - ), - ), - ), - ], - ), - ); - } - - IconData _getIconFromName(String? iconName) { - switch (iconName) { - case 'video': - case 'movie': - return Icons.video_library; - case 'music': - return Icons.music_note; - case 'podcast': - return Icons.podcasts; - case 'book': - case 'audiobook': - return Icons.menu_book; - case 'cloud': - return Icons.cloud; - case 'download': - return Icons.download; - default: - return Icons.search; - } - } -} - -class _TrackItemWithStatus extends ConsumerWidget { - final Track track; - final int index; - final bool showDivider; - final VoidCallback onDownload; - final String? searchExtensionId; - final bool showLocalLibraryIndicator; - final Map thumbnailSizesByExtensionId; - - const _TrackItemWithStatus({ - super.key, - required this.track, - required this.index, - required this.showDivider, - required this.onDownload, - required this.searchExtensionId, - required this.showLocalLibraryIndicator, - required this.thumbnailSizesByExtensionId, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final colorScheme = Theme.of(context).colorScheme; - - final queueItem = ref.watch( - downloadQueueLookupProvider.select( - (lookup) => lookup.byTrackId[track.id], - ), - ); - - final isInHistory = ref.watch( - downloadHistoryProvider.select((state) { - return state.isDownloaded(track.id); - }), - ); - - final isInLocalLibrary = showLocalLibraryIndicator - ? ref.watch( - localLibraryProvider.select( - (state) => state.existsInLibrary( - isrc: track.isrc, - trackName: track.name, - artistName: track.artistName, - ), - ), - ) - : false; - - double thumbWidth = 56; - double thumbHeight = 56; - - final extensionId = track.source ?? searchExtensionId; - final thumbSize = extensionId == null - ? null - : thumbnailSizesByExtensionId[extensionId]; - if (thumbSize != null) { - thumbWidth = thumbSize.$1; - thumbHeight = thumbSize.$2; - } - - final isQueued = queueItem != null; - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - onTap: () => _handleTap( - context, - ref, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - ), - onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( - context, - ref, - track, - ), - splashColor: colorScheme.primary.withValues(alpha: 0.12), - highlightColor: colorScheme.primary.withValues(alpha: 0.08), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: track.coverUrl != null - ? CachedNetworkImage( - imageUrl: track.coverUrl!, - width: thumbWidth, - height: thumbHeight, - fit: BoxFit.cover, - memCacheWidth: (thumbWidth * 2).toInt(), - memCacheHeight: (thumbHeight * 2).toInt(), - cacheManager: CoverCacheManager.instance, - ) - : Container( - width: thumbWidth, - height: thumbHeight, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - track.name, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Row( - children: [ - Flexible( - child: ClickableArtistName( - artistName: track.artistName, - artistId: track.artistId, - coverUrl: track.coverUrl, - extensionId: extensionId, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ...buildQualityBadges( - audioQuality: track.audioQuality, - audioModes: track.audioModes, - colorScheme: colorScheme, - ), - if (isInLocalLibrary) ...[ - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.folder_outlined, - size: 10, - color: colorScheme.onTertiaryContainer, - ), - const SizedBox(width: 3), - Text( - context.l10n.libraryInLibrary, - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w500, - color: colorScheme.onTertiaryContainer, - ), - ), - ], - ), - ), - ], - ], - ), - ], - ), - ), - TrackCollectionQuickActions(track: track), - ], - ), - ), - ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - indent: thumbWidth + 24, - endIndent: 12, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), - ], - ); - } - - void _handleTap( - BuildContext context, - WidgetRef ref, { - required bool isQueued, - required bool isInHistory, - required bool isInLocalLibrary, - }) async { - if (isQueued) return; - - if (isInLocalLibrary) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)), - ), - ); - } - return; - } - - if (isInHistory) { - final historyItem = ref - .read(downloadHistoryProvider.notifier) - .getBySpotifyId(track.id); - if (historyItem != null) { - final exists = await fileExists(historyItem.filePath); - if (exists) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarAlreadyDownloaded(track.name), - ), - ), - ); - } - return; - } else { - ref - .read(downloadHistoryProvider.notifier) - .removeBySpotifyId(track.id); - } - } - } - - onDownload(); - } -} - -/// Widget for displaying album/playlist items in search results -class _CollectionItemWidget extends StatelessWidget { - final Track item; - final bool showDivider; - final VoidCallback onTap; - - const _CollectionItemWidget({ - super.key, - required this.item, - required this.showDivider, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final isPlaylist = item.isPlaylistItem; - final isArtist = item.isArtistItem; - - IconData placeholderIcon = Icons.album; - if (isPlaylist) placeholderIcon = Icons.playlist_play; - if (isArtist) placeholderIcon = Icons.person; - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - onTap: onTap, - splashColor: colorScheme.primary.withValues(alpha: 0.12), - highlightColor: colorScheme.primary.withValues(alpha: 0.08), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(isArtist ? 28 : 10), - child: item.coverUrl != null && item.coverUrl!.isNotEmpty - ? CachedNetworkImage( - imageUrl: item.coverUrl!, - width: 56, - height: 56, - fit: BoxFit.cover, - memCacheWidth: 112, - memCacheHeight: 112, - cacheManager: CoverCacheManager.instance, - ) - : Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon( - placeholderIcon, - color: colorScheme.onSurfaceVariant, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - item.artistName.isNotEmpty - ? item.artistName - : (isPlaylist - ? 'Playlist' - : (isArtist ? 'Artist' : 'Album')), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Icon( - Icons.chevron_right, - color: colorScheme.onSurfaceVariant, - size: 24, - ), - ], - ), - ), - ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - indent: 80, - endIndent: 12, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), - ], - ); - } -} - -/// Widget for displaying artist items from default search (Deezer/Spotify) -class _SearchArtistItemWidget extends StatelessWidget { - final SearchArtist artist; - final bool showDivider; - final VoidCallback onTap; - - const _SearchArtistItemWidget({ - super.key, - required this.artist, - required this.showDivider, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final hasValidImage = - artist.imageUrl != null && - artist.imageUrl!.isNotEmpty && - Uri.tryParse(artist.imageUrl!)?.hasAuthority == true; - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - onTap: onTap, - splashColor: colorScheme.primary.withValues(alpha: 0.12), - highlightColor: colorScheme.primary.withValues(alpha: 0.08), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(28), - child: hasValidImage - ? CachedNetworkImage( - imageUrl: artist.imageUrl!, - width: 56, - height: 56, - fit: BoxFit.cover, - memCacheWidth: 112, - memCacheHeight: 112, - cacheManager: CoverCacheManager.instance, - ) - : Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.person, - color: colorScheme.onSurfaceVariant, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - artist.name, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - 'Artist', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Icon( - Icons.chevron_right, - color: colorScheme.onSurfaceVariant, - size: 24, - ), - ], - ), - ), - ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - indent: 80, - endIndent: 12, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), - ], - ); - } -} - -/// Widget for displaying album items from default search (Deezer/Spotify) -class _SearchAlbumItemWidget extends StatelessWidget { - final SearchAlbum album; - final bool showDivider; - final VoidCallback onTap; - - const _SearchAlbumItemWidget({ - super.key, - required this.album, - required this.showDivider, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final hasValidImage = - album.imageUrl != null && - album.imageUrl!.isNotEmpty && - Uri.tryParse(album.imageUrl!)?.hasAuthority == true; - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - onTap: onTap, - splashColor: colorScheme.primary.withValues(alpha: 0.12), - highlightColor: colorScheme.primary.withValues(alpha: 0.08), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: hasValidImage - ? CachedNetworkImage( - imageUrl: album.imageUrl!, - width: 56, - height: 56, - fit: BoxFit.cover, - memCacheWidth: 112, - memCacheHeight: 112, - cacheManager: CoverCacheManager.instance, - ) - : Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - album.name, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - ClickableArtistName( - artistName: album.artists.isNotEmpty - ? album.artists - : 'Album', - coverUrl: album.imageUrl, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Icon( - Icons.chevron_right, - color: colorScheme.onSurfaceVariant, - size: 24, - ), - ], - ), - ), - ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - indent: 80, - endIndent: 12, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), - ], - ); - } -} - -/// Widget for displaying playlist items from default search (Deezer/Spotify) -class _SearchPlaylistItemWidget extends StatelessWidget { - final SearchPlaylist playlist; - final bool showDivider; - final VoidCallback onTap; - - const _SearchPlaylistItemWidget({ - super.key, - required this.playlist, - required this.showDivider, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final hasValidImage = - playlist.imageUrl != null && - playlist.imageUrl!.isNotEmpty && - Uri.tryParse(playlist.imageUrl!)?.hasAuthority == true; - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - onTap: onTap, - splashColor: colorScheme.primary.withValues(alpha: 0.12), - highlightColor: colorScheme.primary.withValues(alpha: 0.08), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: hasValidImage - ? CachedNetworkImage( - imageUrl: playlist.imageUrl!, - width: 56, - height: 56, - fit: BoxFit.cover, - memCacheWidth: 112, - memCacheHeight: 112, - cacheManager: CoverCacheManager.instance, - ) - : Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.playlist_play, - color: colorScheme.onSurfaceVariant, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - playlist.name, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - playlist.owner.isNotEmpty ? playlist.owner : 'Playlist', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Icon( - Icons.chevron_right, - color: colorScheme.onSurfaceVariant, - size: 24, - ), - ], - ), - ), - ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - indent: 80, - endIndent: 12, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), - ], - ); - } -} - -class _DownloadedOrRemoteCover extends StatefulWidget { - final String? downloadedFilePath; - final String? imageUrl; - final double width; - final double height; - final BorderRadius borderRadius; - final IconData fallbackIcon; - final double fallbackIconSize; - final ColorScheme colorScheme; - - const _DownloadedOrRemoteCover({ - required this.downloadedFilePath, - required this.imageUrl, - required this.width, - required this.height, - required this.borderRadius, - required this.fallbackIcon, - required this.colorScheme, - this.fallbackIconSize = 24, - }); - - @override - State<_DownloadedOrRemoteCover> createState() => - _DownloadedOrRemoteCoverState(); -} - -class _DownloadedOrRemoteCoverState extends State<_DownloadedOrRemoteCover> { - String? _embeddedCoverPath; - bool _refreshScheduled = false; - - @override - void initState() { - super.initState(); - _embeddedCoverPath = _resolveEmbeddedCoverPath(); - } - - @override - void didUpdateWidget(covariant _DownloadedOrRemoteCover oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.downloadedFilePath != widget.downloadedFilePath || - oldWidget.imageUrl != widget.imageUrl) { - final nextPath = _resolveEmbeddedCoverPath(); - if (nextPath != _embeddedCoverPath) { - setState(() => _embeddedCoverPath = nextPath); - } - } - } - - String? _resolveEmbeddedCoverPath() { - final filePath = widget.downloadedFilePath; - if (filePath == null || filePath.isEmpty) return null; - return DownloadedEmbeddedCoverResolver.resolve( - filePath, - onChanged: _onEmbeddedCoverChanged, - ); - } - - void _onEmbeddedCoverChanged() { - if (!mounted || _refreshScheduled) return; - _refreshScheduled = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - _refreshScheduled = false; - if (!mounted) return; - final nextPath = _resolveEmbeddedCoverPath(); - if (nextPath != _embeddedCoverPath) { - setState(() => _embeddedCoverPath = nextPath); - } - }); - } - - Widget _fallback() { - return Container( - width: widget.width, - height: widget.height, - color: widget.colorScheme.surfaceContainerHighest, - child: Icon( - widget.fallbackIcon, - color: widget.colorScheme.onSurfaceVariant, - size: widget.fallbackIconSize, - ), - ); - } - - @override - Widget build(BuildContext context) { - final cacheWidth = (widget.width * 2).round(); - final cacheHeight = (widget.height * 2).round(); - - Widget child; - if (_embeddedCoverPath != null) { - child = Image.file( - File(_embeddedCoverPath!), - width: widget.width, - height: widget.height, - fit: BoxFit.cover, - cacheWidth: cacheWidth, - cacheHeight: cacheHeight, - gaplessPlayback: true, - filterQuality: FilterQuality.low, - errorBuilder: (_, _, _) => _fallback(), - ); - } else if (widget.imageUrl != null && widget.imageUrl!.isNotEmpty) { - child = CachedNetworkImage( - imageUrl: widget.imageUrl!, - width: widget.width, - height: widget.height, - fit: BoxFit.cover, - memCacheWidth: cacheWidth, - memCacheHeight: cacheHeight, - cacheManager: CoverCacheManager.instance, - errorWidget: (_, _, _) => _fallback(), - ); - } else { - child = _fallback(); - } - - return ClipRRect(borderRadius: widget.borderRadius, child: child); - } -} - -class ExtensionAlbumScreen extends ConsumerStatefulWidget { - final String extensionId; - final String albumId; - final String albumName; - final String? coverUrl; - final String? initialAlbumType; - final int? initialTotalTracks; - - const ExtensionAlbumScreen({ - super.key, - required this.extensionId, - required this.albumId, - required this.albumName, - this.coverUrl, - this.initialAlbumType, - this.initialTotalTracks, - }); - - @override - ConsumerState createState() => - _ExtensionAlbumScreenState(); -} - -class _ExtensionAlbumScreenState extends ConsumerState { - List? _tracks; - bool _isLoading = true; - String? _error; - String? _artistId; - String? _artistName; - String? _albumType; - int? _albumTotalTracks; - - @override - void initState() { - super.initState(); - _albumType = normalizeOptionalString(widget.initialAlbumType); - _albumTotalTracks = widget.initialTotalTracks; - _fetchTracks(); - } - - Future _fetchTracks() async { - setState(() { - _isLoading = true; - _error = null; - }); - - try { - final result = await PlatformBridge.getProviderMetadata( - widget.extensionId, - 'album', - widget.albumId, - ); - if (!mounted) return; - - final albumInfo = result['album_info'] as Map? ?? result; - final trackList = - result['track_list'] as List? ?? - result['tracks'] as List?; - if (trackList == null) { - setState(() { - _error = context.l10n.errorNoTracksFound; - _isLoading = false; - }); - return; - } - - final artistId = (albumInfo['artist_id'] ?? albumInfo['artistId']) - ?.toString(); - final artistName = (albumInfo['artists'] ?? albumInfo['artist']) - ?.toString(); - final albumType = - normalizeOptionalString(albumInfo['album_type']?.toString()) ?? - _albumType; - final totalTracks = - albumInfo['total_tracks'] as int? ?? _albumTotalTracks; - final tracks = trackList - .map( - (t) => _parseTrack( - t as Map, - albumTypeFallback: albumType, - totalTracksFallback: totalTracks, - ), - ) - .toList(); - - setState(() { - _tracks = tracks; - _artistId = artistId; - _artistName = artistName; - _albumType = albumType; - _albumTotalTracks = totalTracks; - _isLoading = false; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _error = context.l10n.snackbarError(e.toString()); - _isLoading = false; - }); - } - } - - Track _parseTrack( - Map data, { - String? albumTypeFallback, - int? totalTracksFallback, - }) { - int durationMs = 0; - final durationValue = data['duration_ms']; - if (durationValue is int) { - durationMs = durationValue; - } else if (durationValue is double) { - durationMs = durationValue.toInt(); - } - - return Track( - id: (data['id'] ?? '').toString(), - name: (data['name'] ?? '').toString(), - artistName: (data['artists'] ?? data['artist'] ?? '').toString(), - albumName: (data['album_name'] ?? widget.albumName).toString(), - albumArtist: normalizeOptionalString(data['album_artist']?.toString()), - artistId: - (data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId, - albumId: data['album_id']?.toString() ?? widget.albumId, - coverUrl: _resolveCoverUrl( - data['cover_url']?.toString(), - widget.coverUrl, - ), - isrc: data['isrc']?.toString(), - duration: (durationMs / 1000).round(), - trackNumber: data['track_number'] as int?, - discNumber: data['disc_number'] as int?, - totalDiscs: data['total_discs'] as int?, - releaseDate: data['release_date']?.toString(), - albumType: - normalizeOptionalString(data['album_type']?.toString()) ?? - albumTypeFallback ?? - _albumType, - totalTracks: - data['total_tracks'] as int? ?? - totalTracksFallback ?? - _albumTotalTracks, - composer: data['composer']?.toString(), - source: widget.extensionId, - audioQuality: data['audio_quality']?.toString(), - audioModes: data['audio_modes']?.toString(), - ); - } - - String? _resolveCoverUrl(String? trackCover, String? albumCover) { - if (trackCover != null && trackCover.isNotEmpty) return trackCover; - return albumCover; - } - - @override - Widget build(BuildContext context) { - if (_isLoading) { - return Scaffold( - appBar: AppBar(title: Text(widget.albumName)), - body: const AlbumTrackListSkeleton( - itemCount: 10, - showCoverHeader: true, - ), - ); - } - - if (_error != null) { - return Scaffold( - appBar: AppBar(title: Text(widget.albumName)), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _fetchTracks, - child: Text(context.l10n.dialogRetry), - ), - ], - ), - ), - ); - } - - return AlbumScreen( - albumId: widget.albumId, - albumName: widget.albumName, - coverUrl: widget.coverUrl, - tracks: _tracks, - extensionId: widget.extensionId, - artistId: _artistId, - artistName: _artistName, - ); - } -} - -/// Screen for viewing extension playlist with track fetching -class ExtensionPlaylistScreen extends ConsumerStatefulWidget { - final String extensionId; - final String playlistId; - final String playlistName; - final String? coverUrl; - - const ExtensionPlaylistScreen({ - super.key, - required this.extensionId, - required this.playlistId, - required this.playlistName, - this.coverUrl, - }); - - @override - ConsumerState createState() => - _ExtensionPlaylistScreenState(); -} - -class _ExtensionPlaylistScreenState - extends ConsumerState { - List? _tracks; - bool _isLoading = true; - String? _error; - - @override - void initState() { - super.initState(); - _fetchTracks(); - } - - Future _fetchTracks() async { - setState(() { - _isLoading = true; - _error = null; - }); - - try { - final result = await PlatformBridge.getProviderMetadata( - widget.extensionId, - 'playlist', - widget.playlistId, - ); - if (!mounted) return; - - final trackList = - result['track_list'] as List? ?? - result['tracks'] as List?; - if (trackList == null) { - setState(() { - _error = context.l10n.errorNoTracksFound; - _isLoading = false; - }); - return; - } - - final tracks = trackList - .map((t) => _parseTrack(t as Map)) - .toList(); - - setState(() { - _tracks = tracks; - _isLoading = false; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _error = context.l10n.snackbarError(e.toString()); - _isLoading = false; - }); - } - } - - Track _parseTrack(Map data) { - int durationMs = 0; - final durationValue = data['duration_ms']; - if (durationValue is int) { - durationMs = durationValue; - } else if (durationValue is double) { - durationMs = durationValue.toInt(); - } - - return Track( - id: (data['id'] ?? '').toString(), - name: (data['name'] ?? '').toString(), - artistName: (data['artists'] ?? data['artist'] ?? '').toString(), - albumName: (data['album_name'] ?? '').toString(), - artistId: (data['artist_id'] ?? data['artistId'])?.toString(), - albumId: data['album_id']?.toString(), - coverUrl: _resolveCoverUrl( - data['cover_url']?.toString(), - widget.coverUrl, - ), - isrc: data['isrc']?.toString(), - duration: (durationMs / 1000).round(), - trackNumber: data['track_number'] as int?, - discNumber: data['disc_number'] as int?, - totalDiscs: data['total_discs'] as int?, - releaseDate: data['release_date']?.toString(), - totalTracks: data['total_tracks'] as int?, - composer: data['composer']?.toString(), - source: widget.extensionId, - audioQuality: data['audio_quality']?.toString(), - audioModes: data['audio_modes']?.toString(), - ); - } - - String? _resolveCoverUrl(String? trackCover, String? playlistCover) { - if (trackCover != null && trackCover.isNotEmpty) return trackCover; - return playlistCover; - } - - @override - Widget build(BuildContext context) { - if (_isLoading) { - return Scaffold( - appBar: AppBar(title: Text(widget.playlistName)), - body: const TrackListSkeleton(itemCount: 8, showCoverHeader: true), - ); - } - - if (_error != null) { - return Scaffold( - appBar: AppBar(title: Text(widget.playlistName)), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _fetchTracks, - child: Text(context.l10n.dialogRetry), - ), - ], - ), - ), - ); - } - - return PlaylistScreen( - playlistName: widget.playlistName, - coverUrl: widget.coverUrl, - tracks: _tracks!, - recommendedService: widget.extensionId, - ); - } -} - -class ExtensionArtistScreen extends ConsumerStatefulWidget { - final String extensionId; - final String artistId; - final String artistName; - final String? coverUrl; - - const ExtensionArtistScreen({ - super.key, - required this.extensionId, - required this.artistId, - required this.artistName, - this.coverUrl, - }); - - @override - ConsumerState createState() => - _ExtensionArtistScreenState(); -} - -class _ExtensionArtistScreenState extends ConsumerState { - List? _albums; - List? _topTracks; - String? _headerImageUrl; - int? _monthlyListeners; - bool _isLoading = true; - String? _error; - - @override - void initState() { - super.initState(); - _fetchArtist(); - } - - Future _fetchArtist() async { - setState(() { - _isLoading = true; - _error = null; - }); - - try { - final result = await PlatformBridge.getProviderMetadata( - widget.extensionId, - 'artist', - widget.artistId, - ); - if (!mounted) return; - - final artistInfo = - result['artist_info'] as Map? ?? result; - final albumList = result['albums'] as List?; - final albums = - albumList - ?.map((a) => _parseAlbum(a as Map)) - .toList() ?? - []; - - final topTracksList = result['top_tracks'] as List?; - List? topTracks; - if (topTracksList != null && topTracksList.isNotEmpty) { - topTracks = topTracksList - .map((t) => _parseTrack(t as Map)) - .toList(); - } - - final headerImage = - artistInfo['images'] as String? ?? - artistInfo['header_image'] as String? ?? - artistInfo['cover_url'] as String? ?? - result['header_image'] as String?; - final listeners = - artistInfo['listeners'] as int? ?? result['listeners'] as int?; - - setState(() { - _albums = albums; - _topTracks = topTracks; - _headerImageUrl = headerImage; - _monthlyListeners = listeners; - _isLoading = false; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _error = context.l10n.snackbarError(e.toString()); - _isLoading = false; - }); - } - } - - ArtistAlbum _parseAlbum(Map data) { - return ArtistAlbum( - id: (data['id'] ?? '').toString(), - name: (data['name'] ?? '').toString(), - artists: (data['artists'] ?? '').toString(), - releaseDate: (data['release_date'] ?? '').toString(), - totalTracks: data['total_tracks'] as int? ?? 0, - coverUrl: normalizeCoverReference(data['cover_url']?.toString()), - albumType: (data['album_type'] ?? 'album').toString(), - providerId: (data['provider_id'] ?? widget.extensionId).toString(), - ); - } - - Track _parseTrack(Map data) { - int durationMs = 0; - final durationValue = data['duration_ms']; - if (durationValue is int) { - durationMs = durationValue; - } else if (durationValue is double) { - durationMs = durationValue.toInt(); - } - - return Track( - id: (data['id'] ?? data['spotify_id'] ?? '').toString(), - name: (data['name'] ?? '').toString(), - artistName: (data['artists'] ?? data['artist'] ?? '').toString(), - albumName: (data['album_name'] ?? data['album'] ?? '').toString(), - albumArtist: data['album_artist']?.toString(), - artistId: - (data['artist_id'] ?? data['artistId'])?.toString() ?? - widget.artistId, - albumId: data['album_id']?.toString(), - coverUrl: normalizeCoverReference( - (data['cover_url'] ?? data['images'])?.toString(), - ), - isrc: data['isrc']?.toString(), - duration: (durationMs / 1000).round(), - trackNumber: data['track_number'] as int?, - discNumber: data['disc_number'] as int?, - totalDiscs: data['total_discs'] as int?, - releaseDate: data['release_date']?.toString(), - totalTracks: data['total_tracks'] as int?, - composer: data['composer']?.toString(), - source: (data['provider_id'] ?? widget.extensionId).toString(), - ); - } - - @override - Widget build(BuildContext context) { - if (_isLoading) { - return Scaffold( - appBar: AppBar(title: Text(widget.artistName)), - body: const ArtistScreenSkeleton(), - ); - } - - if (_error != null) { - return Scaffold( - appBar: AppBar(title: Text(widget.artistName)), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _fetchArtist, - child: Text(context.l10n.dialogRetry), - ), - ], - ), - ), - ); - } - - return ArtistScreen( - artistId: widget.artistId, - artistName: widget.artistName, - coverUrl: widget.coverUrl, - headerImageUrl: _headerImageUrl, - monthlyListeners: _monthlyListeners, - albums: _albums, - topTracks: _topTracks, - extensionId: widget.extensionId, // Skip Spotify/Deezer fetch - ); - } -} - -/// Swipeable Quick Picks widget with page indicator -class _QuickPicksPageView extends StatefulWidget { - final ExploreSection section; - final ColorScheme colorScheme; - final int itemsPerPage; - final int totalPages; - final void Function(ExploreItem) onItemTap; - final void Function(ExploreItem) onItemMenu; - - const _QuickPicksPageView({ - required this.section, - required this.colorScheme, - required this.itemsPerPage, - required this.totalPages, - required this.onItemTap, - required this.onItemMenu, - }); - - @override - State<_QuickPicksPageView> createState() => _QuickPicksPageViewState(); -} - -class _QuickPicksPageViewState extends State<_QuickPicksPageView> { - int _currentPage = 0; - late PageController _pageController; - - @override - void initState() { - super.initState(); - _pageController = PageController(); - } - - @override - void dispose() { - _pageController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - widget.section.title, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - SizedBox( - height: widget.itemsPerPage * 64.0, - child: PageView.builder( - controller: _pageController, - itemCount: widget.totalPages, - onPageChanged: (page) { - setState(() => _currentPage = page); - }, - itemBuilder: (context, pageIndex) { - final startIndex = pageIndex * widget.itemsPerPage; - final endIndex = (startIndex + widget.itemsPerPage).clamp( - 0, - widget.section.items.length, - ); - final pageItemCount = endIndex - startIndex; - - return Column( - children: List.generate(pageItemCount, (index) { - final item = widget.section.items[startIndex + index]; - return _buildQuickPickItem(item); - }), - ); - }, - ), - ), - if (widget.totalPages > 1) - Padding( - padding: const EdgeInsets.only(top: 8, bottom: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(widget.totalPages, (index) { - final isActive = index == _currentPage; - return AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: isActive ? 8 : 6, - height: isActive ? 8 : 6, - margin: const EdgeInsets.symmetric(horizontal: 3), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isActive - ? widget.colorScheme.primary - : widget.colorScheme.onSurfaceVariant.withValues( - alpha: 0.3, - ), - ), - ); - }), - ), - ), - ], - ); - } - - Widget _buildQuickPickItem(ExploreItem item) { - return InkWell( - onTap: () => widget.onItemTap(item), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: item.coverUrl != null && item.coverUrl!.isNotEmpty - ? CachedNetworkImage( - imageUrl: item.coverUrl!, - width: 48, - height: 48, - fit: BoxFit.cover, - memCacheWidth: 96, - memCacheHeight: 96, - cacheManager: CoverCacheManager.instance, - errorWidget: (context, url, error) => Container( - width: 48, - height: 48, - color: widget.colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: widget.colorScheme.onSurfaceVariant, - size: 24, - ), - ), - ) - : Container( - width: 48, - height: 48, - color: widget.colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: widget.colorScheme.onSurfaceVariant, - size: 24, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - item.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: widget.colorScheme.onSurface, - ), - ), - if (item.artists.isNotEmpty) - ClickableArtistName( - artistName: item.artists, - coverUrl: item.coverUrl, - extensionId: item.providerId, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: widget.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - IconButton( - tooltip: MaterialLocalizations.of(context).showMenuTooltip, - icon: Icon( - Icons.more_vert, - color: widget.colorScheme.onSurfaceVariant, - size: 20, - ), - onPressed: () => widget.onItemMenu(item), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - ), - ], - ), - ), - ); - } -} diff --git a/lib/screens/home_tab_helpers.dart b/lib/screens/home_tab_helpers.dart new file mode 100644 index 00000000..fb6a2321 --- /dev/null +++ b/lib/screens/home_tab_helpers.dart @@ -0,0 +1,188 @@ +part of 'home_tab.dart'; + +class _RecentAccessView { + final List uniqueItems; + final List downloadIds; + final Map downloadFilePathByRecentKey; + final bool hasHiddenDownloads; + + const _RecentAccessView({ + required this.uniqueItems, + required this.downloadIds, + required this.downloadFilePathByRecentKey, + required this.hasHiddenDownloads, + }); +} + +class _RecentAlbumAggregate { + int count; + DownloadHistoryItem mostRecent; + + _RecentAlbumAggregate({required this.count, required this.mostRecent}); +} + +class _CsvImportOptions { + final bool confirmed; + final bool skipDownloaded; + + const _CsvImportOptions({ + required this.confirmed, + required this.skipDownloaded, + }); +} + +class _SearchResultBuckets { + final List realTracks; + final List realTrackIndexes; + final List albumItems; + final List playlistItems; + final List artistItems; + + const _SearchResultBuckets({ + required this.realTracks, + required this.realTrackIndexes, + required this.albumItems, + required this.playlistItems, + required this.artistItems, + }); +} + +enum _SearchSortOption { + defaultOrder, + titleAsc, + titleDesc, + artistAsc, + artistDesc, + durationAsc, + durationDesc, + dateAsc, + dateDesc, +} + +const _homeHistoryPreviewLimit = 48; + +class _HomeHistoryPreview { + final List items; + + const _HomeHistoryPreview(this.items); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _HomeHistoryPreview && listEquals(items, other.items); + + @override + int get hashCode => Object.hashAll(items); +} + +final _homeHistoryPreviewProvider = Provider>((ref) { + final preview = ref.watch( + downloadHistoryProvider.select((s) { + final items = s.items; + if (items.length <= _homeHistoryPreviewLimit) { + return _HomeHistoryPreview(items); + } + return _HomeHistoryPreview( + items.take(_homeHistoryPreviewLimit).toList(growable: false), + ); + }), + ); + return preview.items; +}); + +_RecentAccessView _buildRecentAccessViewData( + List items, + List historyItems, + Set hiddenIds, +) { + final albumGroups = {}; + for (final h in historyItems) { + final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty) + ? h.albumArtist! + : h.artistName; + final albumKey = '${h.albumName}|$artistForKey'; + final existing = albumGroups[albumKey]; + if (existing == null) { + albumGroups[albumKey] = _RecentAlbumAggregate(count: 1, mostRecent: h); + } else { + existing.count++; + if (h.downloadedAt.isAfter(existing.mostRecent.downloadedAt)) { + existing.mostRecent = h; + } + } + } + + final downloadIds = []; + final visibleDownloads = []; + final downloadFilePathByRecentKey = {}; + for (final aggregate in albumGroups.values) { + final mostRecent = aggregate.mostRecent; + final artistForKey = + (mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty) + ? mostRecent.albumArtist! + : mostRecent.artistName; + + final isSingleTrack = aggregate.count == 1; + final recentId = isSingleTrack + ? (mostRecent.spotifyId ?? mostRecent.id) + : '${mostRecent.albumName}|$artistForKey'; + final recent = RecentAccessItem( + id: recentId, + name: isSingleTrack ? mostRecent.trackName : mostRecent.albumName, + subtitle: isSingleTrack ? mostRecent.artistName : artistForKey, + imageUrl: mostRecent.coverUrl, + type: isSingleTrack ? RecentAccessType.track : RecentAccessType.album, + accessedAt: mostRecent.downloadedAt, + providerId: 'download', + ); + + downloadIds.add(recentId); + downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] = + mostRecent.filePath; + if (!hiddenIds.contains(recentId)) { + visibleDownloads.add(recent); + } + } + + visibleDownloads.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); + if (visibleDownloads.length > 10) { + visibleDownloads.removeRange(10, visibleDownloads.length); + } + + final allItems = [...items, ...visibleDownloads]; + allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); + + final seen = {}; + final uniqueItems = []; + for (final item in allItems) { + final key = '${item.type.name}:${item.id}'; + if (seen.add(key)) { + uniqueItems.add(item); + if (uniqueItems.length >= 10) { + break; + } + } + } + + return _RecentAccessView( + uniqueItems: uniqueItems, + downloadIds: downloadIds, + downloadFilePathByRecentKey: downloadFilePathByRecentKey, + hasHiddenDownloads: hiddenIds.isNotEmpty, + ); +} + +final recentAccessViewProvider = Provider<_RecentAccessView>((ref) { + final historyItems = ref.watch(_homeHistoryPreviewProvider); + final recentAccessItems = ref.watch( + recentAccessProvider.select((s) => s.items), + ); + final hiddenDownloadIds = ref.watch( + recentAccessProvider.select((s) => s.hiddenDownloadIds), + ); + return _buildRecentAccessViewData( + recentAccessItems, + historyItems, + hiddenDownloadIds, + ); +}); diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart new file mode 100644 index 00000000..f4ae8222 --- /dev/null +++ b/lib/screens/home_tab_widgets.dart @@ -0,0 +1,1738 @@ +part of 'home_tab.dart'; + +/// Dropdown widget for quick search provider switching +class _SearchProviderDropdown extends ConsumerWidget { + final VoidCallback? onProviderChanged; + + const _SearchProviderDropdown({this.onProviderChanged}); + + Extension? _defaultSearchExtension(List extensions) { + return extensions + .where( + (ext) => + ext.enabled && + ext.hasCustomSearch && + ext.searchBehavior?.primary == true, + ) + .firstOrNull ?? + extensions + .where((ext) => ext.enabled && ext.hasCustomSearch) + .firstOrNull; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final rawCurrentProvider = ref.watch( + settingsProvider.select((s) => s.searchProvider), + ); + final extensionState = ref.watch(extensionProvider); + final extensions = extensionState.extensions; + final colorScheme = Theme.of(context).colorScheme; + + final searchProviders = extensions + .where((ext) => ext.enabled && ext.hasCustomSearch) + .toList(); + final builtInProviders = builtInSearchProviderSpecs; + final hasAnyProvider = + searchProviders.isNotEmpty || builtInProviders.isNotEmpty; + final isProviderLoading = + !extensionState.isInitialized && extensionState.error == null; + + if (!hasAnyProvider) { + return Padding( + padding: const EdgeInsets.only(left: 12, right: 8), + child: SizedBox( + width: 28, + height: 28, + child: Center( + child: isProviderLoading + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ) + : Icon( + Icons.search_off, + size: 20, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + final resolvedCurrentProvider = + rawCurrentProvider != null && + rawCurrentProvider.isNotEmpty && + (isBuiltInSearchProvider(rawCurrentProvider) || + searchProviders.any((e) => e.id == rawCurrentProvider)) + ? rawCurrentProvider + : _defaultSearchExtension(searchProviders)?.id ?? + defaultBuiltInSearchProviderId; + final currentProvider = + resolvedCurrentProvider != null && resolvedCurrentProvider.isNotEmpty + ? resolvedCurrentProvider + : null; + + Extension? currentExt; + if (currentProvider != null && currentProvider.isNotEmpty) { + currentExt = searchProviders + .where((e) => e.id == currentProvider) + .firstOrNull; + } + + final isBuiltInProvider = + currentProvider != null && isBuiltInSearchProvider(currentProvider); + + IconData displayIcon = Icons.search; + String? iconPath; + if (currentExt != null) { + iconPath = currentExt.iconPath; + if (currentExt.searchBehavior?.icon != null) { + displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!); + } + } else if (isBuiltInProvider) { + displayIcon = resolveProviderIcon(currentProvider); + } + + return Padding( + padding: const EdgeInsets.only(left: 8), + child: PopupMenuButton( + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (iconPath != null && iconPath.isNotEmpty) + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.file( + File(iconPath), + width: 20, + height: 20, + fit: BoxFit.cover, + errorBuilder: (_, e, st) => Icon(displayIcon, size: 20), + ), + ) + else + Icon(displayIcon, size: 20), + const SizedBox(width: 2), + Icon( + Icons.arrow_drop_down, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + tooltip: context.l10n.homeChangeSearchProviderTooltip, + offset: const Offset(0, 40), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + onSelected: (String providerId) { + ref.read(settingsProvider.notifier).setSearchProvider(providerId); + onProviderChanged?.call(); + }, + itemBuilder: (context) => [ + ...builtInProviders.map( + (provider) => PopupMenuItem( + value: provider.id, + child: Row( + children: [ + Icon( + resolveProviderIcon(provider.id), + size: 20, + color: currentProvider == provider.id + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + provider.displayName, + style: TextStyle( + fontWeight: currentProvider == provider.id + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + if (currentProvider == provider.id) + Icon(Icons.check, size: 18, color: colorScheme.primary), + ], + ), + ), + ), + if (searchProviders.isNotEmpty) const PopupMenuDivider(), + ...searchProviders.map( + (ext) => PopupMenuItem( + value: ext.id, + child: Row( + children: [ + if (ext.iconPath != null && ext.iconPath!.isNotEmpty) + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.file( + File(ext.iconPath!), + width: 20, + height: 20, + fit: BoxFit.cover, + errorBuilder: (_, e, st) => Icon( + _getIconFromName(ext.searchBehavior?.icon), + size: 20, + color: currentProvider == ext.id + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + ) + else + Icon( + _getIconFromName(ext.searchBehavior?.icon), + size: 20, + color: currentProvider == ext.id + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + ext.displayName, + style: TextStyle( + fontWeight: currentProvider == ext.id + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + if (currentProvider == ext.id) + Icon(Icons.check, size: 18, color: colorScheme.primary), + ], + ), + ), + ), + ], + ), + ); + } + + IconData _getIconFromName(String? iconName) { + switch (iconName) { + case 'video': + case 'movie': + return Icons.video_library; + case 'music': + return Icons.music_note; + case 'podcast': + return Icons.podcasts; + case 'book': + case 'audiobook': + return Icons.menu_book; + case 'cloud': + return Icons.cloud; + case 'download': + return Icons.download; + default: + return Icons.search; + } + } +} + +class _TrackItemWithStatus extends ConsumerWidget { + final Track track; + final int index; + final bool showDivider; + final VoidCallback onDownload; + final String? searchExtensionId; + final bool showLocalLibraryIndicator; + final Map thumbnailSizesByExtensionId; + + const _TrackItemWithStatus({ + super.key, + required this.track, + required this.index, + required this.showDivider, + required this.onDownload, + required this.searchExtensionId, + required this.showLocalLibraryIndicator, + required this.thumbnailSizesByExtensionId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + final queueItem = ref.watch( + downloadQueueLookupProvider.select( + (lookup) => lookup.byTrackId[track.id], + ), + ); + + final isInHistory = ref.watch( + downloadHistoryProvider.select((state) { + return state.isDownloaded(track.id); + }), + ); + + final isInLocalLibrary = showLocalLibraryIndicator + ? ref.watch( + localLibraryProvider.select( + (state) => state.existsInLibrary( + isrc: track.isrc, + trackName: track.name, + artistName: track.artistName, + ), + ), + ) + : false; + + double thumbWidth = 56; + double thumbHeight = 56; + + final extensionId = track.source ?? searchExtensionId; + final thumbSize = extensionId == null + ? null + : thumbnailSizesByExtensionId[extensionId]; + if (thumbSize != null) { + thumbWidth = thumbSize.$1; + thumbHeight = thumbSize.$2; + } + + final isQueued = queueItem != null; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: () => _handleTap( + context, + ref, + isQueued: isQueued, + isInHistory: isInHistory, + isInLocalLibrary: isInLocalLibrary, + ), + onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( + context, + ref, + track, + ), + splashColor: colorScheme.primary.withValues(alpha: 0.12), + highlightColor: colorScheme.primary.withValues(alpha: 0.08), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: track.coverUrl != null + ? CachedNetworkImage( + imageUrl: track.coverUrl!, + width: thumbWidth, + height: thumbHeight, + fit: BoxFit.cover, + memCacheWidth: (thumbWidth * 2).toInt(), + memCacheHeight: (thumbHeight * 2).toInt(), + cacheManager: CoverCacheManager.instance, + ) + : Container( + width: thumbWidth, + height: thumbHeight, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Row( + children: [ + Flexible( + child: ClickableArtistName( + artistName: track.artistName, + artistId: track.artistId, + coverUrl: track.coverUrl, + extensionId: extensionId, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ...buildQualityBadges( + audioQuality: track.audioQuality, + audioModes: track.audioModes, + colorScheme: colorScheme, + ), + if (isInLocalLibrary) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder_outlined, + size: 10, + color: colorScheme.onTertiaryContainer, + ), + const SizedBox(width: 3), + Text( + context.l10n.libraryInLibrary, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: colorScheme.onTertiaryContainer, + ), + ), + ], + ), + ), + ], + ], + ), + ], + ), + ), + TrackCollectionQuickActions(track: track), + ], + ), + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: thumbWidth + 24, + endIndent: 12, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } + + void _handleTap( + BuildContext context, + WidgetRef ref, { + required bool isQueued, + required bool isInHistory, + required bool isInLocalLibrary, + }) async { + if (isQueued) return; + + if (isInLocalLibrary) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)), + ), + ); + } + return; + } + + if (isInHistory) { + final historyItem = ref + .read(downloadHistoryProvider.notifier) + .getBySpotifyId(track.id); + if (historyItem != null) { + final exists = await fileExists(historyItem.filePath); + if (exists) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarAlreadyDownloaded(track.name), + ), + ), + ); + } + return; + } else { + ref + .read(downloadHistoryProvider.notifier) + .removeBySpotifyId(track.id); + } + } + } + + onDownload(); + } +} + +/// Widget for displaying album/playlist items in search results +class _CollectionItemWidget extends StatelessWidget { + final Track item; + final bool showDivider; + final VoidCallback onTap; + + const _CollectionItemWidget({ + super.key, + required this.item, + required this.showDivider, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isPlaylist = item.isPlaylistItem; + final isArtist = item.isArtistItem; + + IconData placeholderIcon = Icons.album; + if (isPlaylist) placeholderIcon = Icons.playlist_play; + if (isArtist) placeholderIcon = Icons.person; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + splashColor: colorScheme.primary.withValues(alpha: 0.12), + highlightColor: colorScheme.primary.withValues(alpha: 0.08), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(isArtist ? 28 : 10), + child: item.coverUrl != null && item.coverUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: 112, + memCacheHeight: 112, + cacheManager: CoverCacheManager.instance, + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon( + placeholderIcon, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + item.artistName.isNotEmpty + ? item.artistName + : (isPlaylist + ? 'Playlist' + : (isArtist ? 'Artist' : 'Album')), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + size: 24, + ), + ], + ), + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 80, + endIndent: 12, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} + +/// Widget for displaying artist items from default search (Deezer/Spotify) +class _SearchArtistItemWidget extends StatelessWidget { + final SearchArtist artist; + final bool showDivider; + final VoidCallback onTap; + + const _SearchArtistItemWidget({ + super.key, + required this.artist, + required this.showDivider, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final hasValidImage = + artist.imageUrl != null && + artist.imageUrl!.isNotEmpty && + Uri.tryParse(artist.imageUrl!)?.hasAuthority == true; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + splashColor: colorScheme.primary.withValues(alpha: 0.12), + highlightColor: colorScheme.primary.withValues(alpha: 0.08), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(28), + child: hasValidImage + ? CachedNetworkImage( + imageUrl: artist.imageUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: 112, + memCacheHeight: 112, + cacheManager: CoverCacheManager.instance, + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.person, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + artist.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + 'Artist', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + size: 24, + ), + ], + ), + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 80, + endIndent: 12, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} + +/// Widget for displaying album items from default search (Deezer/Spotify) +class _SearchAlbumItemWidget extends StatelessWidget { + final SearchAlbum album; + final bool showDivider; + final VoidCallback onTap; + + const _SearchAlbumItemWidget({ + super.key, + required this.album, + required this.showDivider, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final hasValidImage = + album.imageUrl != null && + album.imageUrl!.isNotEmpty && + Uri.tryParse(album.imageUrl!)?.hasAuthority == true; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + splashColor: colorScheme.primary.withValues(alpha: 0.12), + highlightColor: colorScheme.primary.withValues(alpha: 0.08), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: hasValidImage + ? CachedNetworkImage( + imageUrl: album.imageUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: 112, + memCacheHeight: 112, + cacheManager: CoverCacheManager.instance, + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + album.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + ClickableArtistName( + artistName: album.artists.isNotEmpty + ? album.artists + : 'Album', + coverUrl: album.imageUrl, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + size: 24, + ), + ], + ), + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 80, + endIndent: 12, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} + +/// Widget for displaying playlist items from default search (Deezer/Spotify) +class _SearchPlaylistItemWidget extends StatelessWidget { + final SearchPlaylist playlist; + final bool showDivider; + final VoidCallback onTap; + + const _SearchPlaylistItemWidget({ + super.key, + required this.playlist, + required this.showDivider, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final hasValidImage = + playlist.imageUrl != null && + playlist.imageUrl!.isNotEmpty && + Uri.tryParse(playlist.imageUrl!)?.hasAuthority == true; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + splashColor: colorScheme.primary.withValues(alpha: 0.12), + highlightColor: colorScheme.primary.withValues(alpha: 0.08), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: hasValidImage + ? CachedNetworkImage( + imageUrl: playlist.imageUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: 112, + memCacheHeight: 112, + cacheManager: CoverCacheManager.instance, + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.playlist_play, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + playlist.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + playlist.owner.isNotEmpty ? playlist.owner : 'Playlist', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + size: 24, + ), + ], + ), + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 80, + endIndent: 12, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} + +class _DownloadedOrRemoteCover extends StatefulWidget { + final String? downloadedFilePath; + final String? imageUrl; + final double width; + final double height; + final BorderRadius borderRadius; + final IconData fallbackIcon; + final double fallbackIconSize; + final ColorScheme colorScheme; + + const _DownloadedOrRemoteCover({ + required this.downloadedFilePath, + required this.imageUrl, + required this.width, + required this.height, + required this.borderRadius, + required this.fallbackIcon, + required this.colorScheme, + this.fallbackIconSize = 24, + }); + + @override + State<_DownloadedOrRemoteCover> createState() => + _DownloadedOrRemoteCoverState(); +} + +class _DownloadedOrRemoteCoverState extends State<_DownloadedOrRemoteCover> { + String? _embeddedCoverPath; + bool _refreshScheduled = false; + + @override + void initState() { + super.initState(); + _embeddedCoverPath = _resolveEmbeddedCoverPath(); + } + + @override + void didUpdateWidget(covariant _DownloadedOrRemoteCover oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.downloadedFilePath != widget.downloadedFilePath || + oldWidget.imageUrl != widget.imageUrl) { + final nextPath = _resolveEmbeddedCoverPath(); + if (nextPath != _embeddedCoverPath) { + setState(() => _embeddedCoverPath = nextPath); + } + } + } + + String? _resolveEmbeddedCoverPath() { + final filePath = widget.downloadedFilePath; + if (filePath == null || filePath.isEmpty) return null; + return DownloadedEmbeddedCoverResolver.resolve( + filePath, + onChanged: _onEmbeddedCoverChanged, + ); + } + + void _onEmbeddedCoverChanged() { + if (!mounted || _refreshScheduled) return; + _refreshScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _refreshScheduled = false; + if (!mounted) return; + final nextPath = _resolveEmbeddedCoverPath(); + if (nextPath != _embeddedCoverPath) { + setState(() => _embeddedCoverPath = nextPath); + } + }); + } + + Widget _fallback() { + return Container( + width: widget.width, + height: widget.height, + color: widget.colorScheme.surfaceContainerHighest, + child: Icon( + widget.fallbackIcon, + color: widget.colorScheme.onSurfaceVariant, + size: widget.fallbackIconSize, + ), + ); + } + + @override + Widget build(BuildContext context) { + final cacheWidth = (widget.width * 2).round(); + final cacheHeight = (widget.height * 2).round(); + + Widget child; + if (_embeddedCoverPath != null) { + child = Image.file( + File(_embeddedCoverPath!), + width: widget.width, + height: widget.height, + fit: BoxFit.cover, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + errorBuilder: (_, _, _) => _fallback(), + ); + } else if (widget.imageUrl != null && widget.imageUrl!.isNotEmpty) { + child = CachedNetworkImage( + imageUrl: widget.imageUrl!, + width: widget.width, + height: widget.height, + fit: BoxFit.cover, + memCacheWidth: cacheWidth, + memCacheHeight: cacheHeight, + cacheManager: CoverCacheManager.instance, + errorWidget: (_, _, _) => _fallback(), + ); + } else { + child = _fallback(); + } + + return ClipRRect(borderRadius: widget.borderRadius, child: child); + } +} + +class ExtensionAlbumScreen extends ConsumerStatefulWidget { + final String extensionId; + final String albumId; + final String albumName; + final String? coverUrl; + final String? initialAlbumType; + final int? initialTotalTracks; + + const ExtensionAlbumScreen({ + super.key, + required this.extensionId, + required this.albumId, + required this.albumName, + this.coverUrl, + this.initialAlbumType, + this.initialTotalTracks, + }); + + @override + ConsumerState createState() => + _ExtensionAlbumScreenState(); +} + +class _ExtensionAlbumScreenState extends ConsumerState { + List? _tracks; + bool _isLoading = true; + String? _error; + String? _artistId; + String? _artistName; + String? _albumType; + int? _albumTotalTracks; + + @override + void initState() { + super.initState(); + _albumType = normalizeOptionalString(widget.initialAlbumType); + _albumTotalTracks = widget.initialTotalTracks; + _fetchTracks(); + } + + Future _fetchTracks() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final result = await PlatformBridge.getProviderMetadata( + widget.extensionId, + 'album', + widget.albumId, + ); + if (!mounted) return; + + final albumInfo = result['album_info'] as Map? ?? result; + final trackList = + result['track_list'] as List? ?? + result['tracks'] as List?; + if (trackList == null) { + setState(() { + _error = context.l10n.errorNoTracksFound; + _isLoading = false; + }); + return; + } + + final artistId = (albumInfo['artist_id'] ?? albumInfo['artistId']) + ?.toString(); + final artistName = (albumInfo['artists'] ?? albumInfo['artist']) + ?.toString(); + final albumType = + normalizeOptionalString(albumInfo['album_type']?.toString()) ?? + _albumType; + final totalTracks = + albumInfo['total_tracks'] as int? ?? _albumTotalTracks; + final tracks = trackList + .map( + (t) => _parseTrack( + t as Map, + albumTypeFallback: albumType, + totalTracksFallback: totalTracks, + ), + ) + .toList(); + + setState(() { + _tracks = tracks; + _artistId = artistId; + _artistName = artistName; + _albumType = albumType; + _albumTotalTracks = totalTracks; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = context.l10n.snackbarError(e.toString()); + _isLoading = false; + }); + } + } + + Track _parseTrack( + Map data, { + String? albumTypeFallback, + int? totalTracksFallback, + }) { + int durationMs = 0; + final durationValue = data['duration_ms']; + if (durationValue is int) { + durationMs = durationValue; + } else if (durationValue is double) { + durationMs = durationValue.toInt(); + } + + return Track( + id: (data['id'] ?? '').toString(), + name: (data['name'] ?? '').toString(), + artistName: (data['artists'] ?? data['artist'] ?? '').toString(), + albumName: (data['album_name'] ?? widget.albumName).toString(), + albumArtist: normalizeOptionalString(data['album_artist']?.toString()), + artistId: + (data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId, + albumId: data['album_id']?.toString() ?? widget.albumId, + coverUrl: _resolveCoverUrl( + data['cover_url']?.toString(), + widget.coverUrl, + ), + isrc: data['isrc']?.toString(), + duration: (durationMs / 1000).round(), + trackNumber: data['track_number'] as int?, + discNumber: data['disc_number'] as int?, + totalDiscs: data['total_discs'] as int?, + releaseDate: data['release_date']?.toString(), + albumType: + normalizeOptionalString(data['album_type']?.toString()) ?? + albumTypeFallback ?? + _albumType, + totalTracks: + data['total_tracks'] as int? ?? + totalTracksFallback ?? + _albumTotalTracks, + composer: data['composer']?.toString(), + source: widget.extensionId, + audioQuality: data['audio_quality']?.toString(), + audioModes: data['audio_modes']?.toString(), + ); + } + + String? _resolveCoverUrl(String? trackCover, String? albumCover) { + if (trackCover != null && trackCover.isNotEmpty) return trackCover; + return albumCover; + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return Scaffold( + appBar: AppBar(title: Text(widget.albumName)), + body: const AlbumTrackListSkeleton( + itemCount: 10, + showCoverHeader: true, + ), + ); + } + + if (_error != null) { + return Scaffold( + appBar: AppBar(title: Text(widget.albumName)), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _fetchTracks, + child: Text(context.l10n.dialogRetry), + ), + ], + ), + ), + ); + } + + return AlbumScreen( + albumId: widget.albumId, + albumName: widget.albumName, + coverUrl: widget.coverUrl, + tracks: _tracks, + extensionId: widget.extensionId, + artistId: _artistId, + artistName: _artistName, + ); + } +} + +/// Screen for viewing extension playlist with track fetching +class ExtensionPlaylistScreen extends ConsumerStatefulWidget { + final String extensionId; + final String playlistId; + final String playlistName; + final String? coverUrl; + + const ExtensionPlaylistScreen({ + super.key, + required this.extensionId, + required this.playlistId, + required this.playlistName, + this.coverUrl, + }); + + @override + ConsumerState createState() => + _ExtensionPlaylistScreenState(); +} + +class _ExtensionPlaylistScreenState + extends ConsumerState { + List? _tracks; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _fetchTracks(); + } + + Future _fetchTracks() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final result = await PlatformBridge.getProviderMetadata( + widget.extensionId, + 'playlist', + widget.playlistId, + ); + if (!mounted) return; + + final trackList = + result['track_list'] as List? ?? + result['tracks'] as List?; + if (trackList == null) { + setState(() { + _error = context.l10n.errorNoTracksFound; + _isLoading = false; + }); + return; + } + + final tracks = trackList + .map((t) => _parseTrack(t as Map)) + .toList(); + + setState(() { + _tracks = tracks; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = context.l10n.snackbarError(e.toString()); + _isLoading = false; + }); + } + } + + Track _parseTrack(Map data) { + int durationMs = 0; + final durationValue = data['duration_ms']; + if (durationValue is int) { + durationMs = durationValue; + } else if (durationValue is double) { + durationMs = durationValue.toInt(); + } + + return Track( + id: (data['id'] ?? '').toString(), + name: (data['name'] ?? '').toString(), + artistName: (data['artists'] ?? data['artist'] ?? '').toString(), + albumName: (data['album_name'] ?? '').toString(), + artistId: (data['artist_id'] ?? data['artistId'])?.toString(), + albumId: data['album_id']?.toString(), + coverUrl: _resolveCoverUrl( + data['cover_url']?.toString(), + widget.coverUrl, + ), + isrc: data['isrc']?.toString(), + duration: (durationMs / 1000).round(), + trackNumber: data['track_number'] as int?, + discNumber: data['disc_number'] as int?, + totalDiscs: data['total_discs'] as int?, + releaseDate: data['release_date']?.toString(), + totalTracks: data['total_tracks'] as int?, + composer: data['composer']?.toString(), + source: widget.extensionId, + audioQuality: data['audio_quality']?.toString(), + audioModes: data['audio_modes']?.toString(), + ); + } + + String? _resolveCoverUrl(String? trackCover, String? playlistCover) { + if (trackCover != null && trackCover.isNotEmpty) return trackCover; + return playlistCover; + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return Scaffold( + appBar: AppBar(title: Text(widget.playlistName)), + body: const TrackListSkeleton(itemCount: 8, showCoverHeader: true), + ); + } + + if (_error != null) { + return Scaffold( + appBar: AppBar(title: Text(widget.playlistName)), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _fetchTracks, + child: Text(context.l10n.dialogRetry), + ), + ], + ), + ), + ); + } + + return PlaylistScreen( + playlistName: widget.playlistName, + coverUrl: widget.coverUrl, + tracks: _tracks!, + recommendedService: widget.extensionId, + ); + } +} + +class ExtensionArtistScreen extends ConsumerStatefulWidget { + final String extensionId; + final String artistId; + final String artistName; + final String? coverUrl; + + const ExtensionArtistScreen({ + super.key, + required this.extensionId, + required this.artistId, + required this.artistName, + this.coverUrl, + }); + + @override + ConsumerState createState() => + _ExtensionArtistScreenState(); +} + +class _ExtensionArtistScreenState extends ConsumerState { + List? _albums; + List? _topTracks; + String? _headerImageUrl; + int? _monthlyListeners; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _fetchArtist(); + } + + Future _fetchArtist() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final result = await PlatformBridge.getProviderMetadata( + widget.extensionId, + 'artist', + widget.artistId, + ); + if (!mounted) return; + + final artistInfo = + result['artist_info'] as Map? ?? result; + final albumList = result['albums'] as List?; + final albums = + albumList + ?.map((a) => _parseAlbum(a as Map)) + .toList() ?? + []; + + final topTracksList = result['top_tracks'] as List?; + List? topTracks; + if (topTracksList != null && topTracksList.isNotEmpty) { + topTracks = topTracksList + .map((t) => _parseTrack(t as Map)) + .toList(); + } + + final headerImage = + artistInfo['images'] as String? ?? + artistInfo['header_image'] as String? ?? + artistInfo['cover_url'] as String? ?? + result['header_image'] as String?; + final listeners = + artistInfo['listeners'] as int? ?? result['listeners'] as int?; + + setState(() { + _albums = albums; + _topTracks = topTracks; + _headerImageUrl = headerImage; + _monthlyListeners = listeners; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = context.l10n.snackbarError(e.toString()); + _isLoading = false; + }); + } + } + + ArtistAlbum _parseAlbum(Map data) { + return ArtistAlbum( + id: (data['id'] ?? '').toString(), + name: (data['name'] ?? '').toString(), + artists: (data['artists'] ?? '').toString(), + releaseDate: (data['release_date'] ?? '').toString(), + totalTracks: data['total_tracks'] as int? ?? 0, + coverUrl: normalizeCoverReference(data['cover_url']?.toString()), + albumType: (data['album_type'] ?? 'album').toString(), + providerId: (data['provider_id'] ?? widget.extensionId).toString(), + ); + } + + Track _parseTrack(Map data) { + int durationMs = 0; + final durationValue = data['duration_ms']; + if (durationValue is int) { + durationMs = durationValue; + } else if (durationValue is double) { + durationMs = durationValue.toInt(); + } + + return Track( + id: (data['id'] ?? data['spotify_id'] ?? '').toString(), + name: (data['name'] ?? '').toString(), + artistName: (data['artists'] ?? data['artist'] ?? '').toString(), + albumName: (data['album_name'] ?? data['album'] ?? '').toString(), + albumArtist: data['album_artist']?.toString(), + artistId: + (data['artist_id'] ?? data['artistId'])?.toString() ?? + widget.artistId, + albumId: data['album_id']?.toString(), + coverUrl: normalizeCoverReference( + (data['cover_url'] ?? data['images'])?.toString(), + ), + isrc: data['isrc']?.toString(), + duration: (durationMs / 1000).round(), + trackNumber: data['track_number'] as int?, + discNumber: data['disc_number'] as int?, + totalDiscs: data['total_discs'] as int?, + releaseDate: data['release_date']?.toString(), + totalTracks: data['total_tracks'] as int?, + composer: data['composer']?.toString(), + source: (data['provider_id'] ?? widget.extensionId).toString(), + ); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return Scaffold( + appBar: AppBar(title: Text(widget.artistName)), + body: const ArtistScreenSkeleton(), + ); + } + + if (_error != null) { + return Scaffold( + appBar: AppBar(title: Text(widget.artistName)), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _fetchArtist, + child: Text(context.l10n.dialogRetry), + ), + ], + ), + ), + ); + } + + return ArtistScreen( + artistId: widget.artistId, + artistName: widget.artistName, + coverUrl: widget.coverUrl, + headerImageUrl: _headerImageUrl, + monthlyListeners: _monthlyListeners, + albums: _albums, + topTracks: _topTracks, + extensionId: widget.extensionId, // Skip Spotify/Deezer fetch + ); + } +} + +/// Swipeable Quick Picks widget with page indicator +class _QuickPicksPageView extends StatefulWidget { + final ExploreSection section; + final ColorScheme colorScheme; + final int itemsPerPage; + final int totalPages; + final void Function(ExploreItem) onItemTap; + final void Function(ExploreItem) onItemMenu; + + const _QuickPicksPageView({ + required this.section, + required this.colorScheme, + required this.itemsPerPage, + required this.totalPages, + required this.onItemTap, + required this.onItemMenu, + }); + + @override + State<_QuickPicksPageView> createState() => _QuickPicksPageViewState(); +} + +class _QuickPicksPageViewState extends State<_QuickPicksPageView> { + int _currentPage = 0; + late PageController _pageController; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + widget.section.title, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + SizedBox( + height: widget.itemsPerPage * 64.0, + child: PageView.builder( + controller: _pageController, + itemCount: widget.totalPages, + onPageChanged: (page) { + setState(() => _currentPage = page); + }, + itemBuilder: (context, pageIndex) { + final startIndex = pageIndex * widget.itemsPerPage; + final endIndex = (startIndex + widget.itemsPerPage).clamp( + 0, + widget.section.items.length, + ); + final pageItemCount = endIndex - startIndex; + + return Column( + children: List.generate(pageItemCount, (index) { + final item = widget.section.items[startIndex + index]; + return _buildQuickPickItem(item); + }), + ); + }, + ), + ), + if (widget.totalPages > 1) + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(widget.totalPages, (index) { + final isActive = index == _currentPage; + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: isActive ? 8 : 6, + height: isActive ? 8 : 6, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive + ? widget.colorScheme.primary + : widget.colorScheme.onSurfaceVariant.withValues( + alpha: 0.3, + ), + ), + ); + }), + ), + ), + ], + ); + } + + Widget _buildQuickPickItem(ExploreItem item) { + return InkWell( + onTap: () => widget.onItemTap(item), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: item.coverUrl != null && item.coverUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + memCacheWidth: 96, + memCacheHeight: 96, + cacheManager: CoverCacheManager.instance, + errorWidget: (context, url, error) => Container( + width: 48, + height: 48, + color: widget.colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: widget.colorScheme.onSurfaceVariant, + size: 24, + ), + ), + ) + : Container( + width: 48, + height: 48, + color: widget.colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: widget.colorScheme.onSurfaceVariant, + size: 24, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: widget.colorScheme.onSurface, + ), + ), + if (item.artists.isNotEmpty) + ClickableArtistName( + artistName: item.artists, + coverUrl: item.coverUrl, + extensionId: item.providerId, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: widget.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + tooltip: MaterialLocalizations.of(context).showMenuTooltip, + icon: Icon( + Icons.more_vert, + color: widget.colorScheme.onSurfaceVariant, + size: 20, + ), + onPressed: () => widget.onItemMenu(item), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 4cb4a3ef..068e5933 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -38,1035 +38,8 @@ import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; -enum LibraryItemSource { downloaded, local } - -class UnifiedLibraryItem { - final String id; - final String trackName; - final String artistName; - final String albumName; - final String? coverUrl; - final String? localCoverPath; - final String filePath; - final String? quality; - final DateTime addedAt; - final LibraryItemSource source; - - final DownloadHistoryItem? historyItem; - final LocalLibraryItem? localItem; - - UnifiedLibraryItem({ - required this.id, - required this.trackName, - required this.artistName, - required this.albumName, - this.coverUrl, - this.localCoverPath, - required this.filePath, - this.quality, - required this.addedAt, - required this.source, - this.historyItem, - this.localItem, - }); - - factory UnifiedLibraryItem.fromDownloadHistory(DownloadHistoryItem item) { - return UnifiedLibraryItem( - id: 'dl_${item.id}', - trackName: item.trackName, - artistName: item.artistName, - albumName: item.albumName, - coverUrl: item.coverUrl, - filePath: item.filePath, - quality: buildDisplayAudioQuality( - bitDepth: item.bitDepth, - sampleRate: item.sampleRate, - storedQuality: item.quality, - ), - addedAt: item.downloadedAt, - source: LibraryItemSource.downloaded, - historyItem: item, - ); - } - - factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) { - String? quality; - if (item.bitrate != null && item.bitrate! > 0) { - quality = buildDisplayAudioQuality( - bitrateKbps: item.bitrate, - format: item.format, - ); - } else if (item.bitDepth != null && - item.bitDepth! > 0 && - item.sampleRate != null) { - quality = buildDisplayAudioQuality( - bitDepth: item.bitDepth, - sampleRate: item.sampleRate, - ); - } - return UnifiedLibraryItem( - id: 'local_${item.id}', - trackName: item.trackName, - artistName: item.artistName, - albumName: item.albumName, - coverUrl: null, - localCoverPath: item.coverPath, - filePath: item.filePath, - quality: quality, - addedAt: item.fileModTime != null - ? DateTime.fromMillisecondsSinceEpoch(item.fileModTime!) - : item.scannedAt, - source: LibraryItemSource.local, - localItem: item, - ); - } - - bool get hasCover => - coverUrl != null || - (localCoverPath != null && localCoverPath!.isNotEmpty); - - String? get albumArtist => historyItem?.albumArtist ?? localItem?.albumArtist; - - String? get releaseDate => historyItem?.releaseDate ?? localItem?.releaseDate; - - String? get genre => historyItem?.genre ?? localItem?.genre; - - String get searchKey => - '${trackName.toLowerCase()}|${artistName.toLowerCase()}|${albumName.toLowerCase()}'; - String get albumKey => - '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; - - /// Returns the collection key used to match this item against playlist - /// entries. Uses the same logic as [trackCollectionKey] from the collections - /// provider: prefer ISRC, fall back to source:id. - String get collectionKey { - if (historyItem != null) { - final isrc = historyItem!.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}'; - final source = historyItem!.service.trim().isNotEmpty - ? historyItem!.service.trim() - : 'builtin'; - return '$source:${historyItem!.id}'; - } - if (localItem != null) { - final isrc = localItem!.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}'; - return 'local:${localItem!.id}'; - } - return 'builtin:$id'; - } - - Track toTrack() { - if (historyItem != null) { - final h = historyItem!; - return Track( - id: h.id, - name: h.trackName, - artistName: h.artistName, - albumName: h.albumName, - albumArtist: h.albumArtist, - coverUrl: h.coverUrl, - isrc: h.isrc, - duration: h.duration ?? 0, - trackNumber: h.trackNumber, - discNumber: h.discNumber, - releaseDate: h.releaseDate, - source: h.service, - ); - } - if (localItem != null) { - final l = localItem!; - return Track( - id: l.id, - name: l.trackName, - artistName: l.artistName, - albumName: l.albumName, - albumArtist: l.albumArtist, - coverUrl: l.coverPath, - isrc: l.isrc, - duration: l.duration ?? 0, - trackNumber: l.trackNumber, - discNumber: l.discNumber, - releaseDate: l.releaseDate, - source: 'local', - ); - } - return Track( - id: id, - name: trackName, - artistName: artistName, - albumName: albumName, - coverUrl: coverUrl, - duration: 0, - ); - } -} - -class _GroupedAlbum { - final String albumName; - final String artistName; - final String? coverUrl; - final String sampleFilePath; - final List tracks; - final DateTime latestDownload; - final String searchKey; - - _GroupedAlbum({ - required this.albumName, - required this.artistName, - this.coverUrl, - required this.sampleFilePath, - required this.tracks, - required this.latestDownload, - }) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; - - String get key => '$albumName|$artistName'; -} - -class _GroupedLocalAlbum { - final String albumName; - final String artistName; - final String? coverPath; - final List tracks; - final DateTime latestScanned; - final String searchKey; - - _GroupedLocalAlbum({ - required this.albumName, - required this.artistName, - this.coverPath, - required this.tracks, - required this.latestScanned, - }) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; - - String get key => '$albumName|$artistName'; -} - -class _HistoryStats { - final Map albumCounts; - final Map localAlbumCounts; - final List<_GroupedAlbum> groupedAlbums; - final List<_GroupedLocalAlbum> groupedLocalAlbums; - final int albumCount; - final int singleTracks; - final int localAlbumCount; - final int localSingleTracks; - - const _HistoryStats({ - required this.albumCounts, - this.localAlbumCounts = const {}, - required this.groupedAlbums, - this.groupedLocalAlbums = const [], - required this.albumCount, - required this.singleTracks, - this.localAlbumCount = 0, - this.localSingleTracks = 0, - }); - - int get totalAlbumCount => albumCount + localAlbumCount; - - int get totalSingleTracks => singleTracks + localSingleTracks; -} - -class _FilterContentData { - final List historyItems; - final List unifiedItems; - final List filteredUnifiedItems; - final List<_GroupedAlbum> filteredGroupedAlbums; - final List<_GroupedLocalAlbum> filteredGroupedLocalAlbums; - final bool showFilteringIndicator; - - const _FilterContentData({ - required this.historyItems, - required this.unifiedItems, - required this.filteredUnifiedItems, - required this.filteredGroupedAlbums, - required this.filteredGroupedLocalAlbums, - required this.showFilteringIndicator, - }); - - int get totalTrackCount => filteredUnifiedItems.length; - int get totalAlbumCount => - filteredGroupedAlbums.length + filteredGroupedLocalAlbums.length; -} - -class _UnifiedCacheEntry { - final List historyItems; - final List localItems; - final Map localAlbumCounts; - final String query; - final List items; - - const _UnifiedCacheEntry({ - required this.historyItems, - required this.localItems, - required this.localAlbumCounts, - required this.query, - required this.items, - }); -} - -class _QueueItemIdsSnapshot { - final List ids; - - const _QueueItemIdsSnapshot(this.ids); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is _QueueItemIdsSnapshot && listEquals(ids, other.ids); - - @override - int get hashCode => Object.hashAll(ids); -} - -class _QueueGroupedAlbumFilterRequest { - final String searchQuery; - final String? filterSource; - final String? filterQuality; - final String? filterFormat; - final String? filterMetadata; - final String sortMode; - - const _QueueGroupedAlbumFilterRequest({ - required this.searchQuery, - required this.filterSource, - required this.filterQuality, - required this.filterFormat, - required this.filterMetadata, - required this.sortMode, - }); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is _QueueGroupedAlbumFilterRequest && - searchQuery == other.searchQuery && - filterSource == other.filterSource && - filterQuality == other.filterQuality && - filterFormat == other.filterFormat && - filterMetadata == other.filterMetadata && - sortMode == other.sortMode; - - @override - int get hashCode => Object.hash( - searchQuery, - filterSource, - filterQuality, - filterFormat, - filterMetadata, - sortMode, - ); -} - -class _QueueHistoryStatsMemoEntry { - final List historyItems; - final List localItems; - final _HistoryStats stats; - - const _QueueHistoryStatsMemoEntry({ - required this.historyItems, - required this.localItems, - required this.stats, - }); -} - -_QueueHistoryStatsMemoEntry? _queueHistoryStatsMemo; - -String _queueHistoryAlbumKey(String albumName, String artistName) { - return '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; -} - -String _queueFileExtLower(String filePath) { - final slashIndex = filePath.lastIndexOf('/'); - final dotIndex = filePath.lastIndexOf('.'); - if (dotIndex == -1 || dotIndex < slashIndex + 1) { - return ''; - } - return filePath.substring(dotIndex + 1).toLowerCase(); -} - -bool _queueHasMetadataValue(String? value) { - return value != null && value.trim().isNotEmpty; -} - -String _queueNormalizedMetadataValue(String? value) { - return value?.trim().toLowerCase() ?? ''; -} - -DateTime? _queueParseReleaseDate(String? value) { - final trimmed = value?.trim() ?? ''; - if (trimmed.isEmpty) { - return null; - } - - final parsed = DateTime.tryParse(trimmed); - if (parsed != null) { - return parsed; - } - - final yearMatch = RegExp(r'(\d{4})').firstMatch(trimmed); - if (yearMatch == null) { - return null; - } - - final year = int.tryParse(yearMatch.group(1)!); - if (year == null || year <= 0) { - return null; - } - return DateTime(year); -} - -bool _queueMatchesMetadataFilter({ - required String? filterMetadata, - required String? albumArtist, - required String? releaseDate, - required String? genre, -}) { - if (filterMetadata == null) { - return true; - } - - final hasAlbumArtist = _queueHasMetadataValue(albumArtist); - final hasReleaseDate = _queueParseReleaseDate(releaseDate) != null; - final hasGenre = _queueHasMetadataValue(genre); - final isComplete = hasAlbumArtist && hasReleaseDate && hasGenre; - - switch (filterMetadata) { - case 'complete': - return isComplete; - case 'missing-any': - return !isComplete; - case 'missing-year': - return !hasReleaseDate; - case 'missing-genre': - return !hasGenre; - case 'missing-album-artist': - return !hasAlbumArtist; - default: - return true; - } -} - -bool _queueUnifiedItemMatchesMetadataFilter( - UnifiedLibraryItem item, - String? filterMetadata, -) { - return _queueMatchesMetadataFilter( - filterMetadata: filterMetadata, - albumArtist: item.albumArtist, - releaseDate: item.releaseDate, - genre: item.genre, - ); -} - -int _queueCompareOptionalText( - String? left, - String? right, { - bool descending = false, -}) { - final normalizedLeft = _queueNormalizedMetadataValue(left); - final normalizedRight = _queueNormalizedMetadataValue(right); - final leftEmpty = normalizedLeft.isEmpty; - final rightEmpty = normalizedRight.isEmpty; - - if (leftEmpty && rightEmpty) { - return 0; - } - if (leftEmpty) { - return 1; - } - if (rightEmpty) { - return -1; - } - - final comparison = normalizedLeft.compareTo(normalizedRight); - return descending ? -comparison : comparison; -} - -int _queueCompareOptionalDate( - DateTime? left, - DateTime? right, { - bool descending = false, -}) { - if (left == null && right == null) { - return 0; - } - if (left == null) { - return 1; - } - if (right == null) { - return -1; - } - - final comparison = left.compareTo(right); - return descending ? -comparison : comparison; -} - -DateTime? _queueGroupedAlbumReleaseDate(_GroupedAlbum album) { - for (final track in album.tracks) { - final releaseDate = _queueParseReleaseDate(track.releaseDate); - if (releaseDate != null) { - return releaseDate; - } - } - return null; -} - -DateTime? _queueGroupedLocalAlbumReleaseDate(_GroupedLocalAlbum album) { - for (final track in album.tracks) { - final releaseDate = _queueParseReleaseDate(track.releaseDate); - if (releaseDate != null) { - return releaseDate; - } - } - return null; -} - -String? _queueGroupedAlbumGenre(_GroupedAlbum album) { - for (final track in album.tracks) { - if (_queueHasMetadataValue(track.genre)) { - return track.genre; - } - } - return null; -} - -String? _queueGroupedLocalAlbumGenre(_GroupedLocalAlbum album) { - for (final track in album.tracks) { - if (_queueHasMetadataValue(track.genre)) { - return track.genre; - } - } - return null; -} - -String? _queueLocalQualityLabel(LocalLibraryItem item) { - if (item.bitrate != null && item.bitrate! > 0) { - return '${item.bitrate}kbps'; - } - if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) { - return null; - } - return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; -} - -bool _queuePassesQualityFilter(String? filterQuality, String? quality) { - if (filterQuality == null) return true; - if (quality == null) return filterQuality == 'lossy'; - final normalized = quality.toLowerCase(); - switch (filterQuality) { - case 'hires': - return normalized.startsWith('24'); - case 'cd': - return normalized.startsWith('16'); - case 'lossy': - return !normalized.startsWith('24') && !normalized.startsWith('16'); - default: - return true; - } -} - -bool _queuePassesFormatFilter(String? filterFormat, String filePath) { - if (filterFormat == null) return true; - return _queueFileExtLower(filePath) == filterFormat; -} - -_HistoryStats _buildQueueHistoryStats( - List items, [ - List localItems = const [], -]) { - final memo = _queueHistoryStatsMemo; - if (memo != null && - identical(memo.historyItems, items) && - identical(memo.localItems, localItems)) { - return memo.stats; - } - - final albumCounts = {}; - final albumMap = >{}; - for (final item in items) { - final key = _queueHistoryAlbumKey( - item.albumName, - item.albumArtist ?? item.artistName, - ); - albumCounts[key] = (albumCounts[key] ?? 0) + 1; - albumMap.putIfAbsent(key, () => []).add(item); - } - - var singleTracks = 0; - var albumCount = 0; - for (final count in albumCounts.values) { - if (count > 1) { - albumCount++; - } else { - singleTracks += count; - } - } - - final groupedAlbums = <_GroupedAlbum>[]; - albumMap.forEach((_, tracks) { - if (tracks.length <= 1) return; - tracks.sort((a, b) { - final aNum = a.trackNumber ?? 999; - final bNum = b.trackNumber ?? 999; - return aNum.compareTo(bNum); - }); - - groupedAlbums.add( - _GroupedAlbum( - albumName: tracks.first.albumName, - artistName: tracks.first.albumArtist ?? tracks.first.artistName, - coverUrl: tracks.first.coverUrl, - sampleFilePath: tracks.first.filePath, - tracks: tracks, - latestDownload: tracks - .map((t) => t.downloadedAt) - .reduce((a, b) => a.isAfter(b) ? a : b), - ), - ); - }); - groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload)); - - final downloadedPathKeys = {}; - for (final item in items) { - downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath)); - } - - final dedupedLocalItems = localItems - .where((item) { - final localPathKeys = buildPathMatchKeys(item.filePath); - return !localPathKeys.any(downloadedPathKeys.contains); - }) - .toList(growable: false); - - final localAlbumCounts = {}; - final localAlbumMap = >{}; - for (final item in dedupedLocalItems) { - final key = _queueHistoryAlbumKey( - item.albumName, - item.albumArtist ?? item.artistName, - ); - localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1; - localAlbumMap.putIfAbsent(key, () => []).add(item); - } - - var localAlbumCount = 0; - var localSingleTracks = 0; - for (final count in localAlbumCounts.values) { - if (count > 1) { - localAlbumCount++; - } else { - localSingleTracks++; - } - } - - final groupedLocalAlbums = <_GroupedLocalAlbum>[]; - localAlbumMap.forEach((_, tracks) { - if (tracks.length <= 1) return; - tracks.sort((a, b) { - final aNum = a.trackNumber ?? 999; - final bNum = b.trackNumber ?? 999; - return aNum.compareTo(bNum); - }); - - groupedLocalAlbums.add( - _GroupedLocalAlbum( - albumName: tracks.first.albumName, - artistName: tracks.first.albumArtist ?? tracks.first.artistName, - coverPath: tracks - .firstWhere( - (t) => t.coverPath != null && t.coverPath!.isNotEmpty, - orElse: () => tracks.first, - ) - .coverPath, - tracks: tracks, - latestScanned: tracks - .map((t) => t.scannedAt) - .reduce((a, b) => a.isAfter(b) ? a : b), - ), - ); - }); - groupedLocalAlbums.sort((a, b) => b.latestScanned.compareTo(a.latestScanned)); - - final stats = _HistoryStats( - albumCounts: albumCounts, - localAlbumCounts: localAlbumCounts, - groupedAlbums: groupedAlbums, - groupedLocalAlbums: groupedLocalAlbums, - albumCount: albumCount, - singleTracks: singleTracks, - localAlbumCount: localAlbumCount, - localSingleTracks: localSingleTracks, - ); - _queueHistoryStatsMemo = _QueueHistoryStatsMemoEntry( - historyItems: items, - localItems: localItems, - stats: stats, - ); - return stats; -} - -List<_GroupedAlbum> _queueFilterGroupedAlbums( - List<_GroupedAlbum> albums, - _QueueGroupedAlbumFilterRequest request, -) { - if (request.filterSource == 'local') return const []; - if (request.filterSource == null && - request.filterQuality == null && - request.filterFormat == null && - request.filterMetadata == null && - request.searchQuery.isEmpty && - request.sortMode == 'latest') { - return albums; - } - - final result = <_GroupedAlbum>[]; - for (final album in albums) { - if (request.searchQuery.isNotEmpty && - !album.searchKey.contains(request.searchQuery)) { - continue; - } - - if (request.filterQuality != null || - request.filterFormat != null || - request.filterMetadata != null) { - var hasMatchingTrack = false; - for (final track in album.tracks) { - if (!_queuePassesQualityFilter(request.filterQuality, track.quality)) { - continue; - } - if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) { - continue; - } - if (!_queueMatchesMetadataFilter( - filterMetadata: request.filterMetadata, - albumArtist: track.albumArtist, - releaseDate: track.releaseDate, - genre: track.genre, - )) { - continue; - } - hasMatchingTrack = true; - break; - } - if (!hasMatchingTrack) continue; - } - - result.add(album); - } - - switch (request.sortMode) { - case 'oldest': - result.sort((a, b) => a.latestDownload.compareTo(b.latestDownload)); - case 'artist-asc': - result.sort((a, b) { - final comparison = _queueCompareOptionalText( - a.artistName, - b.artistName, - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'artist-desc': - result.sort((a, b) { - final comparison = _queueCompareOptionalText( - a.artistName, - b.artistName, - descending: true, - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'a-z': - result.sort( - (a, b) => - a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()), - ); - case 'z-a': - result.sort( - (a, b) => - b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()), - ); - case 'album-asc': - result.sort( - (a, b) => _queueCompareOptionalText(a.albumName, b.albumName), - ); - case 'album-desc': - result.sort( - (a, b) => _queueCompareOptionalText( - a.albumName, - b.albumName, - descending: true, - ), - ); - case 'release-oldest': - result.sort((a, b) { - final comparison = _queueCompareOptionalDate( - _queueGroupedAlbumReleaseDate(a), - _queueGroupedAlbumReleaseDate(b), - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'release-newest': - result.sort((a, b) { - final comparison = _queueCompareOptionalDate( - _queueGroupedAlbumReleaseDate(a), - _queueGroupedAlbumReleaseDate(b), - descending: true, - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'genre-asc': - result.sort((a, b) { - final comparison = _queueCompareOptionalText( - _queueGroupedAlbumGenre(a), - _queueGroupedAlbumGenre(b), - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'genre-desc': - result.sort((a, b) { - final comparison = _queueCompareOptionalText( - _queueGroupedAlbumGenre(a), - _queueGroupedAlbumGenre(b), - descending: true, - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - default: - break; - } - return result; -} - -List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums( - List<_GroupedLocalAlbum> albums, - _QueueGroupedAlbumFilterRequest request, -) { - if (request.filterSource == 'downloaded') return const []; - if (request.filterSource == null && - request.filterQuality == null && - request.filterFormat == null && - request.filterMetadata == null && - request.searchQuery.isEmpty && - request.sortMode == 'latest') { - return albums; - } - - final result = <_GroupedLocalAlbum>[]; - for (final album in albums) { - if (request.searchQuery.isNotEmpty && - !album.searchKey.contains(request.searchQuery)) { - continue; - } - - if (request.filterQuality != null || - request.filterFormat != null || - request.filterMetadata != null) { - var hasMatchingTrack = false; - for (final track in album.tracks) { - if (!_queuePassesQualityFilter( - request.filterQuality, - _queueLocalQualityLabel(track), - )) { - continue; - } - if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) { - continue; - } - if (!_queueMatchesMetadataFilter( - filterMetadata: request.filterMetadata, - albumArtist: track.albumArtist, - releaseDate: track.releaseDate, - genre: track.genre, - )) { - continue; - } - hasMatchingTrack = true; - break; - } - if (!hasMatchingTrack) continue; - } - - result.add(album); - } - - switch (request.sortMode) { - case 'oldest': - result.sort((a, b) => a.latestScanned.compareTo(b.latestScanned)); - case 'artist-asc': - result.sort((a, b) { - final comparison = _queueCompareOptionalText( - a.artistName, - b.artistName, - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'artist-desc': - result.sort((a, b) { - final comparison = _queueCompareOptionalText( - a.artistName, - b.artistName, - descending: true, - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'a-z': - result.sort( - (a, b) => - a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()), - ); - case 'z-a': - result.sort( - (a, b) => - b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()), - ); - case 'album-asc': - result.sort( - (a, b) => _queueCompareOptionalText(a.albumName, b.albumName), - ); - case 'album-desc': - result.sort( - (a, b) => _queueCompareOptionalText( - a.albumName, - b.albumName, - descending: true, - ), - ); - case 'release-oldest': - result.sort((a, b) { - final comparison = _queueCompareOptionalDate( - _queueGroupedLocalAlbumReleaseDate(a), - _queueGroupedLocalAlbumReleaseDate(b), - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'release-newest': - result.sort((a, b) { - final comparison = _queueCompareOptionalDate( - _queueGroupedLocalAlbumReleaseDate(a), - _queueGroupedLocalAlbumReleaseDate(b), - descending: true, - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'genre-asc': - result.sort((a, b) { - final comparison = _queueCompareOptionalText( - _queueGroupedLocalAlbumGenre(a), - _queueGroupedLocalAlbumGenre(b), - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'genre-desc': - result.sort((a, b) { - final comparison = _queueCompareOptionalText( - _queueGroupedLocalAlbumGenre(a), - _queueGroupedLocalAlbumGenre(b), - descending: true, - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - default: - break; - } - return result; -} - -final _queueHistoryStatsProvider = Provider<_HistoryStats>((ref) { - final historyItems = ref.watch( - downloadHistoryProvider.select((s) => s.items), - ); - final localLibraryEnabled = ref.watch( - settingsProvider.select((s) => s.localLibraryEnabled), - ); - final localItems = localLibraryEnabled - ? ref.watch(localLibraryProvider.select((s) => s.items)) - : const []; - return _buildQueueHistoryStats(historyItems, localItems); -}); - -final _queueFilteredAlbumsProvider = - Provider.family< - ({List<_GroupedAlbum> albums, List<_GroupedLocalAlbum> localAlbums}), - _QueueGroupedAlbumFilterRequest - >((ref, request) { - final historyStats = ref.watch(_queueHistoryStatsProvider); - return ( - albums: _queueFilterGroupedAlbums(historyStats.groupedAlbums, request), - localAlbums: _queueFilterGroupedLocalAlbums( - historyStats.groupedLocalAlbums, - request, - ), - ); - }); - -Map> _filterHistoryInIsolate(Map payload) { - final entries = (payload['entries'] as List).cast>(); - final albumCounts = Map.from(payload['albumCounts'] as Map); - final query = (payload['query'] as String?) ?? ''; - final hasQuery = query.isNotEmpty; - - final allIds = []; - final albumIds = []; - final singleIds = []; - - for (final entry in entries) { - final id = entry[0] as String; - final albumKey = entry[1] as String; - if (hasQuery) { - final searchKey = entry[2] as String; - if (!searchKey.contains(query)) { - continue; - } - } - - allIds.add(id); - final count = albumCounts[albumKey] ?? 0; - if (count > 1) { - albumIds.add(id); - } else if (count == 1) { - singleIds.add(id); - } - } - - return {'all': allIds, 'albums': albumIds, 'singles': singleIds}; -} +part 'queue_tab_helpers.dart'; +part 'queue_tab_widgets.dart'; class QueueTab extends ConsumerStatefulWidget { final PageController? parentPageController; @@ -1085,11 +58,8 @@ class QueueTab extends ConsumerStatefulWidget { } class _QueueTabState extends ConsumerState { - final Map _fileExistsCache = {}; - final Map> _fileExistsNotifiers = {}; - final ValueNotifier _alwaysMissingFileNotifier = ValueNotifier(false); - final Set _pendingChecks = {}; - static const int _maxCacheSize = 500; + final _FileExistsListenableCache _fileExistsCache = + _FileExistsListenableCache(); static const int _maxSearchIndexCacheSize = 4000; bool _embeddedCoverRefreshScheduled = false; // Version counter to trigger targeted cover image rebuilds @@ -1204,11 +174,7 @@ class _QueueTabState extends ConsumerState { void dispose() { _hideSelectionOverlay(); _hidePlaylistSelectionOverlay(); - for (final notifier in _fileExistsNotifiers.values) { - notifier.dispose(); - } - _fileExistsNotifiers.clear(); - _alwaysMissingFileNotifier.dispose(); + _fileExistsCache.dispose(); _embeddedCoverVersion.dispose(); _filterPageController?.dispose(); _searchController.dispose(); @@ -2238,57 +1204,7 @@ class _QueueTabState extends ConsumerState { } ValueListenable _fileExistsListenable(String? filePath) { - if (filePath == null) return _alwaysMissingFileNotifier; - final cleanPath = _cleanFilePath(filePath); - if (cleanPath.isEmpty) return _alwaysMissingFileNotifier; - - final existingNotifier = _fileExistsNotifiers[cleanPath]; - if (existingNotifier != null) { - final cached = _fileExistsCache[cleanPath]; - if (cached != null && existingNotifier.value != cached) { - existingNotifier.value = cached; - } else if (cached == null) { - _startFileExistsCheck(cleanPath); - } - return existingNotifier; - } - - if (_fileExistsNotifiers.length >= _maxCacheSize) { - final oldestKey = _fileExistsNotifiers.keys.first; - _fileExistsNotifiers.remove(oldestKey)?.dispose(); - _fileExistsCache.remove(oldestKey); - } - - final notifier = ValueNotifier(_fileExistsCache[cleanPath] ?? true); - _fileExistsNotifiers[cleanPath] = notifier; - _startFileExistsCheck(cleanPath); - return notifier; - } - - void _startFileExistsCheck(String cleanPath) { - if (_pendingChecks.contains(cleanPath)) { - return; - } - - final cached = _fileExistsCache[cleanPath]; - if (cached != null) { - final notifier = _fileExistsNotifiers[cleanPath]; - if (notifier != null && notifier.value != cached) { - notifier.value = cached; - } - return; - } - - _pendingChecks.add(cleanPath); - Future.microtask(() async { - final exists = await fileExists(cleanPath); - _pendingChecks.remove(cleanPath); - _fileExistsCache[cleanPath] = exists; - final notifier = _fileExistsNotifiers[cleanPath]; - if (notifier != null && notifier.value != exists) { - notifier.value = exists; - } - }); + return _fileExistsCache.listenable(filePath); } int get _activeFilterCount { @@ -6769,10 +5685,7 @@ class _QueueTabState extends ConsumerState { : colorScheme.onSecondaryContainer; return Semantics( - label: context.l10n.a11yTrackByArtist( - item.trackName, - item.artistName, - ), + label: context.l10n.a11yTrackByArtist(item.trackName, item.artistName), selected: isSelected, child: Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), @@ -7174,202 +6087,3 @@ class _QueueTabState extends ConsumerState { ); } } - -class _QueueItemSliverRow extends ConsumerWidget { - final String itemId; - final ColorScheme colorScheme; - final Widget Function(BuildContext, DownloadItem, ColorScheme) itemBuilder; - - const _QueueItemSliverRow({ - super.key, - required this.itemId, - required this.colorScheme, - required this.itemBuilder, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final item = ref.watch( - downloadQueueLookupProvider.select((lookup) => lookup.byItemId[itemId]), - ); - if (item == null) { - return const SizedBox.shrink(); - } - - return RepaintBoundary(child: itemBuilder(context, item, colorScheme)); - } -} - -enum _CollectionEntryType { wishlist, loved, playlist } - -class _CollectionEntry { - final _CollectionEntryType type; - final int playlistIndex; - - const _CollectionEntry._(this.type, [this.playlistIndex = -1]); - - static const wishlist = _CollectionEntry._(_CollectionEntryType.wishlist); - static const loved = _CollectionEntry._(_CollectionEntryType.loved); - static _CollectionEntry playlist(int index) => - _CollectionEntry._(_CollectionEntryType.playlist, index); -} - -class _FilterChip extends StatelessWidget { - final String label; - final int count; - final bool isSelected; - final VoidCallback onTap; - - const _FilterChip({ - required this.label, - required this.count, - required this.isSelected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return FilterChip( - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(label), - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary.withValues(alpha: 0.2) - : colorScheme.outline.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - count.toString(), - style: TextStyle( - fontSize: 11, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - selected: isSelected, - onSelected: (_) => onTap(), - showCheckmark: false, - ); - } -} - -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) { - final isDisabled = onPressed == null; - return Material( - color: isDisabled - ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) - : colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(14), - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(14), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: 18, - color: isDisabled - ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) - : colorScheme.onSecondaryContainer, - ), - const SizedBox(width: 6), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: isDisabled - ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) - : colorScheme.onSecondaryContainer, - ), - ), - ), - ], - ), - ), - ), - ); - } -} - -class _AnimatedOverlayBottomBar extends StatefulWidget { - final Widget child; - - const _AnimatedOverlayBottomBar({required this.child}); - - @override - State<_AnimatedOverlayBottomBar> createState() => - _AnimatedOverlayBottomBarState(); -} - -class _AnimatedOverlayBottomBarState extends State<_AnimatedOverlayBottomBar> - with SingleTickerProviderStateMixin { - late final AnimationController _controller; - late final Animation _slideAnimation; - late final Animation _fadeAnimation; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 240), - ); - final curve = CurvedAnimation( - parent: _controller, - curve: Curves.easeOutCubic, - ); - _slideAnimation = Tween( - begin: const Offset(0, 0.08), - end: Offset.zero, - ).animate(curve); - _fadeAnimation = Tween(begin: 0, end: 1).animate(curve); - _controller.forward(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition(position: _slideAnimation, child: widget.child), - ); - } -} diff --git a/lib/screens/queue_tab_helpers.dart b/lib/screens/queue_tab_helpers.dart new file mode 100644 index 00000000..19e77d3d --- /dev/null +++ b/lib/screens/queue_tab_helpers.dart @@ -0,0 +1,1101 @@ +part of 'queue_tab.dart'; + +enum LibraryItemSource { downloaded, local } + +class UnifiedLibraryItem { + final String id; + final String trackName; + final String artistName; + final String albumName; + final String? coverUrl; + final String? localCoverPath; + final String filePath; + final String? quality; + final DateTime addedAt; + final LibraryItemSource source; + + final DownloadHistoryItem? historyItem; + final LocalLibraryItem? localItem; + + UnifiedLibraryItem({ + required this.id, + required this.trackName, + required this.artistName, + required this.albumName, + this.coverUrl, + this.localCoverPath, + required this.filePath, + this.quality, + required this.addedAt, + required this.source, + this.historyItem, + this.localItem, + }); + + factory UnifiedLibraryItem.fromDownloadHistory(DownloadHistoryItem item) { + return UnifiedLibraryItem( + id: 'dl_${item.id}', + trackName: item.trackName, + artistName: item.artistName, + albumName: item.albumName, + coverUrl: item.coverUrl, + filePath: item.filePath, + quality: buildDisplayAudioQuality( + bitDepth: item.bitDepth, + sampleRate: item.sampleRate, + storedQuality: item.quality, + ), + addedAt: item.downloadedAt, + source: LibraryItemSource.downloaded, + historyItem: item, + ); + } + + factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) { + String? quality; + if (item.bitrate != null && item.bitrate! > 0) { + quality = buildDisplayAudioQuality( + bitrateKbps: item.bitrate, + format: item.format, + ); + } else if (item.bitDepth != null && + item.bitDepth! > 0 && + item.sampleRate != null) { + quality = buildDisplayAudioQuality( + bitDepth: item.bitDepth, + sampleRate: item.sampleRate, + ); + } + return UnifiedLibraryItem( + id: 'local_${item.id}', + trackName: item.trackName, + artistName: item.artistName, + albumName: item.albumName, + coverUrl: null, + localCoverPath: item.coverPath, + filePath: item.filePath, + quality: quality, + addedAt: item.fileModTime != null + ? DateTime.fromMillisecondsSinceEpoch(item.fileModTime!) + : item.scannedAt, + source: LibraryItemSource.local, + localItem: item, + ); + } + + bool get hasCover => + coverUrl != null || + (localCoverPath != null && localCoverPath!.isNotEmpty); + + String? get albumArtist => historyItem?.albumArtist ?? localItem?.albumArtist; + + String? get releaseDate => historyItem?.releaseDate ?? localItem?.releaseDate; + + String? get genre => historyItem?.genre ?? localItem?.genre; + + String get searchKey => + '${trackName.toLowerCase()}|${artistName.toLowerCase()}|${albumName.toLowerCase()}'; + String get albumKey => + '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; + + /// Returns the collection key used to match this item against playlist + /// entries. Uses the same logic as [trackCollectionKey] from the collections + /// provider: prefer ISRC, fall back to source:id. + String get collectionKey { + if (historyItem != null) { + final isrc = historyItem!.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}'; + final source = historyItem!.service.trim().isNotEmpty + ? historyItem!.service.trim() + : 'builtin'; + return '$source:${historyItem!.id}'; + } + if (localItem != null) { + final isrc = localItem!.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}'; + return 'local:${localItem!.id}'; + } + return 'builtin:$id'; + } + + Track toTrack() { + if (historyItem != null) { + final h = historyItem!; + return Track( + id: h.id, + name: h.trackName, + artistName: h.artistName, + albumName: h.albumName, + albumArtist: h.albumArtist, + coverUrl: h.coverUrl, + isrc: h.isrc, + duration: h.duration ?? 0, + trackNumber: h.trackNumber, + discNumber: h.discNumber, + releaseDate: h.releaseDate, + source: h.service, + ); + } + if (localItem != null) { + final l = localItem!; + return Track( + id: l.id, + name: l.trackName, + artistName: l.artistName, + albumName: l.albumName, + albumArtist: l.albumArtist, + coverUrl: l.coverPath, + isrc: l.isrc, + duration: l.duration ?? 0, + trackNumber: l.trackNumber, + discNumber: l.discNumber, + releaseDate: l.releaseDate, + source: 'local', + ); + } + return Track( + id: id, + name: trackName, + artistName: artistName, + albumName: albumName, + coverUrl: coverUrl, + duration: 0, + ); + } +} + +class _GroupedAlbum { + final String albumName; + final String artistName; + final String? coverUrl; + final String sampleFilePath; + final List tracks; + final DateTime latestDownload; + final String searchKey; + + _GroupedAlbum({ + required this.albumName, + required this.artistName, + this.coverUrl, + required this.sampleFilePath, + required this.tracks, + required this.latestDownload, + }) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; + + String get key => '$albumName|$artistName'; +} + +class _GroupedLocalAlbum { + final String albumName; + final String artistName; + final String? coverPath; + final List tracks; + final DateTime latestScanned; + final String searchKey; + + _GroupedLocalAlbum({ + required this.albumName, + required this.artistName, + this.coverPath, + required this.tracks, + required this.latestScanned, + }) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; + + String get key => '$albumName|$artistName'; +} + +class _HistoryStats { + final Map albumCounts; + final Map localAlbumCounts; + final List<_GroupedAlbum> groupedAlbums; + final List<_GroupedLocalAlbum> groupedLocalAlbums; + final int albumCount; + final int singleTracks; + final int localAlbumCount; + final int localSingleTracks; + + const _HistoryStats({ + required this.albumCounts, + this.localAlbumCounts = const {}, + required this.groupedAlbums, + this.groupedLocalAlbums = const [], + required this.albumCount, + required this.singleTracks, + this.localAlbumCount = 0, + this.localSingleTracks = 0, + }); + + int get totalAlbumCount => albumCount + localAlbumCount; + + int get totalSingleTracks => singleTracks + localSingleTracks; +} + +class _FilterContentData { + final List historyItems; + final List unifiedItems; + final List filteredUnifiedItems; + final List<_GroupedAlbum> filteredGroupedAlbums; + final List<_GroupedLocalAlbum> filteredGroupedLocalAlbums; + final bool showFilteringIndicator; + + const _FilterContentData({ + required this.historyItems, + required this.unifiedItems, + required this.filteredUnifiedItems, + required this.filteredGroupedAlbums, + required this.filteredGroupedLocalAlbums, + required this.showFilteringIndicator, + }); + + int get totalTrackCount => filteredUnifiedItems.length; + int get totalAlbumCount => + filteredGroupedAlbums.length + filteredGroupedLocalAlbums.length; +} + +class _UnifiedCacheEntry { + final List historyItems; + final List localItems; + final Map localAlbumCounts; + final String query; + final List items; + + const _UnifiedCacheEntry({ + required this.historyItems, + required this.localItems, + required this.localAlbumCounts, + required this.query, + required this.items, + }); +} + +class _QueueItemIdsSnapshot { + final List ids; + + const _QueueItemIdsSnapshot(this.ids); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _QueueItemIdsSnapshot && listEquals(ids, other.ids); + + @override + int get hashCode => Object.hashAll(ids); +} + +class _QueueGroupedAlbumFilterRequest { + final String searchQuery; + final String? filterSource; + final String? filterQuality; + final String? filterFormat; + final String? filterMetadata; + final String sortMode; + + const _QueueGroupedAlbumFilterRequest({ + required this.searchQuery, + required this.filterSource, + required this.filterQuality, + required this.filterFormat, + required this.filterMetadata, + required this.sortMode, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _QueueGroupedAlbumFilterRequest && + searchQuery == other.searchQuery && + filterSource == other.filterSource && + filterQuality == other.filterQuality && + filterFormat == other.filterFormat && + filterMetadata == other.filterMetadata && + sortMode == other.sortMode; + + @override + int get hashCode => Object.hash( + searchQuery, + filterSource, + filterQuality, + filterFormat, + filterMetadata, + sortMode, + ); +} + +class _QueueHistoryStatsMemoEntry { + final List historyItems; + final List localItems; + final _HistoryStats stats; + + const _QueueHistoryStatsMemoEntry({ + required this.historyItems, + required this.localItems, + required this.stats, + }); +} + +_QueueHistoryStatsMemoEntry? _queueHistoryStatsMemo; + +class _FileExistsListenableCache { + static const int _maxCacheSize = 500; + + final Map _cache = {}; + final Map> _notifiers = {}; + final ValueNotifier _alwaysMissingNotifier = ValueNotifier(false); + final Set _pendingChecks = {}; + + ValueListenable listenable(String? filePath) { + final cleanPath = DownloadedEmbeddedCoverResolver.cleanFilePath(filePath); + if (cleanPath.isEmpty) return _alwaysMissingNotifier; + + final existingNotifier = _notifiers[cleanPath]; + if (existingNotifier != null) { + final cached = _cache[cleanPath]; + if (cached != null && existingNotifier.value != cached) { + existingNotifier.value = cached; + } else if (cached == null) { + _startCheck(cleanPath); + } + return existingNotifier; + } + + if (_notifiers.length >= _maxCacheSize) { + final oldestKey = _notifiers.keys.first; + _notifiers.remove(oldestKey)?.dispose(); + _cache.remove(oldestKey); + } + + final notifier = ValueNotifier(_cache[cleanPath] ?? true); + _notifiers[cleanPath] = notifier; + _startCheck(cleanPath); + return notifier; + } + + void _startCheck(String cleanPath) { + if (_pendingChecks.contains(cleanPath)) { + return; + } + + final cached = _cache[cleanPath]; + if (cached != null) { + final notifier = _notifiers[cleanPath]; + if (notifier != null && notifier.value != cached) { + notifier.value = cached; + } + return; + } + + _pendingChecks.add(cleanPath); + Future.microtask(() async { + final exists = await fileExists(cleanPath); + _pendingChecks.remove(cleanPath); + _cache[cleanPath] = exists; + final notifier = _notifiers[cleanPath]; + if (notifier != null && notifier.value != exists) { + notifier.value = exists; + } + }); + } + + void dispose() { + for (final notifier in _notifiers.values) { + notifier.dispose(); + } + _notifiers.clear(); + _alwaysMissingNotifier.dispose(); + } +} + +String _queueHistoryAlbumKey(String albumName, String artistName) { + return '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; +} + +String _queueFileExtLower(String filePath) { + final slashIndex = filePath.lastIndexOf('/'); + final dotIndex = filePath.lastIndexOf('.'); + if (dotIndex == -1 || dotIndex < slashIndex + 1) { + return ''; + } + return filePath.substring(dotIndex + 1).toLowerCase(); +} + +bool _queueHasMetadataValue(String? value) { + return value != null && value.trim().isNotEmpty; +} + +String _queueNormalizedMetadataValue(String? value) { + return value?.trim().toLowerCase() ?? ''; +} + +DateTime? _queueParseReleaseDate(String? value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return null; + } + + final parsed = DateTime.tryParse(trimmed); + if (parsed != null) { + return parsed; + } + + final yearMatch = RegExp(r'(\d{4})').firstMatch(trimmed); + if (yearMatch == null) { + return null; + } + + final year = int.tryParse(yearMatch.group(1)!); + if (year == null || year <= 0) { + return null; + } + return DateTime(year); +} + +bool _queueMatchesMetadataFilter({ + required String? filterMetadata, + required String? albumArtist, + required String? releaseDate, + required String? genre, +}) { + if (filterMetadata == null) { + return true; + } + + final hasAlbumArtist = _queueHasMetadataValue(albumArtist); + final hasReleaseDate = _queueParseReleaseDate(releaseDate) != null; + final hasGenre = _queueHasMetadataValue(genre); + final isComplete = hasAlbumArtist && hasReleaseDate && hasGenre; + + switch (filterMetadata) { + case 'complete': + return isComplete; + case 'missing-any': + return !isComplete; + case 'missing-year': + return !hasReleaseDate; + case 'missing-genre': + return !hasGenre; + case 'missing-album-artist': + return !hasAlbumArtist; + default: + return true; + } +} + +bool _queueUnifiedItemMatchesMetadataFilter( + UnifiedLibraryItem item, + String? filterMetadata, +) { + return _queueMatchesMetadataFilter( + filterMetadata: filterMetadata, + albumArtist: item.albumArtist, + releaseDate: item.releaseDate, + genre: item.genre, + ); +} + +int _queueCompareOptionalText( + String? left, + String? right, { + bool descending = false, +}) { + final normalizedLeft = _queueNormalizedMetadataValue(left); + final normalizedRight = _queueNormalizedMetadataValue(right); + final leftEmpty = normalizedLeft.isEmpty; + final rightEmpty = normalizedRight.isEmpty; + + if (leftEmpty && rightEmpty) { + return 0; + } + if (leftEmpty) { + return 1; + } + if (rightEmpty) { + return -1; + } + + final comparison = normalizedLeft.compareTo(normalizedRight); + return descending ? -comparison : comparison; +} + +int _queueCompareOptionalDate( + DateTime? left, + DateTime? right, { + bool descending = false, +}) { + if (left == null && right == null) { + return 0; + } + if (left == null) { + return 1; + } + if (right == null) { + return -1; + } + + final comparison = left.compareTo(right); + return descending ? -comparison : comparison; +} + +DateTime? _queueGroupedAlbumReleaseDate(_GroupedAlbum album) { + for (final track in album.tracks) { + final releaseDate = _queueParseReleaseDate(track.releaseDate); + if (releaseDate != null) { + return releaseDate; + } + } + return null; +} + +DateTime? _queueGroupedLocalAlbumReleaseDate(_GroupedLocalAlbum album) { + for (final track in album.tracks) { + final releaseDate = _queueParseReleaseDate(track.releaseDate); + if (releaseDate != null) { + return releaseDate; + } + } + return null; +} + +String? _queueGroupedAlbumGenre(_GroupedAlbum album) { + for (final track in album.tracks) { + if (_queueHasMetadataValue(track.genre)) { + return track.genre; + } + } + return null; +} + +String? _queueGroupedLocalAlbumGenre(_GroupedLocalAlbum album) { + for (final track in album.tracks) { + if (_queueHasMetadataValue(track.genre)) { + return track.genre; + } + } + return null; +} + +String? _queueLocalQualityLabel(LocalLibraryItem item) { + if (item.bitrate != null && item.bitrate! > 0) { + return '${item.bitrate}kbps'; + } + if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) { + return null; + } + return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; +} + +bool _queuePassesQualityFilter(String? filterQuality, String? quality) { + if (filterQuality == null) return true; + if (quality == null) return filterQuality == 'lossy'; + final normalized = quality.toLowerCase(); + switch (filterQuality) { + case 'hires': + return normalized.startsWith('24'); + case 'cd': + return normalized.startsWith('16'); + case 'lossy': + return !normalized.startsWith('24') && !normalized.startsWith('16'); + default: + return true; + } +} + +bool _queuePassesFormatFilter(String? filterFormat, String filePath) { + if (filterFormat == null) return true; + return _queueFileExtLower(filePath) == filterFormat; +} + +_HistoryStats _buildQueueHistoryStats( + List items, [ + List localItems = const [], +]) { + final memo = _queueHistoryStatsMemo; + if (memo != null && + identical(memo.historyItems, items) && + identical(memo.localItems, localItems)) { + return memo.stats; + } + + final albumCounts = {}; + final albumMap = >{}; + for (final item in items) { + final key = _queueHistoryAlbumKey( + item.albumName, + item.albumArtist ?? item.artistName, + ); + albumCounts[key] = (albumCounts[key] ?? 0) + 1; + albumMap.putIfAbsent(key, () => []).add(item); + } + + var singleTracks = 0; + var albumCount = 0; + for (final count in albumCounts.values) { + if (count > 1) { + albumCount++; + } else { + singleTracks += count; + } + } + + final groupedAlbums = <_GroupedAlbum>[]; + albumMap.forEach((_, tracks) { + if (tracks.length <= 1) return; + tracks.sort((a, b) { + final aNum = a.trackNumber ?? 999; + final bNum = b.trackNumber ?? 999; + return aNum.compareTo(bNum); + }); + + groupedAlbums.add( + _GroupedAlbum( + albumName: tracks.first.albumName, + artistName: tracks.first.albumArtist ?? tracks.first.artistName, + coverUrl: tracks.first.coverUrl, + sampleFilePath: tracks.first.filePath, + tracks: tracks, + latestDownload: tracks + .map((t) => t.downloadedAt) + .reduce((a, b) => a.isAfter(b) ? a : b), + ), + ); + }); + groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload)); + + final downloadedPathKeys = {}; + for (final item in items) { + downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath)); + } + + final dedupedLocalItems = localItems + .where((item) { + final localPathKeys = buildPathMatchKeys(item.filePath); + return !localPathKeys.any(downloadedPathKeys.contains); + }) + .toList(growable: false); + + final localAlbumCounts = {}; + final localAlbumMap = >{}; + for (final item in dedupedLocalItems) { + final key = _queueHistoryAlbumKey( + item.albumName, + item.albumArtist ?? item.artistName, + ); + localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1; + localAlbumMap.putIfAbsent(key, () => []).add(item); + } + + var localAlbumCount = 0; + var localSingleTracks = 0; + for (final count in localAlbumCounts.values) { + if (count > 1) { + localAlbumCount++; + } else { + localSingleTracks++; + } + } + + final groupedLocalAlbums = <_GroupedLocalAlbum>[]; + localAlbumMap.forEach((_, tracks) { + if (tracks.length <= 1) return; + tracks.sort((a, b) { + final aNum = a.trackNumber ?? 999; + final bNum = b.trackNumber ?? 999; + return aNum.compareTo(bNum); + }); + + groupedLocalAlbums.add( + _GroupedLocalAlbum( + albumName: tracks.first.albumName, + artistName: tracks.first.albumArtist ?? tracks.first.artistName, + coverPath: tracks + .firstWhere( + (t) => t.coverPath != null && t.coverPath!.isNotEmpty, + orElse: () => tracks.first, + ) + .coverPath, + tracks: tracks, + latestScanned: tracks + .map((t) => t.scannedAt) + .reduce((a, b) => a.isAfter(b) ? a : b), + ), + ); + }); + groupedLocalAlbums.sort((a, b) => b.latestScanned.compareTo(a.latestScanned)); + + final stats = _HistoryStats( + albumCounts: albumCounts, + localAlbumCounts: localAlbumCounts, + groupedAlbums: groupedAlbums, + groupedLocalAlbums: groupedLocalAlbums, + albumCount: albumCount, + singleTracks: singleTracks, + localAlbumCount: localAlbumCount, + localSingleTracks: localSingleTracks, + ); + _queueHistoryStatsMemo = _QueueHistoryStatsMemoEntry( + historyItems: items, + localItems: localItems, + stats: stats, + ); + return stats; +} + +List<_GroupedAlbum> _queueFilterGroupedAlbums( + List<_GroupedAlbum> albums, + _QueueGroupedAlbumFilterRequest request, +) { + if (request.filterSource == 'local') return const []; + if (request.filterSource == null && + request.filterQuality == null && + request.filterFormat == null && + request.filterMetadata == null && + request.searchQuery.isEmpty && + request.sortMode == 'latest') { + return albums; + } + + final result = <_GroupedAlbum>[]; + for (final album in albums) { + if (request.searchQuery.isNotEmpty && + !album.searchKey.contains(request.searchQuery)) { + continue; + } + + if (request.filterQuality != null || + request.filterFormat != null || + request.filterMetadata != null) { + var hasMatchingTrack = false; + for (final track in album.tracks) { + if (!_queuePassesQualityFilter(request.filterQuality, track.quality)) { + continue; + } + if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) { + continue; + } + if (!_queueMatchesMetadataFilter( + filterMetadata: request.filterMetadata, + albumArtist: track.albumArtist, + releaseDate: track.releaseDate, + genre: track.genre, + )) { + continue; + } + hasMatchingTrack = true; + break; + } + if (!hasMatchingTrack) continue; + } + + result.add(album); + } + + switch (request.sortMode) { + case 'oldest': + result.sort((a, b) => a.latestDownload.compareTo(b.latestDownload)); + case 'artist-asc': + result.sort((a, b) { + final comparison = _queueCompareOptionalText( + a.artistName, + b.artistName, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + case 'artist-desc': + result.sort((a, b) { + final comparison = _queueCompareOptionalText( + a.artistName, + b.artistName, + descending: true, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + case 'a-z': + result.sort( + (a, b) => + a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()), + ); + case 'z-a': + result.sort( + (a, b) => + b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()), + ); + case 'album-asc': + result.sort( + (a, b) => _queueCompareOptionalText(a.albumName, b.albumName), + ); + case 'album-desc': + result.sort( + (a, b) => _queueCompareOptionalText( + a.albumName, + b.albumName, + descending: true, + ), + ); + case 'release-oldest': + result.sort((a, b) { + final comparison = _queueCompareOptionalDate( + _queueGroupedAlbumReleaseDate(a), + _queueGroupedAlbumReleaseDate(b), + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + case 'release-newest': + result.sort((a, b) { + final comparison = _queueCompareOptionalDate( + _queueGroupedAlbumReleaseDate(a), + _queueGroupedAlbumReleaseDate(b), + descending: true, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + case 'genre-asc': + result.sort((a, b) { + final comparison = _queueCompareOptionalText( + _queueGroupedAlbumGenre(a), + _queueGroupedAlbumGenre(b), + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + case 'genre-desc': + result.sort((a, b) { + final comparison = _queueCompareOptionalText( + _queueGroupedAlbumGenre(a), + _queueGroupedAlbumGenre(b), + descending: true, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + default: + break; + } + return result; +} + +List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums( + List<_GroupedLocalAlbum> albums, + _QueueGroupedAlbumFilterRequest request, +) { + if (request.filterSource == 'downloaded') return const []; + if (request.filterSource == null && + request.filterQuality == null && + request.filterFormat == null && + request.filterMetadata == null && + request.searchQuery.isEmpty && + request.sortMode == 'latest') { + return albums; + } + + final result = <_GroupedLocalAlbum>[]; + for (final album in albums) { + if (request.searchQuery.isNotEmpty && + !album.searchKey.contains(request.searchQuery)) { + continue; + } + + if (request.filterQuality != null || + request.filterFormat != null || + request.filterMetadata != null) { + var hasMatchingTrack = false; + for (final track in album.tracks) { + if (!_queuePassesQualityFilter( + request.filterQuality, + _queueLocalQualityLabel(track), + )) { + continue; + } + if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) { + continue; + } + if (!_queueMatchesMetadataFilter( + filterMetadata: request.filterMetadata, + albumArtist: track.albumArtist, + releaseDate: track.releaseDate, + genre: track.genre, + )) { + continue; + } + hasMatchingTrack = true; + break; + } + if (!hasMatchingTrack) continue; + } + + result.add(album); + } + + switch (request.sortMode) { + case 'oldest': + result.sort((a, b) => a.latestScanned.compareTo(b.latestScanned)); + case 'artist-asc': + result.sort((a, b) { + final comparison = _queueCompareOptionalText( + a.artistName, + b.artistName, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + case 'artist-desc': + result.sort((a, b) { + final comparison = _queueCompareOptionalText( + a.artistName, + b.artistName, + descending: true, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + case 'a-z': + result.sort( + (a, b) => + a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()), + ); + case 'z-a': + result.sort( + (a, b) => + b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()), + ); + case 'album-asc': + result.sort( + (a, b) => _queueCompareOptionalText(a.albumName, b.albumName), + ); + case 'album-desc': + result.sort( + (a, b) => _queueCompareOptionalText( + a.albumName, + b.albumName, + descending: true, + ), + ); + case 'release-oldest': + result.sort((a, b) { + final comparison = _queueCompareOptionalDate( + _queueGroupedLocalAlbumReleaseDate(a), + _queueGroupedLocalAlbumReleaseDate(b), + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + case 'release-newest': + result.sort((a, b) { + final comparison = _queueCompareOptionalDate( + _queueGroupedLocalAlbumReleaseDate(a), + _queueGroupedLocalAlbumReleaseDate(b), + descending: true, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + case 'genre-asc': + result.sort((a, b) { + final comparison = _queueCompareOptionalText( + _queueGroupedLocalAlbumGenre(a), + _queueGroupedLocalAlbumGenre(b), + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + case 'genre-desc': + result.sort((a, b) { + final comparison = _queueCompareOptionalText( + _queueGroupedLocalAlbumGenre(a), + _queueGroupedLocalAlbumGenre(b), + descending: true, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + default: + break; + } + return result; +} + +final _queueHistoryStatsProvider = Provider<_HistoryStats>((ref) { + final historyItems = ref.watch( + downloadHistoryProvider.select((s) => s.items), + ); + final localLibraryEnabled = ref.watch( + settingsProvider.select((s) => s.localLibraryEnabled), + ); + final localItems = localLibraryEnabled + ? ref.watch(localLibraryProvider.select((s) => s.items)) + : const []; + return _buildQueueHistoryStats(historyItems, localItems); +}); + +final _queueFilteredAlbumsProvider = + Provider.family< + ({List<_GroupedAlbum> albums, List<_GroupedLocalAlbum> localAlbums}), + _QueueGroupedAlbumFilterRequest + >((ref, request) { + final historyStats = ref.watch(_queueHistoryStatsProvider); + return ( + albums: _queueFilterGroupedAlbums(historyStats.groupedAlbums, request), + localAlbums: _queueFilterGroupedLocalAlbums( + historyStats.groupedLocalAlbums, + request, + ), + ); + }); + +Map> _filterHistoryInIsolate(Map payload) { + final entries = (payload['entries'] as List).cast>(); + final albumCounts = Map.from(payload['albumCounts'] as Map); + final query = (payload['query'] as String?) ?? ''; + final hasQuery = query.isNotEmpty; + + final allIds = []; + final albumIds = []; + final singleIds = []; + + for (final entry in entries) { + final id = entry[0] as String; + final albumKey = entry[1] as String; + if (hasQuery) { + final searchKey = entry[2] as String; + if (!searchKey.contains(query)) { + continue; + } + } + + allIds.add(id); + final count = albumCounts[albumKey] ?? 0; + if (count > 1) { + albumIds.add(id); + } else if (count == 1) { + singleIds.add(id); + } + } + + return {'all': allIds, 'albums': albumIds, 'singles': singleIds}; +} diff --git a/lib/screens/queue_tab_widgets.dart b/lib/screens/queue_tab_widgets.dart new file mode 100644 index 00000000..e53aac52 --- /dev/null +++ b/lib/screens/queue_tab_widgets.dart @@ -0,0 +1,200 @@ +part of 'queue_tab.dart'; + +class _QueueItemSliverRow extends ConsumerWidget { + final String itemId; + final ColorScheme colorScheme; + final Widget Function(BuildContext, DownloadItem, ColorScheme) itemBuilder; + + const _QueueItemSliverRow({ + super.key, + required this.itemId, + required this.colorScheme, + required this.itemBuilder, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final item = ref.watch( + downloadQueueLookupProvider.select((lookup) => lookup.byItemId[itemId]), + ); + if (item == null) { + return const SizedBox.shrink(); + } + + return RepaintBoundary(child: itemBuilder(context, item, colorScheme)); + } +} + +enum _CollectionEntryType { wishlist, loved, playlist } + +class _CollectionEntry { + final _CollectionEntryType type; + final int playlistIndex; + + const _CollectionEntry._(this.type, [this.playlistIndex = -1]); + + static const wishlist = _CollectionEntry._(_CollectionEntryType.wishlist); + static const loved = _CollectionEntry._(_CollectionEntryType.loved); + static _CollectionEntry playlist(int index) => + _CollectionEntry._(_CollectionEntryType.playlist, index); +} + +class _FilterChip extends StatelessWidget { + final String label; + final int count; + final bool isSelected; + final VoidCallback onTap; + + const _FilterChip({ + required this.label, + required this.count, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return FilterChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.2) + : colorScheme.outline.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + count.toString(), + style: TextStyle( + fontSize: 11, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + selected: isSelected, + onSelected: (_) => onTap(), + showCheckmark: false, + ); + } +} + +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) { + final isDisabled = onPressed == null; + return Material( + color: isDisabled + ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) + : colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 18, + color: isDisabled + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) + : colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDisabled + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) + : colorScheme.onSecondaryContainer, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AnimatedOverlayBottomBar extends StatefulWidget { + final Widget child; + + const _AnimatedOverlayBottomBar({required this.child}); + + @override + State<_AnimatedOverlayBottomBar> createState() => + _AnimatedOverlayBottomBarState(); +} + +class _AnimatedOverlayBottomBarState extends State<_AnimatedOverlayBottomBar> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _slideAnimation; + late final Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 240), + ); + final curve = CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + ); + _slideAnimation = Tween( + begin: const Offset(0, 0.08), + end: Offset.zero, + ).animate(curve); + _fadeAnimation = Tween(begin: 0, end: 1).animate(curve); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition(position: _slideAnimation, child: widget.child), + ); + } +} diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart new file mode 100644 index 00000000..db7ece45 --- /dev/null +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -0,0 +1,1695 @@ +part of 'track_metadata_screen.dart'; + +class _ResolvedAutoFillTrack { + final Map track; + final String? deezerId; + + const _ResolvedAutoFillTrack({required this.track, this.deezerId}); +} + +class _EditMetadataSheet extends StatefulWidget { + final ColorScheme colorScheme; + final Map initialValues; + final String filePath; + final String? sourceTrackId; + final int durationMs; + final String artistTagMode; + + const _EditMetadataSheet({ + required this.colorScheme, + required this.initialValues, + required this.filePath, + this.sourceTrackId, + required this.durationMs, + required this.artistTagMode, + }); + + @override + State<_EditMetadataSheet> createState() => _EditMetadataSheetState(); +} + +class _EditMetadataSheetState extends State<_EditMetadataSheet> { + static final RegExp _metadataCollapsePattern = RegExp(r'[^a-z0-9]+'); + static final RegExp _metadataWhitespacePattern = RegExp(r'\s+'); + static final RegExp _spotifyTrackIdPattern = RegExp(r'^[A-Za-z0-9]{22}$'); + static final RegExp _deezerTrackIdPattern = RegExp(r'^\d+$'); + static final RegExp _isrcPattern = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{7}$'); + + bool _saving = false; + bool _showAdvanced = false; + bool _showAutoFill = false; + bool _fetching = false; + String? _selectedCoverPath; + String? _selectedCoverTempDir; + String? _selectedCoverName; + String? _currentCoverPath; + String? _currentCoverTempDir; + bool _loadingCurrentCover = false; + + final Set _autoFillFields = {}; + + static const _fieldDefs = { + 'title': 'title', + 'artist': 'artist', + 'album': 'album', + 'album_artist': 'album_artist', + 'date': 'date', + 'track_number': 'track_number', + 'total_tracks': 'total_tracks', + 'disc_number': 'disc_number', + 'total_discs': 'total_discs', + 'genre': 'genre', + 'isrc': 'isrc', + 'lyrics': 'lyrics', + 'label': 'label', + 'copyright': 'copyright', + 'composer': 'composer', + 'cover': 'cover', + }; + + late final TextEditingController _titleCtrl; + late final TextEditingController _artistCtrl; + late final TextEditingController _albumCtrl; + late final TextEditingController _albumArtistCtrl; + late final TextEditingController _dateCtrl; + late final TextEditingController _trackNumCtrl; + late final TextEditingController _trackTotalCtrl; + late final TextEditingController _discNumCtrl; + late final TextEditingController _discTotalCtrl; + late final TextEditingController _genreCtrl; + late final TextEditingController _isrcCtrl; + late final TextEditingController _lyricsCtrl; + late final TextEditingController _labelCtrl; + late final TextEditingController _copyrightCtrl; + late final TextEditingController _composerCtrl; + late final TextEditingController _commentCtrl; + + bool _hasValue(String? value) => value != null && value.trim().isNotEmpty; + + String _resolveImageExtension(String? ext, Uint8List? bytes) { + final normalized = (ext ?? '').toLowerCase(); + if (normalized == 'png' || + normalized == 'jpg' || + normalized == 'jpeg' || + normalized == 'webp') { + return normalized == 'jpeg' ? 'jpg' : normalized; + } + if (bytes != null && bytes.length >= 8) { + if (bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47) { + return 'png'; + } + if (bytes[0] == 0xFF && bytes[1] == 0xD8) { + return 'jpg'; + } + if (bytes.length >= 12 && + bytes[0] == 0x52 && + bytes[1] == 0x49 && + bytes[2] == 0x46 && + bytes[3] == 0x46 && + bytes[8] == 0x57 && + bytes[9] == 0x45 && + bytes[10] == 0x42 && + bytes[11] == 0x50) { + return 'webp'; + } + } + return 'jpg'; + } + + Future _cleanupSelectedCoverTemp() async { + final dirPath = _selectedCoverTempDir; + _selectedCoverPath = null; + _selectedCoverTempDir = null; + _selectedCoverName = null; + if (dirPath == null || dirPath.isEmpty) return; + try { + final dir = Directory(dirPath); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } catch (_) {} + } + + Future _cleanupCurrentCoverTemp() async { + final dirPath = _currentCoverTempDir; + _currentCoverPath = null; + _currentCoverTempDir = null; + if (dirPath == null || dirPath.isEmpty) return; + try { + final dir = Directory(dirPath); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } catch (_) {} + } + + Future _loadCurrentCoverPreview() async { + if (_loadingCurrentCover) return; + setState(() => _loadingCurrentCover = true); + String? newCoverPath; + String? newCoverDir; + try { + final tempDir = await Directory.systemTemp.createTemp( + 'edit_existing_cover_', + ); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}existing_cover.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + widget.filePath, + coverOutput, + ); + if (coverResult['error'] == null && await File(coverOutput).exists()) { + newCoverPath = coverOutput; + newCoverDir = tempDir.path; + } else { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } catch (_) {} + + if (!mounted) { + if (newCoverDir != null) { + try { + final dir = Directory(newCoverDir); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } catch (_) {} + } + return; + } + + final oldDir = _currentCoverTempDir; + setState(() { + _currentCoverPath = newCoverPath; + _currentCoverTempDir = newCoverDir; + _loadingCurrentCover = false; + }); + if (oldDir != null && oldDir.isNotEmpty && oldDir != newCoverDir) { + try { + final dir = Directory(oldDir); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } catch (_) {} + } + } + + Future _pickCoverImage() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + allowMultiple: false, + withData: true, + ); + if (result == null || result.files.isEmpty) return; + + final picked = result.files.first; + final bytes = picked.bytes; + final sourcePath = picked.path; + final extension = _resolveImageExtension(picked.extension, bytes); + + final tempDir = await Directory.systemTemp.createTemp('edit_cover_'); + final tempPath = + '${tempDir.path}${Platform.pathSeparator}cover.$extension'; + + if (bytes != null && bytes.isNotEmpty) { + await File(tempPath).writeAsBytes(bytes, flush: true); + } else if (sourcePath != null && sourcePath.isNotEmpty) { + final sourceFile = File(sourcePath); + if (!await sourceFile.exists()) { + throw Exception('Selected image is not accessible'); + } + await sourceFile.copy(tempPath); + } else { + throw Exception('Unable to read selected image'); + } + + await _cleanupSelectedCoverTemp(); + if (!mounted) { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + return; + } + setState(() { + _selectedCoverPath = tempPath; + _selectedCoverTempDir = tempDir.path; + _selectedCoverName = picked.name; + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + ); + } + } + + String _fieldLabel(String key) { + final l10n = context.l10n; + switch (key) { + case 'title': + return l10n.editMetadataFieldTitle; + case 'artist': + return l10n.editMetadataFieldArtist; + case 'album': + return l10n.editMetadataFieldAlbum; + case 'album_artist': + return l10n.editMetadataFieldAlbumArtist; + case 'date': + return l10n.editMetadataFieldDate; + case 'track_number': + return l10n.editMetadataFieldTrackNum; + case 'total_tracks': + return 'Track Total'; + case 'disc_number': + return l10n.editMetadataFieldDiscNum; + case 'total_discs': + return 'Disc Total'; + case 'genre': + return l10n.editMetadataFieldGenre; + case 'isrc': + return l10n.editMetadataFieldIsrc; + case 'lyrics': + return l10n.trackLyrics; + case 'label': + return l10n.editMetadataFieldLabel; + case 'copyright': + return l10n.editMetadataFieldCopyright; + case 'composer': + return 'Composer'; + case 'cover': + return l10n.editMetadataFieldCover; + default: + return key; + } + } + + TextEditingController? _controllerForKey(String key) { + switch (key) { + case 'title': + return _titleCtrl; + case 'artist': + return _artistCtrl; + case 'album': + return _albumCtrl; + case 'album_artist': + return _albumArtistCtrl; + case 'date': + return _dateCtrl; + case 'track_number': + return _trackNumCtrl; + case 'total_tracks': + return _trackTotalCtrl; + case 'disc_number': + return _discNumCtrl; + case 'total_discs': + return _discTotalCtrl; + case 'genre': + return _genreCtrl; + case 'isrc': + return _isrcCtrl; + case 'lyrics': + return _lyricsCtrl; + case 'label': + return _labelCtrl; + case 'copyright': + return _copyrightCtrl; + case 'composer': + return _composerCtrl; + default: + return null; + } + } + + void _selectAllFields() { + setState(() { + _autoFillFields.addAll(_fieldDefs.keys); + }); + } + + void _selectEmptyFields() { + setState(() { + _autoFillFields.clear(); + for (final key in _fieldDefs.keys) { + if (key == 'cover') { + if (!_hasValue(_currentCoverPath) && !_hasValue(_selectedCoverPath)) { + _autoFillFields.add(key); + } + continue; + } + final ctrl = _controllerForKey(key); + if (ctrl != null && ctrl.text.trim().isEmpty) { + _autoFillFields.add(key); + } + } + }); + } + + String _normalizeMetadataText(String value) { + final collapsed = value + .toLowerCase() + .replaceAll(_metadataCollapsePattern, ' ') + .trim(); + return collapsed.replaceAll(_metadataWhitespacePattern, ' '); + } + + bool _looksLikeIsrc(String value) { + return _isrcPattern.hasMatch(value.trim().toUpperCase()); + } + + String? _extractRawSpotifyTrackIdFromValue(Object? value) { + final raw = value?.toString().trim() ?? ''; + if (raw.isEmpty) return null; + + if (_spotifyTrackIdPattern.hasMatch(raw)) { + return raw; + } + + if (raw.startsWith('spotify:')) { + final parts = raw.split(':'); + final last = parts.isNotEmpty ? parts.last.trim() : ''; + if (_spotifyTrackIdPattern.hasMatch(last)) { + return last; + } + return null; + } + + final uri = Uri.tryParse(raw); + if (uri != null && + uri.host.contains('spotify.com') && + uri.pathSegments.length >= 2 && + uri.pathSegments.first == 'track') { + final candidate = uri.pathSegments[1].trim(); + if (_spotifyTrackIdPattern.hasMatch(candidate)) { + return candidate; + } + } + + return null; + } + + String? _extractRawDeezerTrackIdFromValue(Object? value) { + final raw = value?.toString().trim() ?? ''; + if (raw.isEmpty) return null; + + if (_deezerTrackIdPattern.hasMatch(raw)) { + return raw; + } + + if (raw.startsWith('deezer:')) { + final parts = raw.split(':'); + final last = parts.isNotEmpty ? parts.last.trim() : ''; + if (_deezerTrackIdPattern.hasMatch(last)) { + return last; + } + } + + final uri = Uri.tryParse(raw); + if (uri != null && uri.host.contains('deezer.com')) { + final trackIndex = uri.pathSegments.indexOf('track'); + if (trackIndex >= 0 && trackIndex + 1 < uri.pathSegments.length) { + final candidate = uri.pathSegments[trackIndex + 1].trim(); + if (_deezerTrackIdPattern.hasMatch(candidate)) { + return candidate; + } + } + } + + return null; + } + + String? _extractRawSpotifyTrackId(Map track) { + for (final candidate in [track['spotify_id'], track['id']]) { + final spotifyId = _extractRawSpotifyTrackIdFromValue(candidate); + if (spotifyId != null) return spotifyId; + } + + final externalLinks = track['external_links']; + if (externalLinks is Map) { + final spotifyId = _extractRawSpotifyTrackIdFromValue( + externalLinks['spotify'], + ); + if (spotifyId != null) return spotifyId; + } + + return null; + } + + String? _extractRawDeezerTrackId(Map track) { + for (final candidate in [ + track['deezer_id'], + track['spotify_id'], + track['id'], + ]) { + final deezerId = _extractRawDeezerTrackIdFromValue(candidate); + if (deezerId != null) return deezerId; + } + + final externalLinks = track['external_links']; + if (externalLinks is Map) { + final deezerId = _extractRawDeezerTrackIdFromValue( + externalLinks['deezer'], + ); + if (deezerId != null) return deezerId; + } + + return null; + } + + Map _unwrapTrackPayload(Map payload) { + final track = payload['track']; + if (track is Map) { + return track; + } + return payload; + } + + void _mergeOnlineTrackData( + Map enriched, + Map track, + ) { + void put(String key, Object? value) { + final text = value?.toString().trim() ?? ''; + if (text.isNotEmpty && text != 'null') { + enriched[key] = text; + } + } + + put('title', track['name'] ?? track['title']); + put('artist', track['artists'] ?? track['artist']); + put('album', track['album_name'] ?? track['album']); + put('album_artist', track['album_artist']); + put('date', track['release_date']); + put('track_number', track['track_number']); + put('total_tracks', track['total_tracks']); + put('disc_number', track['disc_number']); + put('total_discs', track['total_discs']); + put('isrc', track['isrc']); + put('genre', track['genre']); + put('label', track['label']); + put('copyright', track['copyright']); + put('composer', track['composer']); + } + + Future<_ResolvedAutoFillTrack?> _resolveAutoFillTrackFromIdentifiers( + String currentIsrc, + ) async { + if (_looksLikeIsrc(currentIsrc)) { + final deezerTrack = await PlatformBridge.searchDeezerByISRC(currentIsrc); + return _ResolvedAutoFillTrack( + track: _unwrapTrackPayload(deezerTrack), + deezerId: _extractRawDeezerTrackId(deezerTrack), + ); + } + + final sourceTrackId = widget.sourceTrackId?.trim() ?? ''; + if (sourceTrackId.isEmpty) { + return null; + } + + final deezerId = _extractRawDeezerTrackIdFromValue(sourceTrackId); + if (deezerId != null) { + final deezerTrack = await PlatformBridge.getProviderMetadata( + 'deezer', + 'track', + deezerId, + ); + return _ResolvedAutoFillTrack( + track: _unwrapTrackPayload(deezerTrack), + deezerId: deezerId, + ); + } + + final spotifyId = _extractRawSpotifyTrackIdFromValue(sourceTrackId); + if (spotifyId != null) { + final deezerTrack = await PlatformBridge.convertSpotifyToDeezer( + 'track', + spotifyId, + ); + final track = _unwrapTrackPayload(deezerTrack); + return _ResolvedAutoFillTrack( + track: track, + deezerId: + _extractRawDeezerTrackId(track) ?? + _extractRawDeezerTrackId(deezerTrack), + ); + } + + return null; + } + + int _metadataMatchScore( + Map track, { + required String currentTitle, + required String currentArtist, + required String currentAlbum, + required String currentIsrc, + }) { + var score = 0; + + final candidateIsrc = (track['isrc']?.toString() ?? '') + .trim() + .toUpperCase(); + if (currentIsrc.isNotEmpty && candidateIsrc == currentIsrc) { + score += 10000; + } + + final candidateTitle = _normalizeMetadataText( + (track['name'] ?? track['title'] ?? '').toString(), + ); + final candidateArtist = _normalizeMetadataText( + (track['artists'] ?? track['artist'] ?? '').toString(), + ); + final candidateAlbum = _normalizeMetadataText( + (track['album_name'] ?? track['album'] ?? '').toString(), + ); + + if (currentTitle.isNotEmpty && candidateTitle.isNotEmpty) { + if (candidateTitle == currentTitle) { + score += 400; + } else if (candidateTitle.contains(currentTitle) || + currentTitle.contains(candidateTitle)) { + score += 180; + } + } + + if (currentArtist.isNotEmpty && candidateArtist.isNotEmpty) { + if (candidateArtist == currentArtist) { + score += 320; + } else if (candidateArtist.contains(currentArtist) || + currentArtist.contains(candidateArtist)) { + score += 140; + } + } + + if (currentAlbum.isNotEmpty && candidateAlbum.isNotEmpty) { + if (candidateAlbum == currentAlbum) { + score += 120; + } else if (candidateAlbum.contains(currentAlbum) || + currentAlbum.contains(candidateAlbum)) { + score += 50; + } + } + + return score; + } + + Future _fetchAndFill() async { + if (_autoFillFields.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.editMetadataAutoFillNoneSelected)), + ); + return; + } + + setState(() => _fetching = true); + + try { + final title = _titleCtrl.text.trim(); + final artist = _artistCtrl.text.trim(); + final album = _albumCtrl.text.trim(); + final currentIsrc = _isrcCtrl.text.trim().toUpperCase(); + final shouldFetchLyrics = _autoFillFields.contains('lyrics'); + final needsTrackLookup = _autoFillFields.any((key) => key != 'lyrics'); + Map? best; + String? deezerId; + + if (needsTrackLookup) { + try { + final resolved = await _resolveAutoFillTrackFromIdentifiers( + currentIsrc, + ); + if (resolved != null) { + best = resolved.track; + deezerId = resolved.deezerId; + } + } catch (e) { + _log.w('Identifier-first autofill lookup failed: $e'); + } + } + + final queryParts = []; + if (title.isNotEmpty) queryParts.add(title); + if (artist.isNotEmpty) queryParts.add(artist); + if (queryParts.isEmpty && album.isNotEmpty) queryParts.add(album); + + if (needsTrackLookup && best == null && queryParts.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)), + ); + } + return; + } + + final normalizedTitle = _normalizeMetadataText(title); + final normalizedArtist = _normalizeMetadataText(artist); + final normalizedAlbum = _normalizeMetadataText(album); + + if (needsTrackLookup && best == null) { + final query = queryParts.join(' '); + final results = await PlatformBridge.searchTracksWithMetadataProviders( + query, + limit: 5, + ); + + if (!mounted) return; + + if (results.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)), + ); + return; + } + + // Pick best match using current metadata, not only provider order. + best = results.first; + var bestScore = -1; + for (final result in results) { + final score = _metadataMatchScore( + result, + currentTitle: normalizedTitle, + currentArtist: normalizedArtist, + currentAlbum: normalizedAlbum, + currentIsrc: currentIsrc, + ); + if (score > bestScore) { + bestScore = score; + best = result; + } + } + } + + final selectedBest = best; + if (needsTrackLookup && selectedBest == null) { + throw StateError('No metadata match resolved for auto-fill'); + } + + final enriched = {}; + if (selectedBest != null) { + enriched.addAll({ + 'title': (selectedBest['name'] ?? '').toString(), + 'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '') + .toString(), + 'album': (selectedBest['album_name'] ?? selectedBest['album'] ?? '') + .toString(), + 'album_artist': (selectedBest['album_artist'] ?? '').toString(), + 'date': (selectedBest['release_date'] ?? '').toString(), + 'track_number': (selectedBest['track_number'] ?? '').toString(), + 'total_tracks': (selectedBest['total_tracks'] ?? '').toString(), + 'disc_number': (selectedBest['disc_number'] ?? '').toString(), + 'total_discs': (selectedBest['total_discs'] ?? '').toString(), + 'isrc': (selectedBest['isrc'] ?? '').toString(), + 'composer': (selectedBest['composer'] ?? '').toString(), + }); + _mergeOnlineTrackData(enriched, selectedBest); + } + + final enrichedIsrc = (enriched['isrc'] ?? '').trim(); + final needsIsrc = + _autoFillFields.contains('isrc') && enrichedIsrc.isEmpty; + final needsExtended = + _autoFillFields.contains('genre') || + _autoFillFields.contains('label') || + _autoFillFields.contains('copyright') || + _autoFillFields.contains('composer'); + + final rawSpotifyId = selectedBest == null + ? _extractRawSpotifyTrackIdFromValue(widget.sourceTrackId) + : _extractRawSpotifyTrackId(selectedBest); + + deezerId ??= selectedBest == null + ? null + : _extractRawDeezerTrackId(selectedBest); + final candidateIsrc = enrichedIsrc.toUpperCase(); + final deezerLookupIsrc = _looksLikeIsrc(currentIsrc) + ? currentIsrc + : (_looksLikeIsrc(candidateIsrc) ? candidateIsrc : ''); + + if (needsIsrc || needsExtended) { + try { + if (deezerId == null && deezerLookupIsrc.isNotEmpty) { + final deezerResult = await PlatformBridge.searchDeezerByISRC( + deezerLookupIsrc, + ); + deezerId = _extractRawDeezerTrackId(deezerResult); + _mergeOnlineTrackData(enriched, deezerResult); + } + + if (deezerId == null && rawSpotifyId != null) { + // Spotify IDs can be mapped through SongLink to a Deezer track. + final deezerData = await PlatformBridge.convertSpotifyToDeezer( + 'track', + rawSpotifyId, + ); + final trackData = deezerData['track']; + if (trackData is Map) { + deezerId = _extractRawDeezerTrackId(trackData); + _mergeOnlineTrackData(enriched, trackData); + } + deezerId ??= _extractRawDeezerTrackId(deezerData); + } + } catch (_) { + // Deezer resolution is best-effort + } + } + + if (!mounted) return; + + // Fetch ISRC from Deezer track metadata if still missing + if (needsIsrc && + (enriched['isrc'] ?? '').trim().isEmpty && + deezerId != null) { + try { + final deezerMeta = await PlatformBridge.getProviderMetadata( + 'deezer', + 'track', + deezerId, + ); + final trackData = _unwrapTrackPayload(deezerMeta); + _mergeOnlineTrackData(enriched, trackData); + final deezerIsrc = (trackData['isrc'] ?? '').toString().trim(); + if (deezerIsrc.isNotEmpty) { + enriched['isrc'] = deezerIsrc; + } + } catch (_) {} + } + + if (!mounted) return; + + if (needsExtended && deezerId != null) { + try { + final extended = await PlatformBridge.getDeezerExtendedMetadata( + deezerId, + ); + if (extended != null) { + enriched['genre'] = extended['genre'] ?? ''; + enriched['label'] = extended['label'] ?? ''; + enriched['copyright'] = extended['copyright'] ?? ''; + } + } catch (_) { + // Extended metadata is best-effort + } + } + + if (shouldFetchLyrics) { + final lyricsTitle = + ((selectedBest?['name'] ?? selectedBest?['title'] ?? title) + .toString()) + .trim(); + final lyricsArtist = + ((selectedBest?['artists'] ?? selectedBest?['artist'] ?? artist) + .toString()) + .trim(); + + if (lyricsTitle.isNotEmpty && lyricsArtist.isNotEmpty) { + try { + final lyricsResult = await PlatformBridge.getLyricsLRCWithSource( + rawSpotifyId ?? '', + lyricsTitle, + lyricsArtist, + durationMs: widget.durationMs, + ); + final lyricsText = lyricsResult['lyrics']?.toString().trim() ?? ''; + final instrumental = + (lyricsResult['instrumental'] as bool? ?? false) || + lyricsText == '[instrumental:true]'; + if (!instrumental && lyricsText.isNotEmpty) { + enriched['lyrics'] = lyricsText; + } + } catch (e) { + _log.w('Lyrics autofill failed: $e'); + } + } + } + + if (!mounted) return; + + var filledCount = 0; + for (final key in _autoFillFields) { + if (key == 'cover') continue; + final value = enriched[key]; + if (value != null && + value.isNotEmpty && + value != '0' && + value != 'null') { + final ctrl = _controllerForKey(key); + if (ctrl != null) { + ctrl.text = value; + filledCount++; + } + } + } + + if (_autoFillFields.contains('cover') && selectedBest != null) { + final coverUrl = + (selectedBest['cover_url'] ?? selectedBest['images'] ?? '') + .toString(); + if (coverUrl.isNotEmpty) { + try { + final tempDir = await Directory.systemTemp.createTemp( + 'autofill_cover_', + ); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + final response = await HttpClient() + .getUrl(Uri.parse(coverUrl)) + .then((req) => req.close()); + final file = File(coverOutput); + final sink = file.openWrite(); + await response.pipe(sink); + if (await file.exists() && await file.length() > 0) { + await _cleanupSelectedCoverTemp(); + if (mounted) { + setState(() { + _selectedCoverPath = coverOutput; + _selectedCoverTempDir = tempDir.path; + _selectedCoverName = 'Online cover'; + }); + filledCount++; + } + } else { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } catch (_) { + // Cover download is best-effort + } + } + } + + if (mounted) { + setState(() {}); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + filledCount > 0 + ? context.l10n.editMetadataAutoFillDone(filledCount) + : context.l10n.editMetadataAutoFillNoResults, + ), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + ); + } + } finally { + if (mounted) setState(() => _fetching = false); + } + } + + @override + void initState() { + super.initState(); + final v = widget.initialValues; + _titleCtrl = TextEditingController(text: v['title'] ?? ''); + _artistCtrl = TextEditingController(text: v['artist'] ?? ''); + _albumCtrl = TextEditingController(text: v['album'] ?? ''); + _albumArtistCtrl = TextEditingController(text: v['album_artist'] ?? ''); + _dateCtrl = TextEditingController(text: v['date'] ?? ''); + _trackNumCtrl = TextEditingController(text: v['track_number'] ?? ''); + _trackTotalCtrl = TextEditingController(text: v['total_tracks'] ?? ''); + _discNumCtrl = TextEditingController(text: v['disc_number'] ?? ''); + _discTotalCtrl = TextEditingController(text: v['total_discs'] ?? ''); + _genreCtrl = TextEditingController(text: v['genre'] ?? ''); + _isrcCtrl = TextEditingController(text: v['isrc'] ?? ''); + _lyricsCtrl = TextEditingController(text: v['lyrics'] ?? ''); + _labelCtrl = TextEditingController(text: v['label'] ?? ''); + _copyrightCtrl = TextEditingController(text: v['copyright'] ?? ''); + _composerCtrl = TextEditingController(text: v['composer'] ?? ''); + _commentCtrl = TextEditingController(text: v['comment'] ?? ''); + _loadCurrentCoverPreview(); + } + + @override + void dispose() { + unawaited(_cleanupSelectedCoverTemp()); + unawaited(_cleanupCurrentCoverTemp()); + _titleCtrl.dispose(); + _artistCtrl.dispose(); + _albumCtrl.dispose(); + _albumArtistCtrl.dispose(); + _dateCtrl.dispose(); + _trackNumCtrl.dispose(); + _trackTotalCtrl.dispose(); + _discNumCtrl.dispose(); + _discTotalCtrl.dispose(); + _genreCtrl.dispose(); + _isrcCtrl.dispose(); + _lyricsCtrl.dispose(); + _labelCtrl.dispose(); + _copyrightCtrl.dispose(); + _composerCtrl.dispose(); + _commentCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() => _saving = true); + + final metadata = { + 'title': _titleCtrl.text, + 'artist': _artistCtrl.text, + 'album': _albumCtrl.text, + 'album_artist': _albumArtistCtrl.text, + 'date': _dateCtrl.text, + 'track_number': _trackNumCtrl.text, + 'track_total': _trackTotalCtrl.text, + 'disc_number': _discNumCtrl.text, + 'disc_total': _discTotalCtrl.text, + 'genre': _genreCtrl.text, + 'isrc': _isrcCtrl.text, + 'lyrics': _lyricsCtrl.text, + 'label': _labelCtrl.text, + 'copyright': _copyrightCtrl.text, + 'composer': _composerCtrl.text, + 'comment': _commentCtrl.text, + 'cover_path': _selectedCoverPath ?? '', + 'artist_tag_mode': widget.artistTagMode, + }; + + try { + final result = await PlatformBridge.editFileMetadata( + widget.filePath, + metadata, + ); + + if (result['error'] != null) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('${result['error']}'))); + } + setState(() => _saving = false); + return; + } + + final method = result['method'] as String?; + + if (method == 'ffmpeg') { + // For SAF files, Kotlin returns temp_path + saf_uri + final tempPath = result['temp_path'] as String?; + final safUri = result['saf_uri'] as String?; + final ffmpegTarget = tempPath ?? widget.filePath; + + final lower = widget.filePath.toLowerCase(); + final isMp3 = lower.endsWith('.mp3'); + final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg'); + final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac'); + + // Always include all known fields so -map_metadata 0 + explicit + // -metadata flags can both preserve custom tags AND clear fields + // the user emptied. + final vorbisMap = { + 'TITLE': metadata['title'] ?? '', + 'ARTIST': metadata['artist'] ?? '', + 'ALBUM': metadata['album'] ?? '', + 'ALBUMARTIST': metadata['album_artist'] ?? '', + 'DATE': metadata['date'] ?? '', + 'TRACKNUMBER': + (metadata['track_number']?.isNotEmpty == true && + metadata['track_number'] != '0') + ? (metadata['track_total']?.isNotEmpty == true && + metadata['track_total'] != '0' + ? '${metadata['track_number']}/${metadata['track_total']}' + : metadata['track_number']!) + : '', + 'DISCNUMBER': + (metadata['disc_number']?.isNotEmpty == true && + metadata['disc_number'] != '0') + ? (metadata['disc_total']?.isNotEmpty == true && + metadata['disc_total'] != '0' + ? '${metadata['disc_number']}/${metadata['disc_total']}' + : metadata['disc_number']!) + : '', + 'GENRE': metadata['genre'] ?? '', + 'ISRC': metadata['isrc'] ?? '', + 'LYRICS': metadata['lyrics'] ?? '', + 'UNSYNCEDLYRICS': metadata['lyrics'] ?? '', + 'ORGANIZATION': metadata['label'] ?? '', + 'COPYRIGHT': metadata['copyright'] ?? '', + 'COMPOSER': metadata['composer'] ?? '', + 'COMMENT': metadata['comment'] ?? '', + }; + try { + final existingMetadata = await PlatformBridge.readFileMetadata( + ffmpegTarget, + ); + // Preserve ReplayGain tags if present — these are computed once + // during download and should survive manual metadata edits. + final rgFields = { + 'REPLAYGAIN_TRACK_GAIN': + existingMetadata['replaygain_track_gain']?.toString() ?? '', + 'REPLAYGAIN_TRACK_PEAK': + existingMetadata['replaygain_track_peak']?.toString() ?? '', + 'REPLAYGAIN_ALBUM_GAIN': + existingMetadata['replaygain_album_gain']?.toString() ?? '', + 'REPLAYGAIN_ALBUM_PEAK': + existingMetadata['replaygain_album_peak']?.toString() ?? '', + }; + rgFields.forEach((key, value) { + if (value.isNotEmpty) { + vorbisMap[key] = value; + } + }); + } catch (_) { + // Lyrics/ReplayGain preservation is best-effort. + } + + String? existingCoverPath = _selectedCoverPath ?? _currentCoverPath; + String? extractedCoverPath; + if (existingCoverPath == null || existingCoverPath.isEmpty) { + // Preserve current embedded cover when user does not pick a new one. + try { + final tempDir = await Directory.systemTemp.createTemp('cover_'); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + ffmpegTarget, + coverOutput, + ); + if (coverResult['error'] == null) { + existingCoverPath = coverOutput; + extractedCoverPath = coverOutput; + } else { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } catch (_) {} + } + + String? ffmpegResult; + if (isMp3) { + ffmpegResult = await FFmpegService.embedMetadataToMp3( + mp3Path: ffmpegTarget, + coverPath: existingCoverPath, + metadata: vorbisMap, + preserveMetadata: true, + ); + } else if (isM4A) { + ffmpegResult = await FFmpegService.embedMetadataToM4a( + m4aPath: ffmpegTarget, + coverPath: existingCoverPath, + metadata: vorbisMap, + preserveMetadata: true, + ); + } else if (isOpus) { + ffmpegResult = await FFmpegService.embedMetadataToOpus( + opusPath: ffmpegTarget, + coverPath: existingCoverPath, + metadata: vorbisMap, + artistTagMode: widget.artistTagMode, + preserveMetadata: true, + ); + } + + // Cleanup extracted temp cover (manual selected cover is cleaned on dispose) + if (extractedCoverPath != null && extractedCoverPath.isNotEmpty) { + final extractedFile = File(extractedCoverPath); + try { + await extractedFile.delete(); + } catch (_) {} + try { + final dir = extractedFile.parent; + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } catch (_) {} + } + + if (ffmpegResult == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.metadataSaveFailedFfmpeg)), + ); + } + setState(() => _saving = false); + return; + } + + if (tempPath != null && safUri != null) { + final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); + if (!ok && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.metadataSaveFailedStorage)), + ); + setState(() => _saving = false); + return; + } + } + } + + if (mounted) { + Navigator.pop(context, true); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = widget.colorScheme; + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: DraggableScrollableSheet( + initialChildSize: 0.85, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + Expanded( + child: Text( + context.l10n.trackEditMetadata, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + if (_saving) + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + FilledButton( + onPressed: _save, + child: Text(context.l10n.dialogSave), + ), + ], + ), + ), + const SizedBox(height: 12), + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: 24), + children: [ + const SizedBox(height: 6), + _buildCoverEditor(cs), + _buildAutoFillSection(cs), + _field('Title', _titleCtrl), + _field('Artist', _artistCtrl), + _field('Album', _albumCtrl), + _field('Album Artist', _albumArtistCtrl), + _field('Date', _dateCtrl, hint: 'YYYY-MM-DD or YYYY'), + Row( + children: [ + Expanded( + child: _field( + 'Track #', + _trackNumCtrl, + keyboard: TextInputType.number, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _field( + 'Track Total', + _trackTotalCtrl, + keyboard: TextInputType.number, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _field( + 'Disc #', + _discNumCtrl, + keyboard: TextInputType.number, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _field( + 'Disc Total', + _discTotalCtrl, + keyboard: TextInputType.number, + ), + ), + ], + ), + _field('Genre', _genreCtrl), + _field('ISRC', _isrcCtrl), + _field( + context.l10n.trackLyrics, + _lyricsCtrl, + maxLines: 8, + keyboard: TextInputType.multiline, + ), + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: InkWell( + onTap: () => + setState(() => _showAdvanced = !_showAdvanced), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Icon( + _showAdvanced + ? Icons.expand_less + : Icons.expand_more, + size: 20, + color: cs.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + 'Advanced', + style: Theme.of(context).textTheme.labelLarge + ?.copyWith(color: cs.onSurfaceVariant), + ), + ], + ), + ), + ), + ), + if (_showAdvanced) ...[ + _field('Label', _labelCtrl), + _field('Copyright', _copyrightCtrl), + _field('Composer', _composerCtrl), + _field('Comment', _commentCtrl, maxLines: 3), + ], + const SizedBox(height: 24), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildAutoFillSection(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () => setState(() => _showAutoFill = !_showAutoFill), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Row( + children: [ + Icon(Icons.travel_explore, size: 20, color: cs.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + context.l10n.editMetadataAutoFill, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: cs.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + Icon( + _showAutoFill ? Icons.expand_less : Icons.expand_more, + size: 20, + color: cs.onSurfaceVariant, + ), + ], + ), + ), + ), + if (_showAutoFill) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + context.l10n.editMetadataAutoFillDesc, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + _quickSelectButton( + label: context.l10n.editMetadataSelectAll, + onTap: _selectAllFields, + cs: cs, + ), + const SizedBox(width: 8), + _quickSelectButton( + label: context.l10n.editMetadataSelectEmpty, + onTap: _selectEmptyFields, + cs: cs, + ), + ], + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Wrap( + spacing: 6, + runSpacing: 4, + children: _fieldDefs.keys.map((key) { + final selected = _autoFillFields.contains(key); + return FilterChip( + label: Text(_fieldLabel(key)), + selected: selected, + onSelected: _fetching + ? null + : (val) { + setState(() { + if (val) { + _autoFillFields.add(key); + } else { + _autoFillFields.remove(key); + } + }); + }, + selectedColor: cs.primaryContainer, + checkmarkColor: cs.onPrimaryContainer, + labelStyle: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: selected + ? cs.onPrimaryContainer + : cs.onSurfaceVariant, + ), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + }).toList(), + ), + ), + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: (_fetching || _saving || _autoFillFields.isEmpty) + ? null + : _fetchAndFill, + icon: _fetching + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.auto_fix_high), + label: Text( + _fetching + ? context.l10n.editMetadataAutoFillSearching + : context.l10n.editMetadataAutoFillFetch, + ), + ), + ), + ), + ], + ], + ), + ), + ); + } + + Widget _quickSelectButton({ + required String label, + required VoidCallback onTap, + required ColorScheme cs, + }) { + return InkWell( + onTap: _fetching ? null : onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: cs.outline.withValues(alpha: 0.5)), + ), + child: Text( + label, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: cs.primary), + ), + ), + ); + } + + Widget _buildCoverEditor(ColorScheme cs) { + final hasSelectedCover = _hasValue(_selectedCoverPath); + final hasCurrentCover = _hasValue(_currentCoverPath); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Cover Art', + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: cs.onSurface), + ), + const SizedBox(height: 6), + if (_loadingCurrentCover) + const LinearProgressIndicator(minHeight: 2) + else if (!hasCurrentCover) + Text( + context.l10n.trackCoverNoEmbeddedArt, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _saving ? null : _pickCoverImage, + icon: const Icon(Icons.image_outlined), + label: Text( + hasSelectedCover + ? context.l10n.trackCoverReplace + : context.l10n.trackCoverPick, + ), + ), + ), + if (hasSelectedCover) ...[ + const SizedBox(width: 8), + IconButton( + tooltip: context.l10n.trackCoverClearSelected, + onPressed: _saving + ? null + : () async { + await _cleanupSelectedCoverTemp(); + if (!mounted) return; + setState(() {}); + }, + icon: const Icon(Icons.close), + ), + ], + ], + ), + if (hasCurrentCover || hasSelectedCover) ...[ + const SizedBox(height: 12), + Row( + children: [ + if (hasCurrentCover) + Expanded( + child: _buildCoverPreviewTile( + cs: cs, + path: _currentCoverPath!, + label: context.l10n.trackCoverCurrent, + ), + ), + if (hasCurrentCover && hasSelectedCover) + const SizedBox(width: 12), + if (hasSelectedCover) + Expanded( + child: _buildCoverPreviewTile( + cs: cs, + path: _selectedCoverPath!, + label: + _selectedCoverName ?? + context.l10n.trackCoverSelected, + ), + ), + ], + ), + if (hasSelectedCover) ...[ + const SizedBox(height: 8), + Text( + context.l10n.trackCoverReplaceNotice, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ], + ], + ], + ), + ), + ); + } + + Widget _buildCoverPreviewTile({ + required ColorScheme cs, + required String path, + required String label, + }) { + return Column( + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: cs.shadow.withValues(alpha: 0.15), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file( + File(path), + height: 160, + width: 160, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + width: 160, + height: 160, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.broken_image, + color: cs.onSurfaceVariant, + size: 32, + ), + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + label, + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: cs.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); + } + + Widget _field( + String label, + TextEditingController controller, { + String? hint, + TextInputType? keyboard, + int maxLines = 1, + }) { + final cs = widget.colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextField( + controller: controller, + keyboardType: keyboard, + maxLines: maxLines, + decoration: InputDecoration( + labelText: label, + hintText: hint, + filled: true, + fillColor: cs.surfaceContainerHighest.withValues(alpha: 0.5), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: cs.outlineVariant.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: cs.outlineVariant.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: cs.primary, width: 2), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + ), + ); + } +} + +class _MetadataItem { + final String label; + final String value; + + _MetadataItem(this.label, this.value); +} diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 898dc8c0..5a1745c6 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -27,6 +27,8 @@ import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; +part 'track_metadata_edit_sheet.dart'; + final _log = AppLogger('TrackMetadata'); class _EmbeddedCoverPreviewCacheEntry { @@ -4525,1697 +4527,3 @@ class _TrackMetadataScreenState extends ConsumerState { } } } - -class _ResolvedAutoFillTrack { - final Map track; - final String? deezerId; - - const _ResolvedAutoFillTrack({required this.track, this.deezerId}); -} - -class _EditMetadataSheet extends StatefulWidget { - final ColorScheme colorScheme; - final Map initialValues; - final String filePath; - final String? sourceTrackId; - final int durationMs; - final String artistTagMode; - - const _EditMetadataSheet({ - required this.colorScheme, - required this.initialValues, - required this.filePath, - this.sourceTrackId, - required this.durationMs, - required this.artistTagMode, - }); - - @override - State<_EditMetadataSheet> createState() => _EditMetadataSheetState(); -} - -class _EditMetadataSheetState extends State<_EditMetadataSheet> { - static final RegExp _metadataCollapsePattern = RegExp(r'[^a-z0-9]+'); - static final RegExp _metadataWhitespacePattern = RegExp(r'\s+'); - static final RegExp _spotifyTrackIdPattern = RegExp(r'^[A-Za-z0-9]{22}$'); - static final RegExp _deezerTrackIdPattern = RegExp(r'^\d+$'); - static final RegExp _isrcPattern = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{7}$'); - - bool _saving = false; - bool _showAdvanced = false; - bool _showAutoFill = false; - bool _fetching = false; - String? _selectedCoverPath; - String? _selectedCoverTempDir; - String? _selectedCoverName; - String? _currentCoverPath; - String? _currentCoverTempDir; - bool _loadingCurrentCover = false; - - final Set _autoFillFields = {}; - - static const _fieldDefs = { - 'title': 'title', - 'artist': 'artist', - 'album': 'album', - 'album_artist': 'album_artist', - 'date': 'date', - 'track_number': 'track_number', - 'total_tracks': 'total_tracks', - 'disc_number': 'disc_number', - 'total_discs': 'total_discs', - 'genre': 'genre', - 'isrc': 'isrc', - 'lyrics': 'lyrics', - 'label': 'label', - 'copyright': 'copyright', - 'composer': 'composer', - 'cover': 'cover', - }; - - late final TextEditingController _titleCtrl; - late final TextEditingController _artistCtrl; - late final TextEditingController _albumCtrl; - late final TextEditingController _albumArtistCtrl; - late final TextEditingController _dateCtrl; - late final TextEditingController _trackNumCtrl; - late final TextEditingController _trackTotalCtrl; - late final TextEditingController _discNumCtrl; - late final TextEditingController _discTotalCtrl; - late final TextEditingController _genreCtrl; - late final TextEditingController _isrcCtrl; - late final TextEditingController _lyricsCtrl; - late final TextEditingController _labelCtrl; - late final TextEditingController _copyrightCtrl; - late final TextEditingController _composerCtrl; - late final TextEditingController _commentCtrl; - - bool _hasValue(String? value) => value != null && value.trim().isNotEmpty; - - String _resolveImageExtension(String? ext, Uint8List? bytes) { - final normalized = (ext ?? '').toLowerCase(); - if (normalized == 'png' || - normalized == 'jpg' || - normalized == 'jpeg' || - normalized == 'webp') { - return normalized == 'jpeg' ? 'jpg' : normalized; - } - if (bytes != null && bytes.length >= 8) { - if (bytes[0] == 0x89 && - bytes[1] == 0x50 && - bytes[2] == 0x4E && - bytes[3] == 0x47) { - return 'png'; - } - if (bytes[0] == 0xFF && bytes[1] == 0xD8) { - return 'jpg'; - } - if (bytes.length >= 12 && - bytes[0] == 0x52 && - bytes[1] == 0x49 && - bytes[2] == 0x46 && - bytes[3] == 0x46 && - bytes[8] == 0x57 && - bytes[9] == 0x45 && - bytes[10] == 0x42 && - bytes[11] == 0x50) { - return 'webp'; - } - } - return 'jpg'; - } - - Future _cleanupSelectedCoverTemp() async { - final dirPath = _selectedCoverTempDir; - _selectedCoverPath = null; - _selectedCoverTempDir = null; - _selectedCoverName = null; - if (dirPath == null || dirPath.isEmpty) return; - try { - final dir = Directory(dirPath); - if (await dir.exists()) { - await dir.delete(recursive: true); - } - } catch (_) {} - } - - Future _cleanupCurrentCoverTemp() async { - final dirPath = _currentCoverTempDir; - _currentCoverPath = null; - _currentCoverTempDir = null; - if (dirPath == null || dirPath.isEmpty) return; - try { - final dir = Directory(dirPath); - if (await dir.exists()) { - await dir.delete(recursive: true); - } - } catch (_) {} - } - - Future _loadCurrentCoverPreview() async { - if (_loadingCurrentCover) return; - setState(() => _loadingCurrentCover = true); - String? newCoverPath; - String? newCoverDir; - try { - final tempDir = await Directory.systemTemp.createTemp( - 'edit_existing_cover_', - ); - final coverOutput = - '${tempDir.path}${Platform.pathSeparator}existing_cover.jpg'; - final coverResult = await PlatformBridge.extractCoverToFile( - widget.filePath, - coverOutput, - ); - if (coverResult['error'] == null && await File(coverOutput).exists()) { - newCoverPath = coverOutput; - newCoverDir = tempDir.path; - } else { - try { - await tempDir.delete(recursive: true); - } catch (_) {} - } - } catch (_) {} - - if (!mounted) { - if (newCoverDir != null) { - try { - final dir = Directory(newCoverDir); - if (await dir.exists()) { - await dir.delete(recursive: true); - } - } catch (_) {} - } - return; - } - - final oldDir = _currentCoverTempDir; - setState(() { - _currentCoverPath = newCoverPath; - _currentCoverTempDir = newCoverDir; - _loadingCurrentCover = false; - }); - if (oldDir != null && oldDir.isNotEmpty && oldDir != newCoverDir) { - try { - final dir = Directory(oldDir); - if (await dir.exists()) { - await dir.delete(recursive: true); - } - } catch (_) {} - } - } - - Future _pickCoverImage() async { - try { - final result = await FilePicker.platform.pickFiles( - type: FileType.image, - allowMultiple: false, - withData: true, - ); - if (result == null || result.files.isEmpty) return; - - final picked = result.files.first; - final bytes = picked.bytes; - final sourcePath = picked.path; - final extension = _resolveImageExtension(picked.extension, bytes); - - final tempDir = await Directory.systemTemp.createTemp('edit_cover_'); - final tempPath = - '${tempDir.path}${Platform.pathSeparator}cover.$extension'; - - if (bytes != null && bytes.isNotEmpty) { - await File(tempPath).writeAsBytes(bytes, flush: true); - } else if (sourcePath != null && sourcePath.isNotEmpty) { - final sourceFile = File(sourcePath); - if (!await sourceFile.exists()) { - throw Exception('Selected image is not accessible'); - } - await sourceFile.copy(tempPath); - } else { - throw Exception('Unable to read selected image'); - } - - await _cleanupSelectedCoverTemp(); - if (!mounted) { - try { - await tempDir.delete(recursive: true); - } catch (_) {} - return; - } - setState(() { - _selectedCoverPath = tempPath; - _selectedCoverTempDir = tempDir.path; - _selectedCoverName = picked.name; - }); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), - ); - } - } - - String _fieldLabel(String key) { - final l10n = context.l10n; - switch (key) { - case 'title': - return l10n.editMetadataFieldTitle; - case 'artist': - return l10n.editMetadataFieldArtist; - case 'album': - return l10n.editMetadataFieldAlbum; - case 'album_artist': - return l10n.editMetadataFieldAlbumArtist; - case 'date': - return l10n.editMetadataFieldDate; - case 'track_number': - return l10n.editMetadataFieldTrackNum; - case 'total_tracks': - return 'Track Total'; - case 'disc_number': - return l10n.editMetadataFieldDiscNum; - case 'total_discs': - return 'Disc Total'; - case 'genre': - return l10n.editMetadataFieldGenre; - case 'isrc': - return l10n.editMetadataFieldIsrc; - case 'lyrics': - return l10n.trackLyrics; - case 'label': - return l10n.editMetadataFieldLabel; - case 'copyright': - return l10n.editMetadataFieldCopyright; - case 'composer': - return 'Composer'; - case 'cover': - return l10n.editMetadataFieldCover; - default: - return key; - } - } - - TextEditingController? _controllerForKey(String key) { - switch (key) { - case 'title': - return _titleCtrl; - case 'artist': - return _artistCtrl; - case 'album': - return _albumCtrl; - case 'album_artist': - return _albumArtistCtrl; - case 'date': - return _dateCtrl; - case 'track_number': - return _trackNumCtrl; - case 'total_tracks': - return _trackTotalCtrl; - case 'disc_number': - return _discNumCtrl; - case 'total_discs': - return _discTotalCtrl; - case 'genre': - return _genreCtrl; - case 'isrc': - return _isrcCtrl; - case 'lyrics': - return _lyricsCtrl; - case 'label': - return _labelCtrl; - case 'copyright': - return _copyrightCtrl; - case 'composer': - return _composerCtrl; - default: - return null; - } - } - - void _selectAllFields() { - setState(() { - _autoFillFields.addAll(_fieldDefs.keys); - }); - } - - void _selectEmptyFields() { - setState(() { - _autoFillFields.clear(); - for (final key in _fieldDefs.keys) { - if (key == 'cover') { - if (!_hasValue(_currentCoverPath) && !_hasValue(_selectedCoverPath)) { - _autoFillFields.add(key); - } - continue; - } - final ctrl = _controllerForKey(key); - if (ctrl != null && ctrl.text.trim().isEmpty) { - _autoFillFields.add(key); - } - } - }); - } - - String _normalizeMetadataText(String value) { - final collapsed = value - .toLowerCase() - .replaceAll(_metadataCollapsePattern, ' ') - .trim(); - return collapsed.replaceAll(_metadataWhitespacePattern, ' '); - } - - bool _looksLikeIsrc(String value) { - return _isrcPattern.hasMatch(value.trim().toUpperCase()); - } - - String? _extractRawSpotifyTrackIdFromValue(Object? value) { - final raw = value?.toString().trim() ?? ''; - if (raw.isEmpty) return null; - - if (_spotifyTrackIdPattern.hasMatch(raw)) { - return raw; - } - - if (raw.startsWith('spotify:')) { - final parts = raw.split(':'); - final last = parts.isNotEmpty ? parts.last.trim() : ''; - if (_spotifyTrackIdPattern.hasMatch(last)) { - return last; - } - return null; - } - - final uri = Uri.tryParse(raw); - if (uri != null && - uri.host.contains('spotify.com') && - uri.pathSegments.length >= 2 && - uri.pathSegments.first == 'track') { - final candidate = uri.pathSegments[1].trim(); - if (_spotifyTrackIdPattern.hasMatch(candidate)) { - return candidate; - } - } - - return null; - } - - String? _extractRawDeezerTrackIdFromValue(Object? value) { - final raw = value?.toString().trim() ?? ''; - if (raw.isEmpty) return null; - - if (_deezerTrackIdPattern.hasMatch(raw)) { - return raw; - } - - if (raw.startsWith('deezer:')) { - final parts = raw.split(':'); - final last = parts.isNotEmpty ? parts.last.trim() : ''; - if (_deezerTrackIdPattern.hasMatch(last)) { - return last; - } - } - - final uri = Uri.tryParse(raw); - if (uri != null && uri.host.contains('deezer.com')) { - final trackIndex = uri.pathSegments.indexOf('track'); - if (trackIndex >= 0 && trackIndex + 1 < uri.pathSegments.length) { - final candidate = uri.pathSegments[trackIndex + 1].trim(); - if (_deezerTrackIdPattern.hasMatch(candidate)) { - return candidate; - } - } - } - - return null; - } - - String? _extractRawSpotifyTrackId(Map track) { - for (final candidate in [track['spotify_id'], track['id']]) { - final spotifyId = _extractRawSpotifyTrackIdFromValue(candidate); - if (spotifyId != null) return spotifyId; - } - - final externalLinks = track['external_links']; - if (externalLinks is Map) { - final spotifyId = _extractRawSpotifyTrackIdFromValue( - externalLinks['spotify'], - ); - if (spotifyId != null) return spotifyId; - } - - return null; - } - - String? _extractRawDeezerTrackId(Map track) { - for (final candidate in [ - track['deezer_id'], - track['spotify_id'], - track['id'], - ]) { - final deezerId = _extractRawDeezerTrackIdFromValue(candidate); - if (deezerId != null) return deezerId; - } - - final externalLinks = track['external_links']; - if (externalLinks is Map) { - final deezerId = _extractRawDeezerTrackIdFromValue( - externalLinks['deezer'], - ); - if (deezerId != null) return deezerId; - } - - return null; - } - - Map _unwrapTrackPayload(Map payload) { - final track = payload['track']; - if (track is Map) { - return track; - } - return payload; - } - - void _mergeOnlineTrackData( - Map enriched, - Map track, - ) { - void put(String key, Object? value) { - final text = value?.toString().trim() ?? ''; - if (text.isNotEmpty && text != 'null') { - enriched[key] = text; - } - } - - put('title', track['name'] ?? track['title']); - put('artist', track['artists'] ?? track['artist']); - put('album', track['album_name'] ?? track['album']); - put('album_artist', track['album_artist']); - put('date', track['release_date']); - put('track_number', track['track_number']); - put('total_tracks', track['total_tracks']); - put('disc_number', track['disc_number']); - put('total_discs', track['total_discs']); - put('isrc', track['isrc']); - put('genre', track['genre']); - put('label', track['label']); - put('copyright', track['copyright']); - put('composer', track['composer']); - } - - Future<_ResolvedAutoFillTrack?> _resolveAutoFillTrackFromIdentifiers( - String currentIsrc, - ) async { - if (_looksLikeIsrc(currentIsrc)) { - final deezerTrack = await PlatformBridge.searchDeezerByISRC(currentIsrc); - return _ResolvedAutoFillTrack( - track: _unwrapTrackPayload(deezerTrack), - deezerId: _extractRawDeezerTrackId(deezerTrack), - ); - } - - final sourceTrackId = widget.sourceTrackId?.trim() ?? ''; - if (sourceTrackId.isEmpty) { - return null; - } - - final deezerId = _extractRawDeezerTrackIdFromValue(sourceTrackId); - if (deezerId != null) { - final deezerTrack = await PlatformBridge.getProviderMetadata( - 'deezer', - 'track', - deezerId, - ); - return _ResolvedAutoFillTrack( - track: _unwrapTrackPayload(deezerTrack), - deezerId: deezerId, - ); - } - - final spotifyId = _extractRawSpotifyTrackIdFromValue(sourceTrackId); - if (spotifyId != null) { - final deezerTrack = await PlatformBridge.convertSpotifyToDeezer( - 'track', - spotifyId, - ); - final track = _unwrapTrackPayload(deezerTrack); - return _ResolvedAutoFillTrack( - track: track, - deezerId: - _extractRawDeezerTrackId(track) ?? - _extractRawDeezerTrackId(deezerTrack), - ); - } - - return null; - } - - int _metadataMatchScore( - Map track, { - required String currentTitle, - required String currentArtist, - required String currentAlbum, - required String currentIsrc, - }) { - var score = 0; - - final candidateIsrc = (track['isrc']?.toString() ?? '') - .trim() - .toUpperCase(); - if (currentIsrc.isNotEmpty && candidateIsrc == currentIsrc) { - score += 10000; - } - - final candidateTitle = _normalizeMetadataText( - (track['name'] ?? track['title'] ?? '').toString(), - ); - final candidateArtist = _normalizeMetadataText( - (track['artists'] ?? track['artist'] ?? '').toString(), - ); - final candidateAlbum = _normalizeMetadataText( - (track['album_name'] ?? track['album'] ?? '').toString(), - ); - - if (currentTitle.isNotEmpty && candidateTitle.isNotEmpty) { - if (candidateTitle == currentTitle) { - score += 400; - } else if (candidateTitle.contains(currentTitle) || - currentTitle.contains(candidateTitle)) { - score += 180; - } - } - - if (currentArtist.isNotEmpty && candidateArtist.isNotEmpty) { - if (candidateArtist == currentArtist) { - score += 320; - } else if (candidateArtist.contains(currentArtist) || - currentArtist.contains(candidateArtist)) { - score += 140; - } - } - - if (currentAlbum.isNotEmpty && candidateAlbum.isNotEmpty) { - if (candidateAlbum == currentAlbum) { - score += 120; - } else if (candidateAlbum.contains(currentAlbum) || - currentAlbum.contains(candidateAlbum)) { - score += 50; - } - } - - return score; - } - - Future _fetchAndFill() async { - if (_autoFillFields.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.editMetadataAutoFillNoneSelected)), - ); - return; - } - - setState(() => _fetching = true); - - try { - final title = _titleCtrl.text.trim(); - final artist = _artistCtrl.text.trim(); - final album = _albumCtrl.text.trim(); - final currentIsrc = _isrcCtrl.text.trim().toUpperCase(); - final shouldFetchLyrics = _autoFillFields.contains('lyrics'); - final needsTrackLookup = _autoFillFields.any((key) => key != 'lyrics'); - Map? best; - String? deezerId; - - if (needsTrackLookup) { - try { - final resolved = await _resolveAutoFillTrackFromIdentifiers( - currentIsrc, - ); - if (resolved != null) { - best = resolved.track; - deezerId = resolved.deezerId; - } - } catch (e) { - _log.w('Identifier-first autofill lookup failed: $e'); - } - } - - final queryParts = []; - if (title.isNotEmpty) queryParts.add(title); - if (artist.isNotEmpty) queryParts.add(artist); - if (queryParts.isEmpty && album.isNotEmpty) queryParts.add(album); - - if (needsTrackLookup && best == null && queryParts.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)), - ); - } - return; - } - - final normalizedTitle = _normalizeMetadataText(title); - final normalizedArtist = _normalizeMetadataText(artist); - final normalizedAlbum = _normalizeMetadataText(album); - - if (needsTrackLookup && best == null) { - final query = queryParts.join(' '); - final results = await PlatformBridge.searchTracksWithMetadataProviders( - query, - limit: 5, - ); - - if (!mounted) return; - - if (results.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)), - ); - return; - } - - // Pick best match using current metadata, not only provider order. - best = results.first; - var bestScore = -1; - for (final result in results) { - final score = _metadataMatchScore( - result, - currentTitle: normalizedTitle, - currentArtist: normalizedArtist, - currentAlbum: normalizedAlbum, - currentIsrc: currentIsrc, - ); - if (score > bestScore) { - bestScore = score; - best = result; - } - } - } - - final selectedBest = best; - if (needsTrackLookup && selectedBest == null) { - throw StateError('No metadata match resolved for auto-fill'); - } - - final enriched = {}; - if (selectedBest != null) { - enriched.addAll({ - 'title': (selectedBest['name'] ?? '').toString(), - 'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '') - .toString(), - 'album': (selectedBest['album_name'] ?? selectedBest['album'] ?? '') - .toString(), - 'album_artist': (selectedBest['album_artist'] ?? '').toString(), - 'date': (selectedBest['release_date'] ?? '').toString(), - 'track_number': (selectedBest['track_number'] ?? '').toString(), - 'total_tracks': (selectedBest['total_tracks'] ?? '').toString(), - 'disc_number': (selectedBest['disc_number'] ?? '').toString(), - 'total_discs': (selectedBest['total_discs'] ?? '').toString(), - 'isrc': (selectedBest['isrc'] ?? '').toString(), - 'composer': (selectedBest['composer'] ?? '').toString(), - }); - _mergeOnlineTrackData(enriched, selectedBest); - } - - final enrichedIsrc = (enriched['isrc'] ?? '').trim(); - final needsIsrc = - _autoFillFields.contains('isrc') && enrichedIsrc.isEmpty; - final needsExtended = - _autoFillFields.contains('genre') || - _autoFillFields.contains('label') || - _autoFillFields.contains('copyright') || - _autoFillFields.contains('composer'); - - final rawSpotifyId = selectedBest == null - ? _extractRawSpotifyTrackIdFromValue(widget.sourceTrackId) - : _extractRawSpotifyTrackId(selectedBest); - - deezerId ??= selectedBest == null - ? null - : _extractRawDeezerTrackId(selectedBest); - final candidateIsrc = enrichedIsrc.toUpperCase(); - final deezerLookupIsrc = _looksLikeIsrc(currentIsrc) - ? currentIsrc - : (_looksLikeIsrc(candidateIsrc) ? candidateIsrc : ''); - - if (needsIsrc || needsExtended) { - try { - if (deezerId == null && deezerLookupIsrc.isNotEmpty) { - final deezerResult = await PlatformBridge.searchDeezerByISRC( - deezerLookupIsrc, - ); - deezerId = _extractRawDeezerTrackId(deezerResult); - _mergeOnlineTrackData(enriched, deezerResult); - } - - if (deezerId == null && rawSpotifyId != null) { - // Spotify IDs can be mapped through SongLink to a Deezer track. - final deezerData = await PlatformBridge.convertSpotifyToDeezer( - 'track', - rawSpotifyId, - ); - final trackData = deezerData['track']; - if (trackData is Map) { - deezerId = _extractRawDeezerTrackId(trackData); - _mergeOnlineTrackData(enriched, trackData); - } - deezerId ??= _extractRawDeezerTrackId(deezerData); - } - } catch (_) { - // Deezer resolution is best-effort - } - } - - if (!mounted) return; - - // Fetch ISRC from Deezer track metadata if still missing - if (needsIsrc && - (enriched['isrc'] ?? '').trim().isEmpty && - deezerId != null) { - try { - final deezerMeta = await PlatformBridge.getProviderMetadata( - 'deezer', - 'track', - deezerId, - ); - final trackData = _unwrapTrackPayload(deezerMeta); - _mergeOnlineTrackData(enriched, trackData); - final deezerIsrc = (trackData['isrc'] ?? '').toString().trim(); - if (deezerIsrc.isNotEmpty) { - enriched['isrc'] = deezerIsrc; - } - } catch (_) {} - } - - if (!mounted) return; - - if (needsExtended && deezerId != null) { - try { - final extended = await PlatformBridge.getDeezerExtendedMetadata( - deezerId, - ); - if (extended != null) { - enriched['genre'] = extended['genre'] ?? ''; - enriched['label'] = extended['label'] ?? ''; - enriched['copyright'] = extended['copyright'] ?? ''; - } - } catch (_) { - // Extended metadata is best-effort - } - } - - if (shouldFetchLyrics) { - final lyricsTitle = - ((selectedBest?['name'] ?? selectedBest?['title'] ?? title) - .toString()) - .trim(); - final lyricsArtist = - ((selectedBest?['artists'] ?? selectedBest?['artist'] ?? artist) - .toString()) - .trim(); - - if (lyricsTitle.isNotEmpty && lyricsArtist.isNotEmpty) { - try { - final lyricsResult = await PlatformBridge.getLyricsLRCWithSource( - rawSpotifyId ?? '', - lyricsTitle, - lyricsArtist, - durationMs: widget.durationMs, - ); - final lyricsText = lyricsResult['lyrics']?.toString().trim() ?? ''; - final instrumental = - (lyricsResult['instrumental'] as bool? ?? false) || - lyricsText == '[instrumental:true]'; - if (!instrumental && lyricsText.isNotEmpty) { - enriched['lyrics'] = lyricsText; - } - } catch (e) { - _log.w('Lyrics autofill failed: $e'); - } - } - } - - if (!mounted) return; - - var filledCount = 0; - for (final key in _autoFillFields) { - if (key == 'cover') continue; - final value = enriched[key]; - if (value != null && - value.isNotEmpty && - value != '0' && - value != 'null') { - final ctrl = _controllerForKey(key); - if (ctrl != null) { - ctrl.text = value; - filledCount++; - } - } - } - - if (_autoFillFields.contains('cover') && selectedBest != null) { - final coverUrl = - (selectedBest['cover_url'] ?? selectedBest['images'] ?? '') - .toString(); - if (coverUrl.isNotEmpty) { - try { - final tempDir = await Directory.systemTemp.createTemp( - 'autofill_cover_', - ); - final coverOutput = - '${tempDir.path}${Platform.pathSeparator}cover.jpg'; - final response = await HttpClient() - .getUrl(Uri.parse(coverUrl)) - .then((req) => req.close()); - final file = File(coverOutput); - final sink = file.openWrite(); - await response.pipe(sink); - if (await file.exists() && await file.length() > 0) { - await _cleanupSelectedCoverTemp(); - if (mounted) { - setState(() { - _selectedCoverPath = coverOutput; - _selectedCoverTempDir = tempDir.path; - _selectedCoverName = 'Online cover'; - }); - filledCount++; - } - } else { - try { - await tempDir.delete(recursive: true); - } catch (_) {} - } - } catch (_) { - // Cover download is best-effort - } - } - } - - if (mounted) { - setState(() {}); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - filledCount > 0 - ? context.l10n.editMetadataAutoFillDone(filledCount) - : context.l10n.editMetadataAutoFillNoResults, - ), - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), - ); - } - } finally { - if (mounted) setState(() => _fetching = false); - } - } - - @override - void initState() { - super.initState(); - final v = widget.initialValues; - _titleCtrl = TextEditingController(text: v['title'] ?? ''); - _artistCtrl = TextEditingController(text: v['artist'] ?? ''); - _albumCtrl = TextEditingController(text: v['album'] ?? ''); - _albumArtistCtrl = TextEditingController(text: v['album_artist'] ?? ''); - _dateCtrl = TextEditingController(text: v['date'] ?? ''); - _trackNumCtrl = TextEditingController(text: v['track_number'] ?? ''); - _trackTotalCtrl = TextEditingController(text: v['total_tracks'] ?? ''); - _discNumCtrl = TextEditingController(text: v['disc_number'] ?? ''); - _discTotalCtrl = TextEditingController(text: v['total_discs'] ?? ''); - _genreCtrl = TextEditingController(text: v['genre'] ?? ''); - _isrcCtrl = TextEditingController(text: v['isrc'] ?? ''); - _lyricsCtrl = TextEditingController(text: v['lyrics'] ?? ''); - _labelCtrl = TextEditingController(text: v['label'] ?? ''); - _copyrightCtrl = TextEditingController(text: v['copyright'] ?? ''); - _composerCtrl = TextEditingController(text: v['composer'] ?? ''); - _commentCtrl = TextEditingController(text: v['comment'] ?? ''); - _loadCurrentCoverPreview(); - } - - @override - void dispose() { - unawaited(_cleanupSelectedCoverTemp()); - unawaited(_cleanupCurrentCoverTemp()); - _titleCtrl.dispose(); - _artistCtrl.dispose(); - _albumCtrl.dispose(); - _albumArtistCtrl.dispose(); - _dateCtrl.dispose(); - _trackNumCtrl.dispose(); - _trackTotalCtrl.dispose(); - _discNumCtrl.dispose(); - _discTotalCtrl.dispose(); - _genreCtrl.dispose(); - _isrcCtrl.dispose(); - _lyricsCtrl.dispose(); - _labelCtrl.dispose(); - _copyrightCtrl.dispose(); - _composerCtrl.dispose(); - _commentCtrl.dispose(); - super.dispose(); - } - - Future _save() async { - setState(() => _saving = true); - - final metadata = { - 'title': _titleCtrl.text, - 'artist': _artistCtrl.text, - 'album': _albumCtrl.text, - 'album_artist': _albumArtistCtrl.text, - 'date': _dateCtrl.text, - 'track_number': _trackNumCtrl.text, - 'track_total': _trackTotalCtrl.text, - 'disc_number': _discNumCtrl.text, - 'disc_total': _discTotalCtrl.text, - 'genre': _genreCtrl.text, - 'isrc': _isrcCtrl.text, - 'lyrics': _lyricsCtrl.text, - 'label': _labelCtrl.text, - 'copyright': _copyrightCtrl.text, - 'composer': _composerCtrl.text, - 'comment': _commentCtrl.text, - 'cover_path': _selectedCoverPath ?? '', - 'artist_tag_mode': widget.artistTagMode, - }; - - try { - final result = await PlatformBridge.editFileMetadata( - widget.filePath, - metadata, - ); - - if (result['error'] != null) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('${result['error']}'))); - } - setState(() => _saving = false); - return; - } - - final method = result['method'] as String?; - - if (method == 'ffmpeg') { - // For SAF files, Kotlin returns temp_path + saf_uri - final tempPath = result['temp_path'] as String?; - final safUri = result['saf_uri'] as String?; - final ffmpegTarget = tempPath ?? widget.filePath; - - final lower = widget.filePath.toLowerCase(); - final isMp3 = lower.endsWith('.mp3'); - final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg'); - final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac'); - - // Always include all known fields so -map_metadata 0 + explicit - // -metadata flags can both preserve custom tags AND clear fields - // the user emptied. - final vorbisMap = { - 'TITLE': metadata['title'] ?? '', - 'ARTIST': metadata['artist'] ?? '', - 'ALBUM': metadata['album'] ?? '', - 'ALBUMARTIST': metadata['album_artist'] ?? '', - 'DATE': metadata['date'] ?? '', - 'TRACKNUMBER': - (metadata['track_number']?.isNotEmpty == true && - metadata['track_number'] != '0') - ? (metadata['track_total']?.isNotEmpty == true && - metadata['track_total'] != '0' - ? '${metadata['track_number']}/${metadata['track_total']}' - : metadata['track_number']!) - : '', - 'DISCNUMBER': - (metadata['disc_number']?.isNotEmpty == true && - metadata['disc_number'] != '0') - ? (metadata['disc_total']?.isNotEmpty == true && - metadata['disc_total'] != '0' - ? '${metadata['disc_number']}/${metadata['disc_total']}' - : metadata['disc_number']!) - : '', - 'GENRE': metadata['genre'] ?? '', - 'ISRC': metadata['isrc'] ?? '', - 'LYRICS': metadata['lyrics'] ?? '', - 'UNSYNCEDLYRICS': metadata['lyrics'] ?? '', - 'ORGANIZATION': metadata['label'] ?? '', - 'COPYRIGHT': metadata['copyright'] ?? '', - 'COMPOSER': metadata['composer'] ?? '', - 'COMMENT': metadata['comment'] ?? '', - }; - try { - final existingMetadata = await PlatformBridge.readFileMetadata( - ffmpegTarget, - ); - // Preserve ReplayGain tags if present — these are computed once - // during download and should survive manual metadata edits. - final rgFields = { - 'REPLAYGAIN_TRACK_GAIN': - existingMetadata['replaygain_track_gain']?.toString() ?? '', - 'REPLAYGAIN_TRACK_PEAK': - existingMetadata['replaygain_track_peak']?.toString() ?? '', - 'REPLAYGAIN_ALBUM_GAIN': - existingMetadata['replaygain_album_gain']?.toString() ?? '', - 'REPLAYGAIN_ALBUM_PEAK': - existingMetadata['replaygain_album_peak']?.toString() ?? '', - }; - rgFields.forEach((key, value) { - if (value.isNotEmpty) { - vorbisMap[key] = value; - } - }); - } catch (_) { - // Lyrics/ReplayGain preservation is best-effort. - } - - String? existingCoverPath = _selectedCoverPath ?? _currentCoverPath; - String? extractedCoverPath; - if (existingCoverPath == null || existingCoverPath.isEmpty) { - // Preserve current embedded cover when user does not pick a new one. - try { - final tempDir = await Directory.systemTemp.createTemp('cover_'); - final coverOutput = - '${tempDir.path}${Platform.pathSeparator}cover.jpg'; - final coverResult = await PlatformBridge.extractCoverToFile( - ffmpegTarget, - coverOutput, - ); - if (coverResult['error'] == null) { - existingCoverPath = coverOutput; - extractedCoverPath = coverOutput; - } else { - try { - await tempDir.delete(recursive: true); - } catch (_) {} - } - } catch (_) {} - } - - String? ffmpegResult; - if (isMp3) { - ffmpegResult = await FFmpegService.embedMetadataToMp3( - mp3Path: ffmpegTarget, - coverPath: existingCoverPath, - metadata: vorbisMap, - preserveMetadata: true, - ); - } else if (isM4A) { - ffmpegResult = await FFmpegService.embedMetadataToM4a( - m4aPath: ffmpegTarget, - coverPath: existingCoverPath, - metadata: vorbisMap, - preserveMetadata: true, - ); - } else if (isOpus) { - ffmpegResult = await FFmpegService.embedMetadataToOpus( - opusPath: ffmpegTarget, - coverPath: existingCoverPath, - metadata: vorbisMap, - artistTagMode: widget.artistTagMode, - preserveMetadata: true, - ); - } - - // Cleanup extracted temp cover (manual selected cover is cleaned on dispose) - if (extractedCoverPath != null && extractedCoverPath.isNotEmpty) { - final extractedFile = File(extractedCoverPath); - try { - await extractedFile.delete(); - } catch (_) {} - try { - final dir = extractedFile.parent; - if (await dir.exists()) { - await dir.delete(recursive: true); - } - } catch (_) {} - } - - if (ffmpegResult == null) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.metadataSaveFailedFfmpeg)), - ); - } - setState(() => _saving = false); - return; - } - - if (tempPath != null && safUri != null) { - final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); - if (!ok && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.metadataSaveFailedStorage)), - ); - setState(() => _saving = false); - return; - } - } - } - - if (mounted) { - Navigator.pop(context, true); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), - ); - } - } finally { - if (mounted) setState(() => _saving = false); - } - } - - @override - Widget build(BuildContext context) { - final cs = widget.colorScheme; - - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: DraggableScrollableSheet( - initialChildSize: 0.85, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) => Column( - children: [ - Padding( - padding: const EdgeInsets.only(top: 12, bottom: 8), - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: cs.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Row( - children: [ - Expanded( - child: Text( - context.l10n.trackEditMetadata, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - if (_saving) - const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ) - else - FilledButton( - onPressed: _save, - child: Text(context.l10n.dialogSave), - ), - ], - ), - ), - const SizedBox(height: 12), - Expanded( - child: ListView( - controller: scrollController, - padding: const EdgeInsets.symmetric(horizontal: 24), - children: [ - const SizedBox(height: 6), - _buildCoverEditor(cs), - _buildAutoFillSection(cs), - _field('Title', _titleCtrl), - _field('Artist', _artistCtrl), - _field('Album', _albumCtrl), - _field('Album Artist', _albumArtistCtrl), - _field('Date', _dateCtrl, hint: 'YYYY-MM-DD or YYYY'), - Row( - children: [ - Expanded( - child: _field( - 'Track #', - _trackNumCtrl, - keyboard: TextInputType.number, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _field( - 'Track Total', - _trackTotalCtrl, - keyboard: TextInputType.number, - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _field( - 'Disc #', - _discNumCtrl, - keyboard: TextInputType.number, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _field( - 'Disc Total', - _discTotalCtrl, - keyboard: TextInputType.number, - ), - ), - ], - ), - _field('Genre', _genreCtrl), - _field('ISRC', _isrcCtrl), - _field( - context.l10n.trackLyrics, - _lyricsCtrl, - maxLines: 8, - keyboard: TextInputType.multiline, - ), - Padding( - padding: const EdgeInsets.only(top: 8, bottom: 4), - child: InkWell( - onTap: () => - setState(() => _showAdvanced = !_showAdvanced), - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Icon( - _showAdvanced - ? Icons.expand_less - : Icons.expand_more, - size: 20, - color: cs.onSurfaceVariant, - ), - const SizedBox(width: 8), - Text( - 'Advanced', - style: Theme.of(context).textTheme.labelLarge - ?.copyWith(color: cs.onSurfaceVariant), - ), - ], - ), - ), - ), - ), - if (_showAdvanced) ...[ - _field('Label', _labelCtrl), - _field('Copyright', _copyrightCtrl), - _field('Composer', _composerCtrl), - _field('Comment', _commentCtrl, maxLines: 3), - ], - const SizedBox(height: 24), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildAutoFillSection(ColorScheme cs) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Container( - decoration: BoxDecoration( - color: cs.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InkWell( - onTap: () => setState(() => _showAutoFill = !_showAutoFill), - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - child: Row( - children: [ - Icon(Icons.travel_explore, size: 20, color: cs.primary), - const SizedBox(width: 8), - Expanded( - child: Text( - context.l10n.editMetadataAutoFill, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: cs.onSurface, - fontWeight: FontWeight.w600, - ), - ), - ), - Icon( - _showAutoFill ? Icons.expand_less : Icons.expand_more, - size: 20, - color: cs.onSurfaceVariant, - ), - ], - ), - ), - ), - if (_showAutoFill) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - context.l10n.editMetadataAutoFillDesc, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), - ), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - _quickSelectButton( - label: context.l10n.editMetadataSelectAll, - onTap: _selectAllFields, - cs: cs, - ), - const SizedBox(width: 8), - _quickSelectButton( - label: context.l10n.editMetadataSelectEmpty, - onTap: _selectEmptyFields, - cs: cs, - ), - ], - ), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Wrap( - spacing: 6, - runSpacing: 4, - children: _fieldDefs.keys.map((key) { - final selected = _autoFillFields.contains(key); - return FilterChip( - label: Text(_fieldLabel(key)), - selected: selected, - onSelected: _fetching - ? null - : (val) { - setState(() { - if (val) { - _autoFillFields.add(key); - } else { - _autoFillFields.remove(key); - } - }); - }, - selectedColor: cs.primaryContainer, - checkmarkColor: cs.onPrimaryContainer, - labelStyle: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: selected - ? cs.onPrimaryContainer - : cs.onSurfaceVariant, - ), - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ); - }).toList(), - ), - ), - const SizedBox(height: 10), - Padding( - padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12), - child: SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: (_fetching || _saving || _autoFillFields.isEmpty) - ? null - : _fetchAndFill, - icon: _fetching - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.auto_fix_high), - label: Text( - _fetching - ? context.l10n.editMetadataAutoFillSearching - : context.l10n.editMetadataAutoFillFetch, - ), - ), - ), - ), - ], - ], - ), - ), - ); - } - - Widget _quickSelectButton({ - required String label, - required VoidCallback onTap, - required ColorScheme cs, - }) { - return InkWell( - onTap: _fetching ? null : onTap, - borderRadius: BorderRadius.circular(16), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: Border.all(color: cs.outline.withValues(alpha: 0.5)), - ), - child: Text( - label, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: cs.primary), - ), - ), - ); - } - - Widget _buildCoverEditor(ColorScheme cs) { - final hasSelectedCover = _hasValue(_selectedCoverPath); - final hasCurrentCover = _hasValue(_currentCoverPath); - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: cs.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Cover Art', - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: cs.onSurface), - ), - const SizedBox(height: 6), - if (_loadingCurrentCover) - const LinearProgressIndicator(minHeight: 2) - else if (!hasCurrentCover) - Text( - context.l10n.trackCoverNoEmbeddedArt, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: _saving ? null : _pickCoverImage, - icon: const Icon(Icons.image_outlined), - label: Text( - hasSelectedCover - ? context.l10n.trackCoverReplace - : context.l10n.trackCoverPick, - ), - ), - ), - if (hasSelectedCover) ...[ - const SizedBox(width: 8), - IconButton( - tooltip: context.l10n.trackCoverClearSelected, - onPressed: _saving - ? null - : () async { - await _cleanupSelectedCoverTemp(); - if (!mounted) return; - setState(() {}); - }, - icon: const Icon(Icons.close), - ), - ], - ], - ), - if (hasCurrentCover || hasSelectedCover) ...[ - const SizedBox(height: 12), - Row( - children: [ - if (hasCurrentCover) - Expanded( - child: _buildCoverPreviewTile( - cs: cs, - path: _currentCoverPath!, - label: context.l10n.trackCoverCurrent, - ), - ), - if (hasCurrentCover && hasSelectedCover) - const SizedBox(width: 12), - if (hasSelectedCover) - Expanded( - child: _buildCoverPreviewTile( - cs: cs, - path: _selectedCoverPath!, - label: - _selectedCoverName ?? - context.l10n.trackCoverSelected, - ), - ), - ], - ), - if (hasSelectedCover) ...[ - const SizedBox(height: 8), - Text( - context.l10n.trackCoverReplaceNotice, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), - ), - ], - ], - ], - ), - ), - ); - } - - Widget _buildCoverPreviewTile({ - required ColorScheme cs, - required String path, - required String label, - }) { - return Column( - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: cs.shadow.withValues(alpha: 0.15), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.file( - File(path), - height: 160, - width: 160, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => Container( - width: 160, - height: 160, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.broken_image, - color: cs.onSurfaceVariant, - size: 32, - ), - ), - ), - ), - ), - const SizedBox(height: 8), - Text( - label, - style: Theme.of( - context, - ).textTheme.labelMedium?.copyWith(color: cs.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ); - } - - Widget _field( - String label, - TextEditingController controller, { - String? hint, - TextInputType? keyboard, - int maxLines = 1, - }) { - final cs = widget.colorScheme; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: TextField( - controller: controller, - keyboardType: keyboard, - maxLines: maxLines, - decoration: InputDecoration( - labelText: label, - hintText: hint, - filled: true, - fillColor: cs.surfaceContainerHighest.withValues(alpha: 0.5), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.5), - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.5), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: cs.primary, width: 2), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - ), - ), - ); - } -} - -class _MetadataItem { - final String label; - final String value; - - _MetadataItem(this.label, this.value); -} diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index e85f712f..3efb1568 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -29,7 +29,7 @@ class PlatformBridge { 'spotify_id': spotifyId, 'isrc': isrc, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'checkAvailability'); } static Future> _invokeDownloadMethod( @@ -38,7 +38,7 @@ class PlatformBridge { ) async { final request = jsonEncode(payload.toJson()); final result = await _channel.invokeMethod(method, request); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, method); } static Future> downloadByStrategy({ @@ -133,7 +133,7 @@ class PlatformBridge { 'output_dir': outputDir, 'isrc': isrc, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'checkDuplicate'); } static Future buildFilename( @@ -156,8 +156,7 @@ class PlatformBridge { static Future?> pickSafTree() async { final result = await _channel.invokeMethod('pickSafTree'); - if (result == null) return null; - return jsonDecode(result as String) as Map; + return _decodeNullableMapResult(result, 'pickSafTree'); } static Future safExists(String uri) async { @@ -172,7 +171,7 @@ class PlatformBridge { static Future> safStat(String uri) async { final result = await _channel.invokeMethod('safStat', {'uri': uri}); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'safStat'); } static Future> resolveSafFile({ @@ -185,7 +184,7 @@ class PlatformBridge { 'relative_dir': relativeDir, 'file_name': fileName, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'resolveSafFile'); } static Future copyContentUriToTemp(String uri) async { @@ -259,7 +258,7 @@ class PlatformBridge { 'artist_name': artistName, 'duration_ms': durationMs, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'fetchLyrics'); } static Future getLyricsLRC( @@ -293,7 +292,7 @@ class PlatformBridge { 'file_path': filePath ?? '', 'duration_ms': durationMs, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'getLyricsLRCWithSource'); } static Future> embedLyricsToFile( @@ -304,7 +303,7 @@ class PlatformBridge { 'file_path': filePath, 'lyrics': lyrics, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'embedLyricsToFile'); } static Future cleanupConnections() async { @@ -321,7 +320,7 @@ class PlatformBridge { 'output_path': outputPath, 'max_quality': maxQuality, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'downloadCoverToFile'); } static Future> extractCoverToFile( @@ -332,7 +331,7 @@ class PlatformBridge { 'audio_path': audioPath, 'output_path': outputPath, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'extractCoverToFile'); } static Future> fetchAndSaveLyrics({ @@ -351,7 +350,7 @@ class PlatformBridge { 'output_path': outputPath, 'audio_file_path': audioFilePath, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'fetchAndSaveLyrics'); } /// Providers not in the list are disabled. @@ -364,15 +363,13 @@ class PlatformBridge { static Future> getLyricsProviders() async { final result = await _channel.invokeMethod('getLyricsProviders'); - final List decoded = jsonDecode(result as String) as List; - return decoded.cast(); + return _decodeStringListResult(result, 'getLyricsProviders'); } static Future>> getAvailableLyricsProviders() async { final result = await _channel.invokeMethod('getAvailableLyricsProviders'); - final List decoded = jsonDecode(result as String) as List; - return decoded.cast>(); + return _decodeMapListResult(result, 'getAvailableLyricsProviders'); } /// Sets advanced lyrics fetch options used by provider-specific integrations. @@ -387,7 +384,7 @@ class PlatformBridge { static Future> getLyricsFetchOptions() async { final result = await _channel.invokeMethod('getLyricsFetchOptions'); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'getLyricsFetchOptions'); } static Future> reEnrichFile( @@ -397,14 +394,14 @@ class PlatformBridge { final result = await _channel.invokeMethod('reEnrichFile', { 'request_json': requestJSON, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'reEnrichFile'); } static Future> readFileMetadata(String filePath) async { final result = await _channel.invokeMethod('readFileMetadata', { 'file_path': filePath, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'readFileMetadata'); } static Future> editFileMetadata( @@ -416,7 +413,7 @@ class PlatformBridge { 'file_path': filePath, 'metadata_json': metadataJSON, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'editFileMetadata'); } /// Rewrites ARTIST/ALBUMARTIST Vorbis comments as multiple split entries @@ -431,7 +428,7 @@ class PlatformBridge { 'artist': artist, 'album_artist': albumArtist, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'rewriteSplitArtistTags'); } static Future writeTempToSaf(String tempPath, String safUri) async { @@ -439,7 +436,7 @@ class PlatformBridge { 'temp_path': tempPath, 'saf_uri': safUri, }); - final map = jsonDecode(result as String) as Map; + final map = _decodeRequiredMapResult(result, 'writeTempToSaf'); return map['success'] == true; } @@ -510,7 +507,7 @@ class PlatformBridge { 'artist_limit': artistLimit, 'filter': filter ?? '', }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'searchProviderAll'); } static Future> getDeezerRelatedArtists( @@ -521,14 +518,14 @@ class PlatformBridge { 'artist_id': artistId, 'limit': limit, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'getDeezerRelatedArtists'); } static Future> parseProviderUrl(String url) async { final result = await _channel.invokeMethod('parseProviderUrl', { 'url': url, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'parseProviderUrl'); } static Future> getProviderMetadata( @@ -546,7 +543,7 @@ class PlatformBridge { 'getProviderMetadata returned null for $providerId:$resourceType:$resourceId', ); } - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'getProviderMetadata'); } static Future> searchDeezerByISRC( @@ -557,7 +554,7 @@ class PlatformBridge { 'isrc': isrc, 'item_id': itemId ?? '', }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'searchDeezerByISRC'); } static Future?> getDeezerExtendedMetadata( @@ -568,7 +565,10 @@ class PlatformBridge { 'track_id': trackId, }); if (result == null) return null; - final data = jsonDecode(result as String) as Map; + final data = _decodeRequiredMapResult( + result, + 'getDeezerExtendedMetadata', + ); return { 'genre': data['genre'] as String? ?? '', 'label': data['label'] as String? ?? '', @@ -588,20 +588,19 @@ class PlatformBridge { 'resource_type': resourceType, 'spotify_id': spotifyId, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'convertSpotifyToDeezer'); } static Future>> getGoLogs() async { final result = await _channel.invokeMethod('getLogs'); - final logs = jsonDecode(result as String) as List; - return logs.map((e) => e as Map).toList(); + return _decodeMapListResult(result, 'getGoLogs'); } static Future> getGoLogsSince(int index) async { final result = await _channel.invokeMethod('getLogsSince', { 'index': index, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'getGoLogsSince'); } static Future clearGoLogs() async { @@ -635,7 +634,7 @@ class PlatformBridge { final result = await _channel.invokeMethod('loadExtensionsFromDir', { 'dir_path': dirPath, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'loadExtensionsFromDir'); } static Future> loadExtensionFromPath( @@ -645,7 +644,7 @@ class PlatformBridge { final result = await _channel.invokeMethod('loadExtensionFromPath', { 'file_path': filePath, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'loadExtensionFromPath'); } static Future unloadExtension(String extensionId) async { @@ -667,7 +666,7 @@ class PlatformBridge { final result = await _channel.invokeMethod('upgradeExtension', { 'file_path': filePath, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'upgradeExtension'); } static Future> checkExtensionUpgrade( @@ -677,13 +676,12 @@ class PlatformBridge { final result = await _channel.invokeMethod('checkExtensionUpgrade', { 'file_path': filePath, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'checkExtensionUpgrade'); } static Future>> getInstalledExtensions() async { final result = await _channel.invokeMethod('getInstalledExtensions'); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); + return _decodeMapListResult(result, 'getInstalledExtensions'); } static Future setExtensionEnabled( @@ -706,8 +704,7 @@ class PlatformBridge { static Future> getProviderPriority() async { final result = await _channel.invokeMethod('getProviderPriority'); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as String).toList(); + return _decodeStringListResult(result, 'getProviderPriority'); } static Future setDownloadFallbackExtensionIds( @@ -730,8 +727,7 @@ class PlatformBridge { static Future> getMetadataProviderPriority() async { final result = await _channel.invokeMethod('getMetadataProviderPriority'); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as String).toList(); + return _decodeStringListResult(result, 'getMetadataProviderPriority'); } static Future> getExtensionSettings( @@ -740,7 +736,7 @@ class PlatformBridge { final result = await _channel.invokeMethod('getExtensionSettings', { 'extension_id': extensionId, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'getExtensionSettings'); } static Future setExtensionSettings( @@ -766,7 +762,7 @@ class PlatformBridge { if (result == null || (result as String).isEmpty) { return {'success': true}; } - return jsonDecode(result) as Map; + return _decodeRequiredMapResult(result, 'invokeExtensionAction'); } static Future>> searchTracksWithExtensions( @@ -778,8 +774,7 @@ class PlatformBridge { 'query': query, 'limit': limit, }); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); + return _decodeMapListResult(result, 'searchTracksWithExtensions'); } static Future>> searchTracksWithMetadataProviders( @@ -794,8 +789,7 @@ class PlatformBridge { 'searchTracksWithMetadataProviders', {'query': query, 'limit': limit, 'include_extensions': includeExtensions}, ); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); + return _decodeMapListResult(result, 'searchTracksWithMetadataProviders'); } static Future cleanupExtensions() async { @@ -809,8 +803,7 @@ class PlatformBridge { final result = await _channel.invokeMethod('getExtensionPendingAuth', { 'extension_id': extensionId, }); - if (result == null) return null; - return jsonDecode(result as String) as Map; + return _decodeNullableMapResult(result, 'getExtensionPendingAuth'); } static Future setExtensionAuthCode( @@ -854,8 +847,7 @@ class PlatformBridge { static Future>> getAllPendingAuthRequests() async { final result = await _channel.invokeMethod('getAllPendingAuthRequests'); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); + return _decodeMapListResult(result, 'getAllPendingAuthRequests'); } static Future?> getPendingFFmpegCommand( @@ -864,8 +856,7 @@ class PlatformBridge { final result = await _channel.invokeMethod('getPendingFFmpegCommand', { 'command_id': commandId, }); - if (result == null) return null; - return jsonDecode(result as String) as Map; + return _decodeNullableMapResult(result, 'getPendingFFmpegCommand'); } static Future setFFmpegCommandResult( @@ -885,8 +876,7 @@ class PlatformBridge { static Future>> getAllPendingFFmpegCommands() async { final result = await _channel.invokeMethod('getAllPendingFFmpegCommands'); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); + return _decodeMapListResult(result, 'setFFmpegCommandResult'); } static Future>> customSearchWithExtension( @@ -899,20 +889,17 @@ class PlatformBridge { 'query': query, 'options': options != null ? jsonEncode(options) : '', }); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); + return _decodeMapListResult(result, 'customSearchWithExtension'); } static Future>> getSearchProviders() async { final result = await _channel.invokeMethod('getSearchProviders'); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); + return _decodeMapListResult(result, 'getSearchProviders'); } static Future>> getBuiltInProviders() async { final result = await _channel.invokeMethod('getBuiltInProviders'); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); + return _decodeMapListResult(result, 'getBuiltInProviders'); } static Future?> handleURLWithExtension( @@ -922,8 +909,7 @@ class PlatformBridge { final result = await _channel.invokeMethod('handleURLWithExtension', { 'url': url, }); - if (result == null || result == '') return null; - return jsonDecode(result as String) as Map; + return _decodeNullableMapResult(result, 'handleURLWithExtension'); } catch (e) { return null; } @@ -937,8 +923,7 @@ class PlatformBridge { static Future>> getURLHandlers() async { final result = await _channel.invokeMethod('getURLHandlers'); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); + return _decodeMapListResult(result, 'getURLHandlers'); } static Future?> getExtensionHomeFeed( @@ -948,8 +933,7 @@ class PlatformBridge { final result = await _channel.invokeMethod('getExtensionHomeFeed', { 'extension_id': extensionId, }); - if (result == null || result == '') return null; - return jsonDecode(result as String) as Map; + return _decodeNullableMapResult(result, 'getExtensionHomeFeed'); } catch (e) { _log.e('getExtensionHomeFeed failed: $e'); return null; @@ -964,8 +948,7 @@ class PlatformBridge { 'getExtensionBrowseCategories', {'extension_id': extensionId}, ); - if (result == null || result == '') return null; - return jsonDecode(result as String) as Map; + return _decodeNullableMapResult(result, 'getExtensionBrowseCategories'); } catch (e) { _log.e('getExtensionBrowseCategories failed: $e'); return null; @@ -986,8 +969,7 @@ class PlatformBridge { final result = await _channel.invokeMethod('scanLibraryFolder', { 'folder_path': folderPath, }); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); + return _decodeMapListResult(result, 'scanLibraryFolder'); } static Future> scanLibraryFolderIncremental( @@ -1001,7 +983,7 @@ class PlatformBridge { 'folder_path': folderPath, 'existing_files': jsonEncode(existingFiles), }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'scanLibraryFolderIncremental'); } static Future> scanLibraryFolderIncrementalFromSnapshot( @@ -1012,7 +994,10 @@ class PlatformBridge { 'scanLibraryFolderIncrementalFromSnapshot', {'folder_path': folderPath, 'snapshot_path': snapshotPath}, ); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult( + result, + 'scanLibraryFolderIncrementalFromSnapshot', + ); } static Future>> scanSafTree(String treeUri) async { @@ -1020,8 +1005,7 @@ class PlatformBridge { final result = await _channel.invokeMethod('scanSafTree', { 'tree_uri': treeUri, }); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); + return _decodeMapListResult(result, 'scanSafTree'); } static Future> scanSafTreeIncremental( @@ -1035,7 +1019,7 @@ class PlatformBridge { 'tree_uri': treeUri, 'existing_files': jsonEncode(existingFiles), }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'scanSafTreeIncremental'); } static Future> scanSafTreeIncrementalFromSnapshot( @@ -1046,14 +1030,17 @@ class PlatformBridge { 'scanSafTreeIncrementalFromSnapshot', {'tree_uri': treeUri, 'snapshot_path': snapshotPath}, ); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult( + result, + 'scanSafTreeIncrementalFromSnapshot', + ); } static Future> getSafFileModTimes(List uris) async { final result = await _channel.invokeMethod('getSafFileModTimes', { 'uris': jsonEncode(uris), }); - final map = jsonDecode(result as String) as Map; + final map = _decodeRequiredMapResult(result, 'getSafFileModTimes'); return map.map((key, value) => MapEntry(key, (value as num).toInt())); } @@ -1072,6 +1059,73 @@ class PlatformBridge { await _channel.invokeMethod('cancelLibraryScan'); } + static Object? _decodeJsonResult(dynamic result) { + if (result is String) { + if (result.isEmpty) return null; + return jsonDecode(result); + } + return result; + } + + static Map _decodeRequiredMapResult( + dynamic result, + String method, + ) { + final decoded = _decodeJsonResult(result); + if (decoded is Map) { + return decoded.cast(); + } + throw FormatException( + 'Expected map result from $method, got ${decoded.runtimeType}', + ); + } + + static Map? _decodeNullableMapResult( + dynamic result, + String method, + ) { + final decoded = _decodeJsonResult(result); + if (decoded == null) return null; + if (decoded is Map) { + return decoded.cast(); + } + throw FormatException( + 'Expected nullable map result from $method, got ${decoded.runtimeType}', + ); + } + + static List _decodeRequiredListResult( + dynamic result, + String method, + ) { + final decoded = _decodeJsonResult(result); + if (decoded is List) return decoded; + throw FormatException( + 'Expected list result from $method, got ${decoded.runtimeType}', + ); + } + + static List> _decodeMapListResult( + dynamic result, + String method, + ) { + return _decodeRequiredListResult(result, method).map((entry) { + if (entry is Map) return entry.cast(); + throw FormatException( + 'Expected map entry from $method, got ${entry.runtimeType}', + ); + }).toList(); + } + + static List _decodeStringListResult(dynamic result, String method) { + return _decodeRequiredListResult(result, method).map((entry) { + if (entry is String) return entry; + throw FormatException( + 'Expected string entry from $method, got ${entry.runtimeType}', + ); + }).toList(); + } + static Map _decodeMapResult(dynamic result) { if (result is Map) { return result.cast(); @@ -1134,8 +1188,7 @@ class PlatformBridge { final result = await _channel.invokeMethod('readAudioMetadata', { 'file_path': filePath, }); - if (result == null || result == '') return null; - return jsonDecode(result as String) as Map; + return _decodeNullableMapResult(result, 'readAudioMetadata'); } catch (e) { _log.w('Failed to read audio metadata: $e'); return null; @@ -1150,7 +1203,7 @@ class PlatformBridge { 'file_path': filePath, 'metadata': metadata != null ? jsonEncode(metadata) : '', }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'runPostProcessing'); } static Future> runPostProcessingV2( @@ -1167,13 +1220,12 @@ class PlatformBridge { 'input': jsonEncode(input), 'metadata': metadata != null ? jsonEncode(metadata) : '', }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'runPostProcessingV2'); } static Future>> getPostProcessingProviders() async { final result = await _channel.invokeMethod('getPostProcessingProviders'); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); + return _decodeMapListResult(result, 'getPostProcessingProviders'); } static Future initExtensionStore(String cacheDir) async { @@ -1206,8 +1258,7 @@ class PlatformBridge { final result = await _channel.invokeMethod('getStoreExtensions', { 'force_refresh': forceRefresh, }); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); + return _decodeMapListResult(result, 'getStoreExtensions'); } static Future>> searchStoreExtensions( @@ -1219,14 +1270,12 @@ class PlatformBridge { 'query': query, 'category': category ?? '', }); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); + return _decodeMapListResult(result, 'searchStoreExtensions'); } static Future> getStoreCategories() async { final result = await _channel.invokeMethod('getStoreCategories'); - final list = jsonDecode(result as String) as List; - return list.cast(); + return _decodeStringListResult(result, 'getStoreCategories'); } static Future downloadStoreExtension( @@ -1255,6 +1304,6 @@ class PlatformBridge { 'cue_path': cuePath, 'audio_dir': audioDir, }); - return jsonDecode(result as String) as Map; + return _decodeRequiredMapResult(result, 'parseCueSheet'); } }