From 00d03de2b1fc304fe1593ae2ed3ded503a3c61c8 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 26 Jul 2026 22:51:55 +0700 Subject: [PATCH] refactor(home): split explore, recent, import, and search-results into part files --- lib/screens/home_tab.dart | 1916 +--------------------- lib/screens/home_tab_explore.dart | 522 ++++++ lib/screens/home_tab_import.dart | 252 +++ lib/screens/home_tab_recent.dart | 356 ++++ lib/screens/home_tab_search_results.dart | 796 +++++++++ 5 files changed, 1930 insertions(+), 1912 deletions(-) create mode 100644 lib/screens/home_tab_explore.dart create mode 100644 lib/screens/home_tab_import.dart create mode 100644 lib/screens/home_tab_recent.dart create mode 100644 lib/screens/home_tab_search_results.dart diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 57d847f6..4d1be1a6 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -41,6 +41,10 @@ import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart'; part 'home_tab_helpers.dart'; +part 'home_tab_explore.dart'; +part 'home_tab_recent.dart'; +part 'home_tab_import.dart'; +part 'home_tab_search_results.dart'; part 'home_tab_widgets.dart'; class HomeTab extends ConsumerStatefulWidget { @@ -679,255 +683,6 @@ class _HomeTabState extends ConsumerState } } - Future _importCsv(BuildContext context, WidgetRef ref) async { - if (_isCsvImporting) return; - _setCsvImporting(true); - - int currentProgress = 0; - int totalTracks = 0; - - bool progressDialogInitialized = false; - bool progressDialogVisible = false; - BuildContext? progressDialogContext; - StateSetter? setDialogState; - - void showProgressDialog() { - if (progressDialogInitialized || !mounted) return; - progressDialogInitialized = true; - progressDialogVisible = true; - showDialog( - context: this.context, - useRootNavigator: false, - barrierDismissible: false, - builder: (dialogCtx) => StatefulBuilder( - builder: (dialogCtx, setState) { - progressDialogContext = dialogCtx; - setDialogState = setState; - return AlertDialog( - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text( - totalTracks > 0 - ? context.l10n.progressFetchingMetadata( - currentProgress, - totalTracks, - ) - : context.l10n.progressReadingCsv, - ), - ], - ), - ); - }, - ), - ).then((_) { - progressDialogVisible = false; - progressDialogContext = null; - }); - } - - void closeProgressDialog() { - if (!progressDialogVisible) return; - setDialogState = null; - try { - if (progressDialogContext != null) { - Navigator.of(progressDialogContext!).pop(); - } else if (mounted) { - final navigator = Navigator.of(this.context); - if (navigator.canPop()) { - navigator.pop(); - } - } - } catch (_) {} - progressDialogVisible = false; - progressDialogContext = null; - } - - try { - final tracks = await CsvImportService.pickAndParseCsv( - onProgress: (current, total) { - currentProgress = current; - totalTracks = total; - if (!progressDialogInitialized && total > 0) { - showProgressDialog(); - } - setDialogState?.call(() {}); - }, - ); - - closeProgressDialog(); - - if (tracks.isNotEmpty) { - final settings = ref.read(settingsProvider); - - if (!mounted) return; - - final l10n = this.context.l10n; - - final options = await showDialog<_CsvImportOptions>( - context: this.context, - useRootNavigator: false, - builder: (dialogCtx) { - var skipDownloaded = true; - return StatefulBuilder( - builder: (dialogCtx, setDialogState) => AlertDialog( - title: Text(l10n.dialogImportPlaylistTitle), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.dialogImportPlaylistMessage(tracks.length)), - const SizedBox(height: 12), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.homeSkipAlreadyDownloaded), - value: skipDownloaded, - onChanged: (value) { - setDialogState(() { - skipDownloaded = value ?? true; - }); - }, - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop( - dialogCtx, - const _CsvImportOptions( - confirmed: false, - skipDownloaded: true, - ), - ), - child: Text(l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop( - dialogCtx, - _CsvImportOptions( - confirmed: true, - skipDownloaded: skipDownloaded, - ), - ), - child: Text(l10n.dialogImport), - ), - ], - ), - ); - }, - ); - - if (options == null || !options.confirmed) return; - - var tracksToQueue = tracks; - var skippedDownloadedCount = 0; - - if (options.skipDownloaded) { - final historyLookups = tracks - .map(historyLookupForTrack) - .toList(growable: false); - final existingHistoryKeys = await ref.read( - downloadHistoryBatchExistsProvider( - HistoryBatchLookupRequest(historyLookups), - ).future, - ); - tracksToQueue = []; - for (var i = 0; i < tracks.length; i++) { - final track = tracks[i]; - final isDownloaded = existingHistoryKeys.contains( - historyLookups[i].lookupKey, - ); - if (isDownloaded) { - skippedDownloadedCount++; - continue; - } - tracksToQueue.add(track); - } - } - - if (tracksToQueue.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar( - content: Text( - l10n.discographySkippedDownloaded(0, skippedDownloadedCount), - ), - ), - ); - } - return; - } - - final queueSnackbarMessage = skippedDownloadedCount > 0 - ? l10n.discographySkippedDownloaded( - tracksToQueue.length, - skippedDownloadedCount, - ) - : l10n.snackbarAddedTracksToQueue(tracksToQueue.length); - - if (!mounted) return; - - if (settings.askQualityBeforeDownload || - settings.allowQualityVariants) { - DownloadServicePicker.show( - this.context, - trackName: l10n.csvImportTracks(tracksToQueue.length), - artistName: l10n.dialogImportPlaylistTitle, - onSelect: (quality, service) { - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue( - tracksToQueue, - service, - qualityOverride: quality, - ); - if (mounted) { - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar( - content: Text(queueSnackbarMessage), - action: buildViewQueueSnackBarAction(this.context), - ), - ); - } - }, - ); - } else { - final extensionState = ref.read(extensionProvider); - final service = resolveEffectiveDownloadService( - settings.defaultService, - extensionState, - ); - if (service.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar( - content: Text(this.context.l10n.extensionsNoDownloadProvider), - ), - ); - } - return; - } - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracksToQueue, service); - if (mounted) { - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar( - content: Text(queueSnackbarMessage), - action: buildViewQueueSnackBarAction(this.context), - ), - ); - } - } - } - } finally { - closeProgressDialog(); - _setCsvImporting(false); - } - } - @override Widget build(BuildContext context) { super.build(context); @@ -1509,1667 +1264,4 @@ class _HomeTabState extends ConsumerState ], ); } - - List _buildExploreSections( - List sections, - String? greeting, - ColorScheme colorScheme, - ) { - final hasGreeting = greeting != null && greeting.isNotEmpty; - final sectionOffset = hasGreeting ? 1 : 0; - final totalCount = sections.length + sectionOffset + 1; - - return [ - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - if (hasGreeting && index == 0) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Text( - greeting, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ); - } - - final sectionIndex = index - sectionOffset; - if (sectionIndex < sections.length) { - final section = sections[sectionIndex]; - return KeyedSubtree( - key: ValueKey('explore-section-${section.uri}-${section.title}'), - child: _buildExploreSection(section, colorScheme), - ); - } - - return const SizedBox(height: 24); - }, childCount: totalCount), - ), - ]; - } - - Widget _buildExploreSection(ExploreSection section, ColorScheme colorScheme) { - final sectionHeight = _exploreSectionHeight(context); - if (section.isYTMusicQuickPicks) { - return _buildYTMusicQuickPicksSection(section, colorScheme); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 20, 16, 12), - child: Text( - section.title, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - SizedBox( - height: sectionHeight, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12), - itemCount: section.items.length, - itemBuilder: (context, index) { - final item = section.items[index]; - return StaggeredListItem( - key: ValueKey( - 'explore-item-${item.type}-${item.id}-${item.uri}', - ), - index: index, - staggerDelay: const Duration(milliseconds: 50), - child: _buildExploreItem(item, colorScheme), - ); - }, - ), - ), - ], - ); - } - - /// Skeleton mirroring [_buildExploreSection]'s layout — album cards or - /// artist circles — shown while the home feed loads. Wrap in - /// [ShimmerLoading] for the sweep effect. - Widget _buildExploreSectionSkeleton({required bool isArtist}) { - final cardSize = _exploreCardSize(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Padding( - padding: EdgeInsets.fromLTRB(16, 20, 16, 12), - child: SkeletonBox(width: 140, height: 20, borderRadius: 4), - ), - SizedBox( - height: _exploreSectionHeight(context), - child: ListView.builder( - scrollDirection: Axis.horizontal, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 12), - itemCount: 6, - itemBuilder: (context, index) => SizedBox( - width: cardSize, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Column( - crossAxisAlignment: isArtist - ? CrossAxisAlignment.center - : CrossAxisAlignment.start, - children: [ - SkeletonBox( - width: cardSize, - height: cardSize, - borderRadius: isArtist ? cardSize / 2 : 10, - ), - const SizedBox(height: 8), - SkeletonBox( - width: cardSize * (isArtist ? 0.6 : 0.85), - height: 14, - borderRadius: 4, - ), - if (!isArtist) ...[ - const SizedBox(height: 6), - SkeletonBox( - width: cardSize * 0.5, - height: 12, - borderRadius: 4, - ), - ], - ], - ), - ), - ), - ), - ), - ], - ); - } - - Widget _buildYTMusicQuickPicksSection( - ExploreSection section, - ColorScheme colorScheme, - ) { - const itemsPerPage = 5; - final totalPages = (section.items.length / itemsPerPage).ceil(); - - return _QuickPicksPageView( - section: section, - colorScheme: colorScheme, - itemsPerPage: itemsPerPage, - totalPages: totalPages, - onItemTap: _navigateToExploreItem, - onItemMenu: _showTrackBottomSheet, - ); - } - - Widget _buildExploreItem(ExploreItem item, ColorScheme colorScheme) { - final isArtist = item.type == 'artist'; - final cardSize = _exploreCardSize(context); - final iconSize = cardSize * 0.3; - - return Semantics( - button: true, - label: context.l10n.a11yOpenItem(item.type, item.name), - child: GestureDetector( - onTap: () => _navigateToExploreItem(item), - child: SizedBox( - width: cardSize, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Column( - crossAxisAlignment: isArtist - ? CrossAxisAlignment.center - : CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular( - isArtist ? cardSize / 2 : 10, - ), - child: item.coverUrl != null && item.coverUrl!.isNotEmpty - ? CachedCoverImage( - imageUrl: item.coverUrl!, - width: cardSize, - height: cardSize, - fit: BoxFit.cover, - placeholder: (context, url) => ShimmerLoading( - child: Container( - width: cardSize, - height: cardSize, - color: colorScheme.surfaceContainerHighest, - ), - ), - errorWidget: (context, url, error) => Container( - width: cardSize, - height: cardSize, - color: colorScheme.surfaceContainerHighest, - child: Icon( - _getIconForType(item.type), - color: colorScheme.onSurfaceVariant, - size: iconSize, - ), - ), - ) - : Container( - width: cardSize, - height: cardSize, - color: colorScheme.surfaceContainerHighest, - child: Icon( - _getIconForType(item.type), - color: colorScheme.onSurfaceVariant, - size: iconSize, - ), - ), - ), - const SizedBox(height: 8), - Text( - item.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: isArtist ? TextAlign.center : TextAlign.start, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - if (item.artists.isNotEmpty && !isArtist) - ClickableArtistName( - artistName: item.artists, - coverUrl: item.coverUrl, - extensionId: item.providerId, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 12, - ), - ), - ], - ), - ), - ), - ), - ); - } - - IconData _getIconForType(String type) { - switch (type) { - case 'track': - return Icons.music_note; - case 'album': - return Icons.album; - case 'playlist': - return Icons.playlist_play; - case 'artist': - return Icons.person; - case 'station': - return Icons.radio; - default: - return Icons.music_note; - } - } - - String? _providerIdForExploreItem(ExploreItem item) { - final itemProviderId = item.providerId?.trim(); - if (itemProviderId != null && itemProviderId.isNotEmpty) { - return itemProviderId; - } - - final feedProviderId = ref.read(exploreProvider).providerId?.trim(); - if (feedProviderId != null && feedProviderId.isNotEmpty) { - return feedProviderId; - } - - return null; - } - - void _showMissingExploreProviderMessage() { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.extensionsNoHomeFeedExtensions)), - ); - } - - void _navigateToExploreItem(ExploreItem item) async { - final extensionId = _providerIdForExploreItem(item); - - switch (item.type) { - case 'track': - _showTrackBottomSheet(item); - return; - case 'album': - if (extensionId == null) { - _showMissingExploreProviderMessage(); - return; - } - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ExtensionAlbumScreen( - extensionId: extensionId, - albumId: item.id, - albumName: item.name, - coverUrl: item.coverUrl, - ), - ), - ); - return; - case 'playlist': - if (extensionId == null) { - _showMissingExploreProviderMessage(); - return; - } - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ExtensionPlaylistScreen( - extensionId: extensionId, - playlistId: item.id, - playlistName: item.name, - coverUrl: item.coverUrl, - ), - ), - ); - return; - case 'artist': - if (extensionId == null) { - _showMissingExploreProviderMessage(); - return; - } - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ExtensionArtistScreen( - extensionId: extensionId, - artistId: item.id, - artistName: item.name, - coverUrl: item.coverUrl, - ), - ), - ); - return; - default: - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('${item.type}: ${item.name}'))); - return; - } - } - - void _showTrackBottomSheet(ExploreItem item) { - final colorScheme = Theme.of(context).colorScheme; - - showModalBottomSheet( - context: context, - useRootNavigator: true, - backgroundColor: colorScheme.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - margin: const EdgeInsets.only(top: 12), - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: item.coverUrl != null && item.coverUrl!.isNotEmpty - ? CachedCoverImage( - imageUrl: item.coverUrl!, - width: 64, - height: 64, - fit: BoxFit.cover, - ) - : Container( - width: 64, - height: 64, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w600), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - ClickableArtistName( - artistName: item.artists, - coverUrl: item.coverUrl, - extensionId: item.providerId, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: colorScheme.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ), - const Divider(height: 1), - ListTile( - leading: Icon(Icons.download, color: colorScheme.primary), - title: Text(context.l10n.downloadTitle), - onTap: () { - Navigator.pop(context); - _handleExploreTrackPrimaryAction(item); - }, - ), - ListTile( - leading: Icon(Icons.album, color: colorScheme.onSurfaceVariant), - title: Text(context.l10n.homeGoToAlbum), - onTap: () { - Navigator.pop(context); - _navigateToTrackAlbum(item); - }, - ), - const SizedBox(height: 8), - ], - ), - ), - ); - } - - Future _handleExploreTrackPrimaryAction(ExploreItem item) async { - final settings = ref.read(settingsProvider); - - final track = Track( - id: item.id, - name: item.name, - artistName: item.artists, - albumName: item.albumName ?? '', - albumId: item.albumId, - duration: item.durationMs ~/ 1000, - trackNumber: null, - discNumber: null, - totalDiscs: null, - isrc: null, - releaseDate: item.releaseDate, - coverUrl: item.coverUrl, - source: _providerIdForExploreItem(item), - ); - - if (settings.askQualityBeforeDownload || settings.allowQualityVariants) { - DownloadServicePicker.show( - context, - trackName: track.name, - artistName: track.artistName, - coverUrl: track.coverUrl, - onSelect: (quality, service) { - ref - .read(downloadQueueProvider.notifier) - .addToQueue(track, service, qualityOverride: quality); - showAddedToQueueSnackBar(context, track.name); - }, - ); - } else { - final extensionState = ref.read(extensionProvider); - final service = resolveEffectiveDownloadService( - settings.defaultService, - extensionState, - ); - if (service.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), - ); - return; - } - ref.read(downloadQueueProvider.notifier).addToQueue(track, service); - showAddedToQueueSnackBar(context, track.name); - } - } - - Future _navigateToTrackAlbum(ExploreItem item) async { - if (item.albumId != null && item.albumId!.isNotEmpty) { - final extensionId = _providerIdForExploreItem(item); - if (extensionId == null) { - _showMissingExploreProviderMessage(); - return; - } - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ExtensionAlbumScreen( - extensionId: extensionId, - albumId: item.albumId!, - albumName: item.albumName ?? 'Album', - coverUrl: item.coverUrl, - ), - ), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.homeAlbumInfoUnavailable)), - ); - } - } - - Widget _buildRecentAccess(_RecentAccessView view, ColorScheme colorScheme) { - final uniqueItems = view.uniqueItems; - final downloadIds = view.downloadIds; - final hasHiddenDownloads = view.hasHiddenDownloads; - - return Padding( - padding: - const EdgeInsets.fromLTRB(16, 8, 16, 8) + - EdgeInsets.symmetric(horizontal: wideListInset(context)), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - context.l10n.homeRecent, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - if (uniqueItems.isNotEmpty) - TextButton( - onPressed: () { - for (final id in downloadIds) { - ref - .read(recentAccessProvider.notifier) - .hideDownloadFromRecents(id); - } - ref.read(recentAccessProvider.notifier).clearHistory(); - }, - child: Text( - context.l10n.dialogClearAll, - style: TextStyle(color: colorScheme.primary, fontSize: 12), - ), - ), - ], - ), - const SizedBox(height: 8), - if (uniqueItems.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: SizedBox( - width: double.infinity, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - hasHiddenDownloads ? Icons.visibility_off : Icons.history, - size: 48, - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.5, - ), - ), - const SizedBox(height: 12), - Text( - context.l10n.recentEmpty, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - if (hasHiddenDownloads) ...[ - const SizedBox(height: 16), - OutlinedButton.icon( - onPressed: () { - ref - .read(recentAccessProvider.notifier) - .clearHiddenDownloads(); - }, - icon: const Icon(Icons.visibility, size: 18), - label: Text(context.l10n.recentShowAllDownloads), - ), - ], - ], - ), - ), - ) - else - ...uniqueItems.map( - (item) => _buildRecentAccessItem( - item, - colorScheme, - view.downloadFilePathByRecentKey, - ), - ), - ], - ), - ); - } - - Widget _buildRecentAccessItem( - RecentAccessItem item, - ColorScheme colorScheme, - Map downloadFilePathByRecentKey, - ) { - IconData typeIcon; - String typeLabel; - final isDownloaded = item.providerId == 'download'; - - switch (item.type) { - case RecentAccessType.artist: - typeIcon = Icons.person; - typeLabel = context.l10n.recentTypeArtist; - case RecentAccessType.album: - typeIcon = Icons.album; - typeLabel = context.l10n.recentTypeAlbum; - case RecentAccessType.track: - typeIcon = Icons.music_note; - typeLabel = context.l10n.recentTypeSong; - case RecentAccessType.playlist: - typeIcon = Icons.playlist_play; - typeLabel = context.l10n.recentTypePlaylist; - } - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: InkWell( - onTap: () => _navigateToRecentItem(item), - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), - child: Row( - children: [ - _DownloadedOrRemoteCover( - downloadedFilePath: isDownloaded - ? downloadFilePathByRecentKey['${item.type.name}:${item.id}'] - : null, - imageUrl: item.imageUrl, - width: 56, - height: 56, - borderRadius: BorderRadius.circular( - item.type == RecentAccessType.artist ? 28 : 4, - ), - fallbackIcon: typeIcon, - colorScheme: colorScheme, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 4), - Text( - isDownloaded - ? (item.subtitle != null - ? '${context.l10n.recentTypeSong} • ${item.subtitle}' - : context.l10n.recentTypeSong) - : (item.subtitle != null - ? '$typeLabel • ${item.subtitle}' - : typeLabel), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: isDownloaded - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - IconButton( - tooltip: context.l10n.actionDismiss, - icon: Icon( - Icons.close, - size: 20, - color: colorScheme.onSurfaceVariant, - ), - onPressed: () { - if (item.providerId == 'download') { - ref - .read(recentAccessProvider.notifier) - .hideDownloadFromRecents(item.id); - } else { - ref.read(recentAccessProvider.notifier).removeItem(item); - } - }, - ), - ], - ), - ), - ), - ); - } - - bool _isEnabledMetadataExtension(String? providerId) { - final normalized = providerId?.trim(); - if (normalized == null || normalized.isEmpty) return false; - - return ref - .read(extensionProvider) - .extensions - .any( - (ext) => - ext.enabled && ext.hasMetadataProvider && ext.id == normalized, - ); - } - - Future _navigateToRecentItem(RecentAccessItem item) async { - _searchFocusNode.unfocus(); - - switch (item.type) { - case RecentAccessType.artist: - if (_isEnabledMetadataExtension(item.providerId)) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ExtensionArtistScreen( - extensionId: item.providerId!, - artistId: item.id, - artistName: item.name, - coverUrl: item.imageUrl, - ), - ), - ); - } else { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ArtistScreen( - artistId: item.id, - artistName: item.name, - coverUrl: item.imageUrl, - ), - ), - ); - } - return; - case RecentAccessType.album: - if (item.providerId == 'download') { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => DownloadedAlbumScreen( - albumName: item.name, - artistName: item.subtitle ?? '', - coverUrl: item.imageUrl, - ), - ), - ); - } else if (_isEnabledMetadataExtension(item.providerId)) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ExtensionAlbumScreen( - extensionId: item.providerId!, - albumId: item.id, - albumName: item.name, - coverUrl: item.imageUrl, - ), - ), - ); - } else { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => AlbumScreen( - albumId: item.id, - albumName: item.name, - coverUrl: item.imageUrl, - ), - ), - ); - } - return; - case RecentAccessType.track: - final historyItem = await ref - .read(downloadHistoryProvider.notifier) - .getBySpotifyIdAsync(item.id); - if (!mounted) return; - if (historyItem != null) { - _navigateToMetadataScreen(historyItem); - } else { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(item.name))); - } - return; - case RecentAccessType.playlist: - if (item.id.trim().isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.recentPlaylistInfo(item.name))), - ); - return; - } - - if (_isEnabledMetadataExtension(item.providerId)) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ExtensionPlaylistScreen( - extensionId: item.providerId!, - playlistId: item.id, - playlistName: item.name, - coverUrl: item.imageUrl, - ), - ), - ); - } else { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PlaylistScreen( - playlistName: item.name, - coverUrl: item.imageUrl, - tracks: const [], - playlistId: item.id, - ), - ), - ); - } - return; - } - } - - Future _navigateToMetadataScreen( - DownloadHistoryItem item, { - List? navigationItems, - int? navigationIndex, - }) async { - final navigator = Navigator.of(context); - precacheCoverImage(context, item.coverUrl); - final beforeModTime = - await DownloadedEmbeddedCoverResolver.readFileModTimeMillis( - item.filePath, - ); - if (!mounted) return; - final result = await navigator.push( - slidePageRoute( - page: TrackMetadataScreen( - item: item, - historyNavigationItems: navigationItems, - navigationIndex: navigationIndex, - ), - ), - ); - await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( - item.filePath, - beforeModTime: beforeModTime, - force: result == true, - onChanged: _onEmbeddedCoverChanged, - ); - } - - Widget _buildErrorWidget(String error, ColorScheme colorScheme) { - final l10n = context.l10n; - final isRateLimit = - error.contains('429') || - error.toLowerCase().contains('rate limit') || - error.toLowerCase().contains('too many requests'); - final isUrlNotRecognized = error == 'url_not_recognized'; - - if (isRateLimit) { - return ErrorCard(error: error, colorScheme: colorScheme); - } - - if (isUrlNotRecognized) { - return Card( - elevation: 0, - color: colorScheme.errorContainer.withValues(alpha: 0.5), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon(Icons.link_off, color: colorScheme.error), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.errorUrlNotRecognized, - style: TextStyle( - color: colorScheme.error, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - l10n.errorUrlNotRecognizedMessage, - style: TextStyle(color: colorScheme.error, fontSize: 12), - ), - ], - ), - ), - ], - ), - ), - ); - } - - return ErrorCard(error: l10n.errorUrlFetchFailed, colorScheme: colorScheme); - } - - Widget _buildEmptySearchResultWidget(ColorScheme colorScheme) { - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 340), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 86, - height: 86, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: colorScheme.surfaceContainerHighest, - ), - child: Icon( - Icons.manage_search, - size: 46, - color: colorScheme.primary, - ), - ), - const SizedBox(height: 16), - Text( - context.l10n.errorNoTracksFound, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: colorScheme.onSurface, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Text( - context.l10n.searchEmptyResultSubtitle, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), - ), - ), - ], - ), - ); - } - - String _sortOptionLabel(HomeSearchSortOption option) { - switch (option) { - case HomeSearchSortOption.defaultOrder: - return context.l10n.searchSortDefault; - case HomeSearchSortOption.titleAsc: - return context.l10n.searchSortTitleAZ; - case HomeSearchSortOption.titleDesc: - return context.l10n.searchSortTitleZA; - case HomeSearchSortOption.artistAsc: - return context.l10n.searchSortArtistAZ; - case HomeSearchSortOption.artistDesc: - return context.l10n.searchSortArtistZA; - case HomeSearchSortOption.durationAsc: - return context.l10n.searchSortDurationShort; - case HomeSearchSortOption.durationDesc: - return context.l10n.searchSortDurationLong; - case HomeSearchSortOption.dateAsc: - return context.l10n.searchSortDateOldest; - case HomeSearchSortOption.dateDesc: - return context.l10n.searchSortDateNewest; - } - } - - void _showSortOptions(ColorScheme colorScheme) { - var tempSort = _searchSortOption; - showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - backgroundColor: colorScheme.surfaceContainerLow, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (ctx) => StatefulBuilder( - builder: (ctx, setSheetState) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 32, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - Row( - children: [ - Text( - context.l10n.searchSortTitle, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - TextButton( - onPressed: () => setSheetState( - () => tempSort = HomeSearchSortOption.defaultOrder, - ), - child: Text(context.l10n.libraryFilterReset), - ), - ], - ), - const SizedBox(height: 16), - Wrap( - spacing: 8, - runSpacing: 8, - children: HomeSearchSortOption.values.map((option) { - return FilterChip( - label: Text(_sortOptionLabel(option)), - selected: tempSort == option, - showCheckmark: false, - onSelected: (_) => - setSheetState(() => tempSort = option), - ); - }).toList(), - ), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: () { - Navigator.pop(ctx); - if (_searchSortOption != tempSort) { - setState(() { - _searchSortOption = tempSort; - }); - } - }, - child: Text(context.l10n.libraryFilterApply), - ), - ), - ], - ), - ), - ); - }, - ), - ); - } - - ({List tracks, List indexes}) _sortTrackResults( - List tracks, - List indexes, - ) { - if (tracks.isEmpty || - _searchSortOption == HomeSearchSortOption.defaultOrder) { - return (tracks: tracks, indexes: indexes); - } - if (identical(tracks, _sortedTracksSource) && - identical(indexes, _sortedTrackIndexesSource) && - _sortedTracksMode == _searchSortOption && - _sortedTracksCache != null && - _sortedTrackIndexesCache != null) { - return (tracks: _sortedTracksCache!, indexes: _sortedTrackIndexesCache!); - } - final paired = List.generate( - tracks.length, - (i) => (tracks[i], indexes[i]), - growable: false, - ); - final sortedPairs = sortHomeSearchItems<(Track, int)>( - items: paired, - option: _searchSortOption, - nameOf: (p) => p.$1.name, - artistOf: (p) => p.$1.artistName, - durationOf: (p) => p.$1.duration, - dateOf: (p) => p.$1.releaseDate, - ); - final sortedTracks = sortedPairs.map((p) => p.$1).toList(growable: false); - final sortedIndexes = sortedPairs.map((p) => p.$2).toList(growable: false); - _sortedTracksSource = tracks; - _sortedTrackIndexesSource = indexes; - _sortedTracksMode = _searchSortOption; - _sortedTracksCache = sortedTracks; - _sortedTrackIndexesCache = sortedIndexes; - return (tracks: sortedTracks, indexes: sortedIndexes); - } - - List _buildSearchResults({ - required List tracks, - required bool isLoading, - required String? error, - required ColorScheme colorScheme, - required bool hasResults, - required bool showEmptySearchResult, - required String? searchExtensionId, - required bool showLocalLibraryIndicator, - required Map thumbnailSizesByExtensionId, - }) { - final hasActualData = tracks.isNotEmpty; - - if (!hasActualData && isLoading) { - return [const SliverToBoxAdapter(child: HomeSearchSkeleton())]; - } - if (!hasResults) { - return [const SliverToBoxAdapter(child: SizedBox.shrink())]; - } - - final buckets = _getSearchResultBuckets(tracks); - final realTracks = buckets.realTracks; - final realTrackIndexes = buckets.realTrackIndexes; - final albumItems = buckets.albumItems; - final playlistItems = buckets.playlistItems; - final artistItems = buckets.artistItems; - - final sortedTrackResults = _sortTrackResults(realTracks, realTrackIndexes); - final sortedTracks = sortedTrackResults.tracks; - final sortedTrackIndexes = sortedTrackResults.indexes; - - final slivers = [ - if (error != null) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: _buildErrorWidget(error, colorScheme), - ), - ), - if (isLoading) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: LinearProgressIndicator(), - ), - ), - if (showEmptySearchResult && !hasActualData) - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 96), - child: _buildEmptySearchResultWidget(colorScheme), - ), - ), - ), - ]; - - bool sortButtonShown = false; - - if (artistItems.isNotEmpty) { - slivers.addAll( - _buildVirtualizedResultSection( - title: context.l10n.searchArtists, - itemCount: artistItems.length, - colorScheme: colorScheme, - showSortButton: !sortButtonShown, - itemBuilder: (index, showDivider) => _CollectionItemWidget( - key: ValueKey('artist-${artistItems[index].id}'), - item: artistItems[index], - showDivider: showDivider, - onTap: () => _navigateToExtensionArtist(artistItems[index]), - ), - ), - ); - sortButtonShown = true; - } - - if (albumItems.isNotEmpty) { - slivers.addAll( - _buildVirtualizedResultSection( - title: context.l10n.searchAlbums, - itemCount: albumItems.length, - colorScheme: colorScheme, - showSortButton: !sortButtonShown, - itemBuilder: (index, showDivider) => _CollectionItemWidget( - key: ValueKey('album-${albumItems[index].id}'), - item: albumItems[index], - showDivider: showDivider, - onTap: () => _navigateToExtensionAlbum(albumItems[index]), - ), - ), - ); - sortButtonShown = true; - } - - if (playlistItems.isNotEmpty) { - slivers.addAll( - _buildVirtualizedResultSection( - title: context.l10n.searchPlaylists, - itemCount: playlistItems.length, - colorScheme: colorScheme, - showSortButton: !sortButtonShown, - itemBuilder: (index, showDivider) => _CollectionItemWidget( - key: ValueKey('playlist-${playlistItems[index].id}'), - item: playlistItems[index], - showDivider: showDivider, - onTap: () => _navigateToExtensionPlaylist(playlistItems[index]), - ), - ), - ); - sortButtonShown = true; - } - - if (sortedTracks.isNotEmpty) { - final historyLookups = sortedTracks - .map(historyLookupForTrack) - .toList(growable: false); - final existingHistoryKeys = ref - .watch( - downloadHistoryBatchExistsProvider( - HistoryBatchLookupRequest(historyLookups), - ), - ) - .maybeWhen(data: (keys) => keys, orElse: () => const {}); - slivers.addAll( - _buildVirtualizedResultSection( - title: context.l10n.searchSongs, - itemCount: sortedTracks.length, - colorScheme: colorScheme, - showSortButton: !sortButtonShown, - itemBuilder: (index, showDivider) => _TrackItemWithStatus( - key: ValueKey(sortedTracks[index].id), - track: sortedTracks[index], - index: sortedTrackIndexes[index], - showDivider: showDivider, - onDownload: ({bool forceQualityPicker = false}) => _downloadTrack( - sortedTrackIndexes[index], - forceQualityPicker: forceQualityPicker, - ), - searchExtensionId: searchExtensionId, - showLocalLibraryIndicator: showLocalLibraryIndicator, - thumbnailSizesByExtensionId: thumbnailSizesByExtensionId, - isInHistory: existingHistoryKeys.contains( - historyLookups[index].lookupKey, - ), - ), - ), - ); - } - - slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 16))); - return slivers; - } - - List _buildVirtualizedResultSection({ - required String title, - required int itemCount, - required ColorScheme colorScheme, - required Widget Function(int index, bool showDivider) itemBuilder, - bool showSortButton = false, - }) { - final sectionColor = Theme.of(context).brightness == Brightness.dark - ? Color.alphaBlend( - Colors.white.withValues(alpha: 0.08), - colorScheme.surface, - ) - : colorScheme.surfaceContainerHighest; - - return [ - SliverToBoxAdapter( - child: Padding( - // Aligned with the clamped result list below on wide screens. - padding: - EdgeInsets.fromLTRB(16, 8, 8, 8) + - EdgeInsets.symmetric(horizontal: wideListInset(context)), - child: Row( - children: [ - Expanded( - child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - if (showSortButton) - SizedBox( - height: 32, - child: TextButton.icon( - onPressed: () => _showSortOptions(colorScheme), - icon: Icon( - Icons.swap_vert, - size: 18, - color: - _searchSortOption != HomeSearchSortOption.defaultOrder - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - label: Text( - _searchSortOption != HomeSearchSortOption.defaultOrder - ? _sortOptionLabel(_searchSortOption) - : context.l10n.libraryFilterSort, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: - _searchSortOption != - HomeSearchSortOption.defaultOrder - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - ), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 8), - visualDensity: VisualDensity.compact, - ), - ), - ), - ], - ), - ), - ), - SliverPadding( - padding: EdgeInsets.symmetric(horizontal: wideListInset(context)), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final isFirst = index == 0; - final isLast = index == itemCount - 1; - return StaggeredListItem( - index: index, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: sectionColor, - borderRadius: BorderRadius.vertical( - top: isFirst ? const Radius.circular(20) : Radius.zero, - bottom: isLast ? const Radius.circular(20) : Radius.zero, - ), - ), - clipBehavior: Clip.antiAlias, - child: Material( - color: Colors.transparent, - child: itemBuilder(index, !isLast), - ), - ), - ); - }, childCount: itemCount), - ), - ), - ]; - } - - void _navigateToExtensionAlbum(Track albumItem) async { - final extensionId = albumItem.source; - if (extensionId == null || extensionId.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.errorMissingExtensionSource('album')), - ), - ); - return; - } - - ref.read(settingsProvider.notifier).setHasSearchedBefore(); - - ref - .read(recentAccessProvider.notifier) - .recordAlbumAccess( - id: albumItem.id, - name: albumItem.name, - artistName: albumItem.artistName, - imageUrl: albumItem.coverUrl, - providerId: extensionId, - ); - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ExtensionAlbumScreen( - extensionId: extensionId, - albumId: albumItem.id, - albumName: albumItem.name, - coverUrl: albumItem.coverUrl, - initialAlbumType: albumItem.albumType, - initialTotalTracks: albumItem.totalTracks, - ), - ), - ); - } - - void _navigateToExtensionPlaylist(Track playlistItem) async { - final extensionId = playlistItem.source; - if (extensionId == null || extensionId.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.errorMissingExtensionSource('playlist')), - ), - ); - return; - } - - ref.read(settingsProvider.notifier).setHasSearchedBefore(); - - ref - .read(recentAccessProvider.notifier) - .recordPlaylistAccess( - id: playlistItem.id, - name: playlistItem.name, - ownerName: playlistItem.artistName, - imageUrl: playlistItem.coverUrl, - providerId: extensionId, - ); - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ExtensionPlaylistScreen( - extensionId: extensionId, - playlistId: playlistItem.id, - playlistName: playlistItem.name, - coverUrl: playlistItem.coverUrl, - ), - ), - ); - } - - void _navigateToExtensionArtist(Track artistItem) { - final extensionId = artistItem.source; - if (extensionId == null || extensionId.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.errorMissingExtensionSource('artist')), - ), - ); - return; - } - - ref.read(settingsProvider.notifier).setHasSearchedBefore(); - - ref - .read(recentAccessProvider.notifier) - .recordArtistAccess( - id: artistItem.id, - name: artistItem.name, - imageUrl: artistItem.coverUrl, - providerId: extensionId, - ); - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ExtensionArtistScreen( - extensionId: extensionId, - artistId: artistItem.id, - artistName: artistItem.name, - coverUrl: artistItem.coverUrl, - ), - ), - ); - } - - String _getSearchHint() { - final settings = ref.read(settingsProvider); - final extState = ref.read(extensionProvider); - final searchProvider = HomeSearchProviderPolicy.resolveProvider( - settings.searchProvider, - extState.extensions, - ); - - if (!extState.isInitialized) { - return context.l10n.homeSearchHintDefault; - } - - if (searchProvider != null && searchProvider.isNotEmpty) { - final ext = extState.extensions - .where((e) => e.id == searchProvider) - .firstOrNull; - if (ext != null && ext.enabled) { - if (ext.searchBehavior?.placeholder != null) { - return ext.searchBehavior!.placeholder!; - } - return context.l10n.homeSearchHintProvider(ext.displayName); - } - } - return context.l10n.homeSearchHintDefault; - } - - Widget _buildSearchFilterBar( - List filters, - String? selectedFilter, - ColorScheme colorScheme, - ) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 8), - child: FilterChip( - label: Text(context.l10n.historyFilterAll), - selected: selectedFilter == 'all', - onSelected: (_) { - ref.read(trackProvider.notifier).setSearchFilter('all'); - _triggerSearchWithFilter('all'); - }, - showCheckmark: false, - ), - ), - ...filters.map((filter) { - final isSelected = selectedFilter == filter.id; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: FilterChip( - label: Text(filter.label ?? filter.id), - selected: isSelected, - onSelected: (_) { - ref.read(trackProvider.notifier).setSearchFilter(filter.id); - _triggerSearchWithFilter(filter.id); - }, - showCheckmark: false, - avatar: filter.icon != null - ? Icon(_getFilterIcon(filter.icon!), size: 18) - : null, - ), - ); - }), - ], - ), - ), - ); - } - - IconData _getFilterIcon(String iconName) { - switch (iconName.toLowerCase()) { - case 'music': - case 'track': - case 'song': - return Icons.music_note; - case 'album': - return Icons.album; - case 'artist': - return Icons.person; - case 'playlist': - return Icons.playlist_play; - case 'video': - return Icons.video_library; - case 'podcast': - return Icons.podcasts; - default: - return Icons.search; - } - } - - void _triggerSearchWithFilter(String? filter) { - final text = _urlController.text.trim(); - if (text.isEmpty || text.length < _minLiveSearchChars) return; - if (looksLikeUrlOrSpotifyUri(text)) return; - - _lastSearchQuery = null; - _performSearch(text, filterOverride: filter); - } - - Widget _buildSearchBar(ColorScheme colorScheme) { - final hasText = _urlController.text.isNotEmpty; - - return TextField( - controller: _urlController, - focusNode: _searchFocusNode, - autofocus: false, - decoration: InputDecoration( - hintText: _getSearchHint(), - filled: true, - fillColor: settingsGroupColor(context), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide(color: colorScheme.outlineVariant), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide(color: colorScheme.outlineVariant), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide(color: colorScheme.primary, width: 2), - ), - prefixIcon: _SearchProviderDropdown( - onProviderChanged: () { - _lastSearchQuery = null; - ref.read(trackProvider.notifier).setSearchFilter(null); - setState(() {}); - final text = _urlController.text.trim(); - if (text.isNotEmpty && text.length >= _minLiveSearchChars) { - _performSearch(text); - } - }, - ), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (hasText) - IconButton( - icon: const Icon(Icons.clear), - onPressed: _clearAndRefresh, - tooltip: context.l10n.dialogClear, - ) - else ...[ - IconButton( - icon: const Icon(Icons.file_upload_outlined), - onPressed: _isCsvImporting - ? null - : () => _importCsv(context, ref), - tooltip: context.l10n.homeImportCsvTooltip, - ), - IconButton( - icon: const Icon(Icons.paste), - onPressed: _pasteFromClipboard, - tooltip: context.l10n.actionPaste, - ), - ], - ], - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), - ), - onSubmitted: (_) => _onSearchSubmitted(), - onTapOutside: (_) { - FocusScope.of(context).unfocus(); - }, - ); - } - - void _onSearchSubmitted() { - _liveSearchDebounce?.cancel(); - _pendingLiveSearchQuery = null; - - final text = _urlController.text.trim(); - if (text.isEmpty) return; - - if (looksLikeUrlOrSpotifyUri(text)) { - _fetchMetadata(); - _searchFocusNode.unfocus(); - return; - } - - if (text.length >= 2) { - _performSearch(text); - } - _searchFocusNode.unfocus(); - } } diff --git a/lib/screens/home_tab_explore.dart b/lib/screens/home_tab_explore.dart new file mode 100644 index 00000000..920f74af --- /dev/null +++ b/lib/screens/home_tab_explore.dart @@ -0,0 +1,522 @@ +part of 'home_tab.dart'; + +extension _HomeTabExploreUI on _HomeTabState { + List _buildExploreSections( + List sections, + String? greeting, + ColorScheme colorScheme, + ) { + final hasGreeting = greeting != null && greeting.isNotEmpty; + final sectionOffset = hasGreeting ? 1 : 0; + final totalCount = sections.length + sectionOffset + 1; + + return [ + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + if (hasGreeting && index == 0) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + greeting, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ); + } + + final sectionIndex = index - sectionOffset; + if (sectionIndex < sections.length) { + final section = sections[sectionIndex]; + return KeyedSubtree( + key: ValueKey('explore-section-${section.uri}-${section.title}'), + child: _buildExploreSection(section, colorScheme), + ); + } + + return const SizedBox(height: 24); + }, childCount: totalCount), + ), + ]; + } + + Widget _buildExploreSection(ExploreSection section, ColorScheme colorScheme) { + final sectionHeight = _exploreSectionHeight(context); + if (section.isYTMusicQuickPicks) { + return _buildYTMusicQuickPicksSection(section, colorScheme); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 12), + child: Text( + section.title, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + SizedBox( + height: sectionHeight, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: section.items.length, + itemBuilder: (context, index) { + final item = section.items[index]; + return StaggeredListItem( + key: ValueKey( + 'explore-item-${item.type}-${item.id}-${item.uri}', + ), + index: index, + staggerDelay: const Duration(milliseconds: 50), + child: _buildExploreItem(item, colorScheme), + ); + }, + ), + ), + ], + ); + } + + /// Skeleton mirroring [_buildExploreSection]'s layout — album cards or + /// artist circles — shown while the home feed loads. Wrap in + /// [ShimmerLoading] for the sweep effect. + Widget _buildExploreSectionSkeleton({required bool isArtist}) { + final cardSize = _exploreCardSize(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 20, 16, 12), + child: SkeletonBox(width: 140, height: 20, borderRadius: 4), + ), + SizedBox( + height: _exploreSectionHeight(context), + child: ListView.builder( + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: 6, + itemBuilder: (context, index) => SizedBox( + width: cardSize, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Column( + crossAxisAlignment: isArtist + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + SkeletonBox( + width: cardSize, + height: cardSize, + borderRadius: isArtist ? cardSize / 2 : 10, + ), + const SizedBox(height: 8), + SkeletonBox( + width: cardSize * (isArtist ? 0.6 : 0.85), + height: 14, + borderRadius: 4, + ), + if (!isArtist) ...[ + const SizedBox(height: 6), + SkeletonBox( + width: cardSize * 0.5, + height: 12, + borderRadius: 4, + ), + ], + ], + ), + ), + ), + ), + ), + ], + ); + } + + Widget _buildYTMusicQuickPicksSection( + ExploreSection section, + ColorScheme colorScheme, + ) { + const itemsPerPage = 5; + final totalPages = (section.items.length / itemsPerPage).ceil(); + + return _QuickPicksPageView( + section: section, + colorScheme: colorScheme, + itemsPerPage: itemsPerPage, + totalPages: totalPages, + onItemTap: _navigateToExploreItem, + onItemMenu: _showTrackBottomSheet, + ); + } + + Widget _buildExploreItem(ExploreItem item, ColorScheme colorScheme) { + final isArtist = item.type == 'artist'; + final cardSize = _exploreCardSize(context); + final iconSize = cardSize * 0.3; + + return Semantics( + button: true, + label: context.l10n.a11yOpenItem(item.type, item.name), + child: GestureDetector( + onTap: () => _navigateToExploreItem(item), + child: SizedBox( + width: cardSize, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Column( + crossAxisAlignment: isArtist + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + isArtist ? cardSize / 2 : 10, + ), + child: item.coverUrl != null && item.coverUrl!.isNotEmpty + ? CachedCoverImage( + imageUrl: item.coverUrl!, + width: cardSize, + height: cardSize, + fit: BoxFit.cover, + placeholder: (context, url) => ShimmerLoading( + child: Container( + width: cardSize, + height: cardSize, + color: colorScheme.surfaceContainerHighest, + ), + ), + errorWidget: (context, url, error) => Container( + width: cardSize, + height: cardSize, + color: colorScheme.surfaceContainerHighest, + child: Icon( + _getIconForType(item.type), + color: colorScheme.onSurfaceVariant, + size: iconSize, + ), + ), + ) + : Container( + width: cardSize, + height: cardSize, + color: colorScheme.surfaceContainerHighest, + child: Icon( + _getIconForType(item.type), + color: colorScheme.onSurfaceVariant, + size: iconSize, + ), + ), + ), + const SizedBox(height: 8), + Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: isArtist ? TextAlign.center : TextAlign.start, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + if (item.artists.isNotEmpty && !isArtist) + ClickableArtistName( + artistName: item.artists, + coverUrl: item.coverUrl, + extensionId: item.providerId, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ), + ); + } + + IconData _getIconForType(String type) { + switch (type) { + case 'track': + return Icons.music_note; + case 'album': + return Icons.album; + case 'playlist': + return Icons.playlist_play; + case 'artist': + return Icons.person; + case 'station': + return Icons.radio; + default: + return Icons.music_note; + } + } + + String? _providerIdForExploreItem(ExploreItem item) { + final itemProviderId = item.providerId?.trim(); + if (itemProviderId != null && itemProviderId.isNotEmpty) { + return itemProviderId; + } + + final feedProviderId = ref.read(exploreProvider).providerId?.trim(); + if (feedProviderId != null && feedProviderId.isNotEmpty) { + return feedProviderId; + } + + return null; + } + + void _showMissingExploreProviderMessage() { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoHomeFeedExtensions)), + ); + } + + void _navigateToExploreItem(ExploreItem item) async { + final extensionId = _providerIdForExploreItem(item); + + switch (item.type) { + case 'track': + _showTrackBottomSheet(item); + return; + case 'album': + if (extensionId == null) { + _showMissingExploreProviderMessage(); + return; + } + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ExtensionAlbumScreen( + extensionId: extensionId, + albumId: item.id, + albumName: item.name, + coverUrl: item.coverUrl, + ), + ), + ); + return; + case 'playlist': + if (extensionId == null) { + _showMissingExploreProviderMessage(); + return; + } + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ExtensionPlaylistScreen( + extensionId: extensionId, + playlistId: item.id, + playlistName: item.name, + coverUrl: item.coverUrl, + ), + ), + ); + return; + case 'artist': + if (extensionId == null) { + _showMissingExploreProviderMessage(); + return; + } + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ExtensionArtistScreen( + extensionId: extensionId, + artistId: item.id, + artistName: item.name, + coverUrl: item.coverUrl, + ), + ), + ); + return; + default: + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('${item.type}: ${item.name}'))); + return; + } + } + + void _showTrackBottomSheet(ExploreItem item) { + final colorScheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: item.coverUrl != null && item.coverUrl!.isNotEmpty + ? CachedCoverImage( + imageUrl: item.coverUrl!, + width: 64, + height: 64, + fit: BoxFit.cover, + ) + : Container( + width: 64, + height: 64, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + ClickableArtistName( + artistName: item.artists, + coverUrl: item.coverUrl, + extensionId: item.providerId, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + const Divider(height: 1), + ListTile( + leading: Icon(Icons.download, color: colorScheme.primary), + title: Text(context.l10n.downloadTitle), + onTap: () { + Navigator.pop(context); + _handleExploreTrackPrimaryAction(item); + }, + ), + ListTile( + leading: Icon(Icons.album, color: colorScheme.onSurfaceVariant), + title: Text(context.l10n.homeGoToAlbum), + onTap: () { + Navigator.pop(context); + _navigateToTrackAlbum(item); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + Future _handleExploreTrackPrimaryAction(ExploreItem item) async { + final settings = ref.read(settingsProvider); + + final track = Track( + id: item.id, + name: item.name, + artistName: item.artists, + albumName: item.albumName ?? '', + albumId: item.albumId, + duration: item.durationMs ~/ 1000, + trackNumber: null, + discNumber: null, + totalDiscs: null, + isrc: null, + releaseDate: item.releaseDate, + coverUrl: item.coverUrl, + source: _providerIdForExploreItem(item), + ); + + if (settings.askQualityBeforeDownload || settings.allowQualityVariants) { + DownloadServicePicker.show( + context, + trackName: track.name, + artistName: track.artistName, + coverUrl: track.coverUrl, + onSelect: (quality, service) { + ref + .read(downloadQueueProvider.notifier) + .addToQueue(track, service, qualityOverride: quality); + showAddedToQueueSnackBar(context, track.name); + }, + ); + } else { + final extensionState = ref.read(extensionProvider); + final service = resolveEffectiveDownloadService( + settings.defaultService, + extensionState, + ); + if (service.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + return; + } + ref.read(downloadQueueProvider.notifier).addToQueue(track, service); + showAddedToQueueSnackBar(context, track.name); + } + } + + Future _navigateToTrackAlbum(ExploreItem item) async { + if (item.albumId != null && item.albumId!.isNotEmpty) { + final extensionId = _providerIdForExploreItem(item); + if (extensionId == null) { + _showMissingExploreProviderMessage(); + return; + } + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ExtensionAlbumScreen( + extensionId: extensionId, + albumId: item.albumId!, + albumName: item.albumName ?? 'Album', + coverUrl: item.coverUrl, + ), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.homeAlbumInfoUnavailable)), + ); + } + } +} diff --git a/lib/screens/home_tab_import.dart b/lib/screens/home_tab_import.dart new file mode 100644 index 00000000..b25850e1 --- /dev/null +++ b/lib/screens/home_tab_import.dart @@ -0,0 +1,252 @@ +part of 'home_tab.dart'; + +extension _HomeTabCsvImport on _HomeTabState { + Future _importCsv(BuildContext context, WidgetRef ref) async { + if (_isCsvImporting) return; + _setCsvImporting(true); + + int currentProgress = 0; + int totalTracks = 0; + + bool progressDialogInitialized = false; + bool progressDialogVisible = false; + BuildContext? progressDialogContext; + StateSetter? setDialogState; + + void showProgressDialog() { + if (progressDialogInitialized || !mounted) return; + progressDialogInitialized = true; + progressDialogVisible = true; + showDialog( + context: this.context, + useRootNavigator: false, + barrierDismissible: false, + builder: (dialogCtx) => StatefulBuilder( + builder: (dialogCtx, setState) { + progressDialogContext = dialogCtx; + setDialogState = setState; + return AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + totalTracks > 0 + ? context.l10n.progressFetchingMetadata( + currentProgress, + totalTracks, + ) + : context.l10n.progressReadingCsv, + ), + ], + ), + ); + }, + ), + ).then((_) { + progressDialogVisible = false; + progressDialogContext = null; + }); + } + + void closeProgressDialog() { + if (!progressDialogVisible) return; + setDialogState = null; + try { + if (progressDialogContext != null) { + Navigator.of(progressDialogContext!).pop(); + } else if (mounted) { + final navigator = Navigator.of(this.context); + if (navigator.canPop()) { + navigator.pop(); + } + } + } catch (_) {} + progressDialogVisible = false; + progressDialogContext = null; + } + + try { + final tracks = await CsvImportService.pickAndParseCsv( + onProgress: (current, total) { + currentProgress = current; + totalTracks = total; + if (!progressDialogInitialized && total > 0) { + showProgressDialog(); + } + setDialogState?.call(() {}); + }, + ); + + closeProgressDialog(); + + if (tracks.isNotEmpty) { + final settings = ref.read(settingsProvider); + + if (!mounted) return; + + final l10n = this.context.l10n; + + final options = await showDialog<_CsvImportOptions>( + context: this.context, + useRootNavigator: false, + builder: (dialogCtx) { + var skipDownloaded = true; + return StatefulBuilder( + builder: (dialogCtx, setDialogState) => AlertDialog( + title: Text(l10n.dialogImportPlaylistTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.dialogImportPlaylistMessage(tracks.length)), + const SizedBox(height: 12), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.homeSkipAlreadyDownloaded), + value: skipDownloaded, + onChanged: (value) { + setDialogState(() { + skipDownloaded = value ?? true; + }); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop( + dialogCtx, + const _CsvImportOptions( + confirmed: false, + skipDownloaded: true, + ), + ), + child: Text(l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop( + dialogCtx, + _CsvImportOptions( + confirmed: true, + skipDownloaded: skipDownloaded, + ), + ), + child: Text(l10n.dialogImport), + ), + ], + ), + ); + }, + ); + + if (options == null || !options.confirmed) return; + + var tracksToQueue = tracks; + var skippedDownloadedCount = 0; + + if (options.skipDownloaded) { + final historyLookups = tracks + .map(historyLookupForTrack) + .toList(growable: false); + final existingHistoryKeys = await ref.read( + downloadHistoryBatchExistsProvider( + HistoryBatchLookupRequest(historyLookups), + ).future, + ); + tracksToQueue = []; + for (var i = 0; i < tracks.length; i++) { + final track = tracks[i]; + final isDownloaded = existingHistoryKeys.contains( + historyLookups[i].lookupKey, + ); + if (isDownloaded) { + skippedDownloadedCount++; + continue; + } + tracksToQueue.add(track); + } + } + + if (tracksToQueue.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text( + l10n.discographySkippedDownloaded(0, skippedDownloadedCount), + ), + ), + ); + } + return; + } + + final queueSnackbarMessage = skippedDownloadedCount > 0 + ? l10n.discographySkippedDownloaded( + tracksToQueue.length, + skippedDownloadedCount, + ) + : l10n.snackbarAddedTracksToQueue(tracksToQueue.length); + + if (!mounted) return; + + if (settings.askQualityBeforeDownload || + settings.allowQualityVariants) { + DownloadServicePicker.show( + this.context, + trackName: l10n.csvImportTracks(tracksToQueue.length), + artistName: l10n.dialogImportPlaylistTitle, + onSelect: (quality, service) { + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue( + tracksToQueue, + service, + qualityOverride: quality, + ); + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text(queueSnackbarMessage), + action: buildViewQueueSnackBarAction(this.context), + ), + ); + } + }, + ); + } else { + final extensionState = ref.read(extensionProvider); + final service = resolveEffectiveDownloadService( + settings.defaultService, + extensionState, + ); + if (service.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text(this.context.l10n.extensionsNoDownloadProvider), + ), + ); + } + return; + } + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue(tracksToQueue, service); + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text(queueSnackbarMessage), + action: buildViewQueueSnackBarAction(this.context), + ), + ); + } + } + } + } finally { + closeProgressDialog(); + _setCsvImporting(false); + } + } +} diff --git a/lib/screens/home_tab_recent.dart b/lib/screens/home_tab_recent.dart new file mode 100644 index 00000000..647472ec --- /dev/null +++ b/lib/screens/home_tab_recent.dart @@ -0,0 +1,356 @@ +part of 'home_tab.dart'; + +extension _HomeTabRecentUI on _HomeTabState { + Widget _buildRecentAccess(_RecentAccessView view, ColorScheme colorScheme) { + final uniqueItems = view.uniqueItems; + final downloadIds = view.downloadIds; + final hasHiddenDownloads = view.hasHiddenDownloads; + + return Padding( + padding: + const EdgeInsets.fromLTRB(16, 8, 16, 8) + + EdgeInsets.symmetric(horizontal: wideListInset(context)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.l10n.homeRecent, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if (uniqueItems.isNotEmpty) + TextButton( + onPressed: () { + for (final id in downloadIds) { + ref + .read(recentAccessProvider.notifier) + .hideDownloadFromRecents(id); + } + ref.read(recentAccessProvider.notifier).clearHistory(); + }, + child: Text( + context.l10n.dialogClearAll, + style: TextStyle(color: colorScheme.primary, fontSize: 12), + ), + ), + ], + ), + const SizedBox(height: 8), + if (uniqueItems.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + hasHiddenDownloads ? Icons.visibility_off : Icons.history, + size: 48, + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.5, + ), + ), + const SizedBox(height: 12), + Text( + context.l10n.recentEmpty, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if (hasHiddenDownloads) ...[ + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () { + ref + .read(recentAccessProvider.notifier) + .clearHiddenDownloads(); + }, + icon: const Icon(Icons.visibility, size: 18), + label: Text(context.l10n.recentShowAllDownloads), + ), + ], + ], + ), + ), + ) + else + ...uniqueItems.map( + (item) => _buildRecentAccessItem( + item, + colorScheme, + view.downloadFilePathByRecentKey, + ), + ), + ], + ), + ); + } + + Widget _buildRecentAccessItem( + RecentAccessItem item, + ColorScheme colorScheme, + Map downloadFilePathByRecentKey, + ) { + IconData typeIcon; + String typeLabel; + final isDownloaded = item.providerId == 'download'; + + switch (item.type) { + case RecentAccessType.artist: + typeIcon = Icons.person; + typeLabel = context.l10n.recentTypeArtist; + case RecentAccessType.album: + typeIcon = Icons.album; + typeLabel = context.l10n.recentTypeAlbum; + case RecentAccessType.track: + typeIcon = Icons.music_note; + typeLabel = context.l10n.recentTypeSong; + case RecentAccessType.playlist: + typeIcon = Icons.playlist_play; + typeLabel = context.l10n.recentTypePlaylist; + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: InkWell( + onTap: () => _navigateToRecentItem(item), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + child: Row( + children: [ + _DownloadedOrRemoteCover( + downloadedFilePath: isDownloaded + ? downloadFilePathByRecentKey['${item.type.name}:${item.id}'] + : null, + imageUrl: item.imageUrl, + width: 56, + height: 56, + borderRadius: BorderRadius.circular( + item.type == RecentAccessType.artist ? 28 : 4, + ), + fallbackIcon: typeIcon, + colorScheme: colorScheme, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + isDownloaded + ? (item.subtitle != null + ? '${context.l10n.recentTypeSong} • ${item.subtitle}' + : context.l10n.recentTypeSong) + : (item.subtitle != null + ? '$typeLabel • ${item.subtitle}' + : typeLabel), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: isDownloaded + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + tooltip: context.l10n.actionDismiss, + icon: Icon( + Icons.close, + size: 20, + color: colorScheme.onSurfaceVariant, + ), + onPressed: () { + if (item.providerId == 'download') { + ref + .read(recentAccessProvider.notifier) + .hideDownloadFromRecents(item.id); + } else { + ref.read(recentAccessProvider.notifier).removeItem(item); + } + }, + ), + ], + ), + ), + ), + ); + } + + bool _isEnabledMetadataExtension(String? providerId) { + final normalized = providerId?.trim(); + if (normalized == null || normalized.isEmpty) return false; + + return ref + .read(extensionProvider) + .extensions + .any( + (ext) => + ext.enabled && ext.hasMetadataProvider && ext.id == normalized, + ); + } + + Future _navigateToRecentItem(RecentAccessItem item) async { + _searchFocusNode.unfocus(); + + switch (item.type) { + case RecentAccessType.artist: + if (_isEnabledMetadataExtension(item.providerId)) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ExtensionArtistScreen( + extensionId: item.providerId!, + artistId: item.id, + artistName: item.name, + coverUrl: item.imageUrl, + ), + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ArtistScreen( + artistId: item.id, + artistName: item.name, + coverUrl: item.imageUrl, + ), + ), + ); + } + return; + case RecentAccessType.album: + if (item.providerId == 'download') { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DownloadedAlbumScreen( + albumName: item.name, + artistName: item.subtitle ?? '', + coverUrl: item.imageUrl, + ), + ), + ); + } else if (_isEnabledMetadataExtension(item.providerId)) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ExtensionAlbumScreen( + extensionId: item.providerId!, + albumId: item.id, + albumName: item.name, + coverUrl: item.imageUrl, + ), + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AlbumScreen( + albumId: item.id, + albumName: item.name, + coverUrl: item.imageUrl, + ), + ), + ); + } + return; + case RecentAccessType.track: + final historyItem = await ref + .read(downloadHistoryProvider.notifier) + .getBySpotifyIdAsync(item.id); + if (!mounted) return; + if (historyItem != null) { + _navigateToMetadataScreen(historyItem); + } else { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(item.name))); + } + return; + case RecentAccessType.playlist: + if (item.id.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.recentPlaylistInfo(item.name))), + ); + return; + } + + if (_isEnabledMetadataExtension(item.providerId)) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ExtensionPlaylistScreen( + extensionId: item.providerId!, + playlistId: item.id, + playlistName: item.name, + coverUrl: item.imageUrl, + ), + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PlaylistScreen( + playlistName: item.name, + coverUrl: item.imageUrl, + tracks: const [], + playlistId: item.id, + ), + ), + ); + } + return; + } + } + + Future _navigateToMetadataScreen( + DownloadHistoryItem item, { + List? navigationItems, + int? navigationIndex, + }) async { + final navigator = Navigator.of(context); + precacheCoverImage(context, item.coverUrl); + final beforeModTime = + await DownloadedEmbeddedCoverResolver.readFileModTimeMillis( + item.filePath, + ); + if (!mounted) return; + final result = await navigator.push( + slidePageRoute( + page: TrackMetadataScreen( + item: item, + historyNavigationItems: navigationItems, + navigationIndex: navigationIndex, + ), + ), + ); + await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( + item.filePath, + beforeModTime: beforeModTime, + force: result == true, + onChanged: _onEmbeddedCoverChanged, + ); + } +} diff --git a/lib/screens/home_tab_search_results.dart b/lib/screens/home_tab_search_results.dart new file mode 100644 index 00000000..bc08ccef --- /dev/null +++ b/lib/screens/home_tab_search_results.dart @@ -0,0 +1,796 @@ +// ignore_for_file: invalid_use_of_protected_member +part of 'home_tab.dart'; + +extension _HomeTabSearchResultsUI on _HomeTabState { + Widget _buildErrorWidget(String error, ColorScheme colorScheme) { + final l10n = context.l10n; + final isRateLimit = + error.contains('429') || + error.toLowerCase().contains('rate limit') || + error.toLowerCase().contains('too many requests'); + final isUrlNotRecognized = error == 'url_not_recognized'; + + if (isRateLimit) { + return ErrorCard(error: error, colorScheme: colorScheme); + } + + if (isUrlNotRecognized) { + return Card( + elevation: 0, + color: colorScheme.errorContainer.withValues(alpha: 0.5), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.link_off, color: colorScheme.error), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.errorUrlNotRecognized, + style: TextStyle( + color: colorScheme.error, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + l10n.errorUrlNotRecognizedMessage, + style: TextStyle(color: colorScheme.error, fontSize: 12), + ), + ], + ), + ), + ], + ), + ), + ); + } + + return ErrorCard(error: l10n.errorUrlFetchFailed, colorScheme: colorScheme); + } + + Widget _buildEmptySearchResultWidget(ColorScheme colorScheme) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 340), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 86, + height: 86, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.surfaceContainerHighest, + ), + child: Icon( + Icons.manage_search, + size: 46, + color: colorScheme.primary, + ), + ), + const SizedBox(height: 16), + Text( + context.l10n.errorNoTracksFound, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + context.l10n.searchEmptyResultSubtitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ), + ); + } + + String _sortOptionLabel(HomeSearchSortOption option) { + switch (option) { + case HomeSearchSortOption.defaultOrder: + return context.l10n.searchSortDefault; + case HomeSearchSortOption.titleAsc: + return context.l10n.searchSortTitleAZ; + case HomeSearchSortOption.titleDesc: + return context.l10n.searchSortTitleZA; + case HomeSearchSortOption.artistAsc: + return context.l10n.searchSortArtistAZ; + case HomeSearchSortOption.artistDesc: + return context.l10n.searchSortArtistZA; + case HomeSearchSortOption.durationAsc: + return context.l10n.searchSortDurationShort; + case HomeSearchSortOption.durationDesc: + return context.l10n.searchSortDurationLong; + case HomeSearchSortOption.dateAsc: + return context.l10n.searchSortDateOldest; + case HomeSearchSortOption.dateDesc: + return context.l10n.searchSortDateNewest; + } + } + + void _showSortOptions(ColorScheme colorScheme) { + var tempSort = _searchSortOption; + showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + backgroundColor: colorScheme.surfaceContainerLow, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setSheetState) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Row( + children: [ + Text( + context.l10n.searchSortTitle, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + TextButton( + onPressed: () => setSheetState( + () => tempSort = HomeSearchSortOption.defaultOrder, + ), + child: Text(context.l10n.libraryFilterReset), + ), + ], + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: HomeSearchSortOption.values.map((option) { + return FilterChip( + label: Text(_sortOptionLabel(option)), + selected: tempSort == option, + showCheckmark: false, + onSelected: (_) => + setSheetState(() => tempSort = option), + ); + }).toList(), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + Navigator.pop(ctx); + if (_searchSortOption != tempSort) { + setState(() { + _searchSortOption = tempSort; + }); + } + }, + child: Text(context.l10n.libraryFilterApply), + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + ({List tracks, List indexes}) _sortTrackResults( + List tracks, + List indexes, + ) { + if (tracks.isEmpty || + _searchSortOption == HomeSearchSortOption.defaultOrder) { + return (tracks: tracks, indexes: indexes); + } + if (identical(tracks, _sortedTracksSource) && + identical(indexes, _sortedTrackIndexesSource) && + _sortedTracksMode == _searchSortOption && + _sortedTracksCache != null && + _sortedTrackIndexesCache != null) { + return (tracks: _sortedTracksCache!, indexes: _sortedTrackIndexesCache!); + } + final paired = List.generate( + tracks.length, + (i) => (tracks[i], indexes[i]), + growable: false, + ); + final sortedPairs = sortHomeSearchItems<(Track, int)>( + items: paired, + option: _searchSortOption, + nameOf: (p) => p.$1.name, + artistOf: (p) => p.$1.artistName, + durationOf: (p) => p.$1.duration, + dateOf: (p) => p.$1.releaseDate, + ); + final sortedTracks = sortedPairs.map((p) => p.$1).toList(growable: false); + final sortedIndexes = sortedPairs.map((p) => p.$2).toList(growable: false); + _sortedTracksSource = tracks; + _sortedTrackIndexesSource = indexes; + _sortedTracksMode = _searchSortOption; + _sortedTracksCache = sortedTracks; + _sortedTrackIndexesCache = sortedIndexes; + return (tracks: sortedTracks, indexes: sortedIndexes); + } + + List _buildSearchResults({ + required List tracks, + required bool isLoading, + required String? error, + required ColorScheme colorScheme, + required bool hasResults, + required bool showEmptySearchResult, + required String? searchExtensionId, + required bool showLocalLibraryIndicator, + required Map thumbnailSizesByExtensionId, + }) { + final hasActualData = tracks.isNotEmpty; + + if (!hasActualData && isLoading) { + return [const SliverToBoxAdapter(child: HomeSearchSkeleton())]; + } + if (!hasResults) { + return [const SliverToBoxAdapter(child: SizedBox.shrink())]; + } + + final buckets = _getSearchResultBuckets(tracks); + final realTracks = buckets.realTracks; + final realTrackIndexes = buckets.realTrackIndexes; + final albumItems = buckets.albumItems; + final playlistItems = buckets.playlistItems; + final artistItems = buckets.artistItems; + + final sortedTrackResults = _sortTrackResults(realTracks, realTrackIndexes); + final sortedTracks = sortedTrackResults.tracks; + final sortedTrackIndexes = sortedTrackResults.indexes; + + final slivers = [ + if (error != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _buildErrorWidget(error, colorScheme), + ), + ), + if (isLoading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: LinearProgressIndicator(), + ), + ), + if (showEmptySearchResult && !hasActualData) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 96), + child: _buildEmptySearchResultWidget(colorScheme), + ), + ), + ), + ]; + + bool sortButtonShown = false; + + if (artistItems.isNotEmpty) { + slivers.addAll( + _buildVirtualizedResultSection( + title: context.l10n.searchArtists, + itemCount: artistItems.length, + colorScheme: colorScheme, + showSortButton: !sortButtonShown, + itemBuilder: (index, showDivider) => _CollectionItemWidget( + key: ValueKey('artist-${artistItems[index].id}'), + item: artistItems[index], + showDivider: showDivider, + onTap: () => _navigateToExtensionArtist(artistItems[index]), + ), + ), + ); + sortButtonShown = true; + } + + if (albumItems.isNotEmpty) { + slivers.addAll( + _buildVirtualizedResultSection( + title: context.l10n.searchAlbums, + itemCount: albumItems.length, + colorScheme: colorScheme, + showSortButton: !sortButtonShown, + itemBuilder: (index, showDivider) => _CollectionItemWidget( + key: ValueKey('album-${albumItems[index].id}'), + item: albumItems[index], + showDivider: showDivider, + onTap: () => _navigateToExtensionAlbum(albumItems[index]), + ), + ), + ); + sortButtonShown = true; + } + + if (playlistItems.isNotEmpty) { + slivers.addAll( + _buildVirtualizedResultSection( + title: context.l10n.searchPlaylists, + itemCount: playlistItems.length, + colorScheme: colorScheme, + showSortButton: !sortButtonShown, + itemBuilder: (index, showDivider) => _CollectionItemWidget( + key: ValueKey('playlist-${playlistItems[index].id}'), + item: playlistItems[index], + showDivider: showDivider, + onTap: () => _navigateToExtensionPlaylist(playlistItems[index]), + ), + ), + ); + sortButtonShown = true; + } + + if (sortedTracks.isNotEmpty) { + final historyLookups = sortedTracks + .map(historyLookupForTrack) + .toList(growable: false); + final existingHistoryKeys = ref + .watch( + downloadHistoryBatchExistsProvider( + HistoryBatchLookupRequest(historyLookups), + ), + ) + .maybeWhen(data: (keys) => keys, orElse: () => const {}); + slivers.addAll( + _buildVirtualizedResultSection( + title: context.l10n.searchSongs, + itemCount: sortedTracks.length, + colorScheme: colorScheme, + showSortButton: !sortButtonShown, + itemBuilder: (index, showDivider) => _TrackItemWithStatus( + key: ValueKey(sortedTracks[index].id), + track: sortedTracks[index], + index: sortedTrackIndexes[index], + showDivider: showDivider, + onDownload: ({bool forceQualityPicker = false}) => _downloadTrack( + sortedTrackIndexes[index], + forceQualityPicker: forceQualityPicker, + ), + searchExtensionId: searchExtensionId, + showLocalLibraryIndicator: showLocalLibraryIndicator, + thumbnailSizesByExtensionId: thumbnailSizesByExtensionId, + isInHistory: existingHistoryKeys.contains( + historyLookups[index].lookupKey, + ), + ), + ), + ); + } + + slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 16))); + return slivers; + } + + List _buildVirtualizedResultSection({ + required String title, + required int itemCount, + required ColorScheme colorScheme, + required Widget Function(int index, bool showDivider) itemBuilder, + bool showSortButton = false, + }) { + final sectionColor = Theme.of(context).brightness == Brightness.dark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.08), + colorScheme.surface, + ) + : colorScheme.surfaceContainerHighest; + + return [ + SliverToBoxAdapter( + child: Padding( + // Aligned with the clamped result list below on wide screens. + padding: + EdgeInsets.fromLTRB(16, 8, 8, 8) + + EdgeInsets.symmetric(horizontal: wideListInset(context)), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + if (showSortButton) + SizedBox( + height: 32, + child: TextButton.icon( + onPressed: () => _showSortOptions(colorScheme), + icon: Icon( + Icons.swap_vert, + size: 18, + color: + _searchSortOption != HomeSearchSortOption.defaultOrder + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + label: Text( + _searchSortOption != HomeSearchSortOption.defaultOrder + ? _sortOptionLabel(_searchSortOption) + : context.l10n.libraryFilterSort, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: + _searchSortOption != + HomeSearchSortOption.defaultOrder + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + visualDensity: VisualDensity.compact, + ), + ), + ), + ], + ), + ), + ), + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: wideListInset(context)), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final isFirst = index == 0; + final isLast = index == itemCount - 1; + return StaggeredListItem( + index: index, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: sectionColor, + borderRadius: BorderRadius.vertical( + top: isFirst ? const Radius.circular(20) : Radius.zero, + bottom: isLast ? const Radius.circular(20) : Radius.zero, + ), + ), + clipBehavior: Clip.antiAlias, + child: Material( + color: Colors.transparent, + child: itemBuilder(index, !isLast), + ), + ), + ); + }, childCount: itemCount), + ), + ), + ]; + } + + void _navigateToExtensionAlbum(Track albumItem) async { + final extensionId = albumItem.source; + if (extensionId == null || extensionId.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.errorMissingExtensionSource('album')), + ), + ); + return; + } + + ref.read(settingsProvider.notifier).setHasSearchedBefore(); + + ref + .read(recentAccessProvider.notifier) + .recordAlbumAccess( + id: albumItem.id, + name: albumItem.name, + artistName: albumItem.artistName, + imageUrl: albumItem.coverUrl, + providerId: extensionId, + ); + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ExtensionAlbumScreen( + extensionId: extensionId, + albumId: albumItem.id, + albumName: albumItem.name, + coverUrl: albumItem.coverUrl, + initialAlbumType: albumItem.albumType, + initialTotalTracks: albumItem.totalTracks, + ), + ), + ); + } + + void _navigateToExtensionPlaylist(Track playlistItem) async { + final extensionId = playlistItem.source; + if (extensionId == null || extensionId.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.errorMissingExtensionSource('playlist')), + ), + ); + return; + } + + ref.read(settingsProvider.notifier).setHasSearchedBefore(); + + ref + .read(recentAccessProvider.notifier) + .recordPlaylistAccess( + id: playlistItem.id, + name: playlistItem.name, + ownerName: playlistItem.artistName, + imageUrl: playlistItem.coverUrl, + providerId: extensionId, + ); + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ExtensionPlaylistScreen( + extensionId: extensionId, + playlistId: playlistItem.id, + playlistName: playlistItem.name, + coverUrl: playlistItem.coverUrl, + ), + ), + ); + } + + void _navigateToExtensionArtist(Track artistItem) { + final extensionId = artistItem.source; + if (extensionId == null || extensionId.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.errorMissingExtensionSource('artist')), + ), + ); + return; + } + + ref.read(settingsProvider.notifier).setHasSearchedBefore(); + + ref + .read(recentAccessProvider.notifier) + .recordArtistAccess( + id: artistItem.id, + name: artistItem.name, + imageUrl: artistItem.coverUrl, + providerId: extensionId, + ); + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ExtensionArtistScreen( + extensionId: extensionId, + artistId: artistItem.id, + artistName: artistItem.name, + coverUrl: artistItem.coverUrl, + ), + ), + ); + } + + String _getSearchHint() { + final settings = ref.read(settingsProvider); + final extState = ref.read(extensionProvider); + final searchProvider = HomeSearchProviderPolicy.resolveProvider( + settings.searchProvider, + extState.extensions, + ); + + if (!extState.isInitialized) { + return context.l10n.homeSearchHintDefault; + } + + if (searchProvider != null && searchProvider.isNotEmpty) { + final ext = extState.extensions + .where((e) => e.id == searchProvider) + .firstOrNull; + if (ext != null && ext.enabled) { + if (ext.searchBehavior?.placeholder != null) { + return ext.searchBehavior!.placeholder!; + } + return context.l10n.homeSearchHintProvider(ext.displayName); + } + } + return context.l10n.homeSearchHintDefault; + } + + Widget _buildSearchFilterBar( + List filters, + String? selectedFilter, + ColorScheme colorScheme, + ) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: Text(context.l10n.historyFilterAll), + selected: selectedFilter == 'all', + onSelected: (_) { + ref.read(trackProvider.notifier).setSearchFilter('all'); + _triggerSearchWithFilter('all'); + }, + showCheckmark: false, + ), + ), + ...filters.map((filter) { + final isSelected = selectedFilter == filter.id; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: Text(filter.label ?? filter.id), + selected: isSelected, + onSelected: (_) { + ref.read(trackProvider.notifier).setSearchFilter(filter.id); + _triggerSearchWithFilter(filter.id); + }, + showCheckmark: false, + avatar: filter.icon != null + ? Icon(_getFilterIcon(filter.icon!), size: 18) + : null, + ), + ); + }), + ], + ), + ), + ); + } + + IconData _getFilterIcon(String iconName) { + switch (iconName.toLowerCase()) { + case 'music': + case 'track': + case 'song': + return Icons.music_note; + case 'album': + return Icons.album; + case 'artist': + return Icons.person; + case 'playlist': + return Icons.playlist_play; + case 'video': + return Icons.video_library; + case 'podcast': + return Icons.podcasts; + default: + return Icons.search; + } + } + + void _triggerSearchWithFilter(String? filter) { + final text = _urlController.text.trim(); + if (text.isEmpty || text.length < _HomeTabState._minLiveSearchChars) return; + if (looksLikeUrlOrSpotifyUri(text)) return; + + _lastSearchQuery = null; + _performSearch(text, filterOverride: filter); + } + + Widget _buildSearchBar(ColorScheme colorScheme) { + final hasText = _urlController.text.isNotEmpty; + + return TextField( + controller: _urlController, + focusNode: _searchFocusNode, + autofocus: false, + decoration: InputDecoration( + hintText: _getSearchHint(), + filled: true, + fillColor: settingsGroupColor(context), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide(color: colorScheme.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide(color: colorScheme.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide(color: colorScheme.primary, width: 2), + ), + prefixIcon: _SearchProviderDropdown( + onProviderChanged: () { + _lastSearchQuery = null; + ref.read(trackProvider.notifier).setSearchFilter(null); + setState(() {}); + final text = _urlController.text.trim(); + if (text.isNotEmpty && + text.length >= _HomeTabState._minLiveSearchChars) { + _performSearch(text); + } + }, + ), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasText) + IconButton( + icon: const Icon(Icons.clear), + onPressed: _clearAndRefresh, + tooltip: context.l10n.dialogClear, + ) + else ...[ + IconButton( + icon: const Icon(Icons.file_upload_outlined), + onPressed: _isCsvImporting + ? null + : () => _importCsv(context, ref), + tooltip: context.l10n.homeImportCsvTooltip, + ), + IconButton( + icon: const Icon(Icons.paste), + onPressed: _pasteFromClipboard, + tooltip: context.l10n.actionPaste, + ), + ], + ], + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + ), + onSubmitted: (_) => _onSearchSubmitted(), + onTapOutside: (_) { + FocusScope.of(context).unfocus(); + }, + ); + } + + void _onSearchSubmitted() { + _liveSearchDebounce?.cancel(); + _pendingLiveSearchQuery = null; + + final text = _urlController.text.trim(); + if (text.isEmpty) return; + + if (looksLikeUrlOrSpotifyUri(text)) { + _fetchMetadata(); + _searchFocusNode.unfocus(); + return; + } + + if (text.length >= 2) { + _performSearch(text); + } + _searchFocusNode.unfocus(); + } +}