mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
refactor(home): split explore, recent, import, and search-results into part files
This commit is contained in:
+4
-1912
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,522 @@
|
||||
part of 'home_tab.dart';
|
||||
|
||||
extension _HomeTabExploreUI on _HomeTabState {
|
||||
List<Widget> _buildExploreSections(
|
||||
List<ExploreSection> 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<void>(
|
||||
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<void>(
|
||||
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<void>(
|
||||
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<void>(
|
||||
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<void> _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<void> _navigateToTrackAlbum(ExploreItem item) async {
|
||||
if (item.albumId != null && item.albumId!.isNotEmpty) {
|
||||
final extensionId = _providerIdForExploreItem(item);
|
||||
if (extensionId == null) {
|
||||
_showMissingExploreProviderMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
part of 'home_tab.dart';
|
||||
|
||||
extension _HomeTabCsvImport on _HomeTabState {
|
||||
Future<void> _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<void>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> 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<void> _navigateToRecentItem(RecentAccessItem item) async {
|
||||
_searchFocusNode.unfocus();
|
||||
|
||||
switch (item.type) {
|
||||
case RecentAccessType.artist:
|
||||
if (_isEnabledMetadataExtension(item.providerId)) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => ExtensionArtistScreen(
|
||||
extensionId: item.providerId!,
|
||||
artistId: item.id,
|
||||
artistName: item.name,
|
||||
coverUrl: item.imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => ArtistScreen(
|
||||
artistId: item.id,
|
||||
artistName: item.name,
|
||||
coverUrl: item.imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
case RecentAccessType.album:
|
||||
if (item.providerId == 'download') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => DownloadedAlbumScreen(
|
||||
albumName: item.name,
|
||||
artistName: item.subtitle ?? '',
|
||||
coverUrl: item.imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (_isEnabledMetadataExtension(item.providerId)) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => ExtensionAlbumScreen(
|
||||
extensionId: item.providerId!,
|
||||
albumId: item.id,
|
||||
albumName: item.name,
|
||||
coverUrl: item.imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
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<void>(
|
||||
builder: (context) => ExtensionPlaylistScreen(
|
||||
extensionId: item.providerId!,
|
||||
playlistId: item.id,
|
||||
playlistName: item.name,
|
||||
coverUrl: item.imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => PlaylistScreen(
|
||||
playlistName: item.name,
|
||||
coverUrl: item.imageUrl,
|
||||
tracks: const [],
|
||||
playlistId: item.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _navigateToMetadataScreen(
|
||||
DownloadHistoryItem item, {
|
||||
List<DownloadHistoryItem>? 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<bool>(
|
||||
page: TrackMetadataScreen(
|
||||
item: item,
|
||||
historyNavigationItems: navigationItems,
|
||||
navigationIndex: navigationIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||
item.filePath,
|
||||
beforeModTime: beforeModTime,
|
||||
force: result == true,
|
||||
onChanged: _onEmbeddedCoverChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void>(
|
||||
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<Track> tracks, List<int> indexes}) _sortTrackResults(
|
||||
List<Track> tracks,
|
||||
List<int> 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<Widget> _buildSearchResults({
|
||||
required List<Track> tracks,
|
||||
required bool isLoading,
|
||||
required String? error,
|
||||
required ColorScheme colorScheme,
|
||||
required bool hasResults,
|
||||
required bool showEmptySearchResult,
|
||||
required String? searchExtensionId,
|
||||
required bool showLocalLibraryIndicator,
|
||||
required Map<String, (double, double)> 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 = <Widget>[
|
||||
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 <String>{});
|
||||
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<Widget> _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<void>(
|
||||
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<void>(
|
||||
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<void>(
|
||||
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<SearchFilter> 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user