feat(search): save max-quality cover on long press #511

This commit is contained in:
zarzet
2026-08-11 16:01:39 +07:00
parent f189687e08
commit e750c195ca
5 changed files with 339 additions and 33 deletions
+2
View File
@@ -22,6 +22,7 @@ import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/screens/home_search_logic.dart';
import 'package:spotiflac_android/services/csv_import_service.dart';
import 'package:spotiflac_android/services/cover_download_service.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
@@ -75,6 +76,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
bool _embeddedCoverRefreshScheduled = false;
List<Extension>? _thumbnailSizesExtensionsCache;
bool _isCsvImporting = false;
final Set<String> _activeCoverDownloads = <String>{};
void _setCsvImporting(bool value) {
if (_isCsvImporting == value) return;
+49
View File
@@ -2,6 +2,51 @@
part of 'home_tab.dart';
extension _HomeTabSearchResultsUI on _HomeTabState {
Future<void> _saveSearchResultCover(Track item) async {
final coverUrl = item.coverUrl?.trim() ?? '';
if (coverUrl.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.trackCoverNoSource)));
return;
}
if (!_activeCoverDownloads.add(coverUrl)) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(context.l10n.updateDownloading)));
final baseName = item.isCollection || item.artistName.trim().isEmpty
? item.name
: '${item.artistName} - ${item.name}';
try {
final saved = await CoverDownloadService.saveRemoteCover(
coverUrl: coverUrl,
baseName: baseName,
settings: ref.read(settingsProvider),
);
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(context.l10n.trackCoverSaved(saved.fileName))),
);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
context.l10n.trackSaveFailed(context.friendlyError(error)),
),
),
);
} finally {
_activeCoverDownloads.remove(coverUrl);
}
}
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final l10n = context.l10n;
final isRateLimit =
@@ -312,6 +357,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState {
item: artistItems[index],
showDivider: showDivider,
onTap: () => _navigateToExtensionArtist(artistItems[index]),
onSaveCover: () => _saveSearchResultCover(artistItems[index]),
),
),
);
@@ -330,6 +376,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState {
item: albumItems[index],
showDivider: showDivider,
onTap: () => _navigateToExtensionAlbum(albumItems[index]),
onSaveCover: () => _saveSearchResultCover(albumItems[index]),
),
),
);
@@ -348,6 +395,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState {
item: playlistItems[index],
showDivider: showDivider,
onTap: () => _navigateToExtensionPlaylist(playlistItems[index]),
onSaveCover: () => _saveSearchResultCover(playlistItems[index]),
),
),
);
@@ -386,6 +434,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState {
isInHistory: existingHistoryKeys.contains(
historyLookups[index].lookupKey,
),
onSaveCover: () => _saveSearchResultCover(sortedTracks[index]),
),
),
);
+66 -33
View File
@@ -196,6 +196,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
final String? searchExtensionId;
final bool showLocalLibraryIndicator;
final Map<String, (double, double)> thumbnailSizesByExtensionId;
final VoidCallback onSaveCover;
/// Resolved by the result page via one batch lookup instead of a per-row
/// exists query (which in SAF mode also costs a bridge call per row).
@@ -211,6 +212,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
required this.showLocalLibraryIndicator,
required this.thumbnailSizesByExtensionId,
required this.isInHistory,
required this.onSaveCover,
});
@override
@@ -248,6 +250,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
}
final isQueued = queueItem != null;
final hasCover = track.coverUrl?.isNotEmpty == true;
return Column(
mainAxisSize: MainAxisSize.min,
@@ -272,24 +275,36 @@ class _TrackItemWithStatus extends ConsumerWidget {
padding: const EdgeInsets.fromLTRB(12, 10, 6, 10),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: track.coverUrl != null
? CachedCoverImage(
imageUrl: track.coverUrl!,
width: thumbWidth,
height: thumbHeight,
fit: BoxFit.cover,
)
: Container(
width: thumbWidth,
height: thumbHeight,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
Semantics(
label:
'${context.l10n.dialogDownload} '
'${context.l10n.editMetadataFieldCover}: ${track.name}',
button: hasCover,
onLongPress: hasCover ? onSaveCover : null,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onLongPress: hasCover ? onSaveCover : null,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: hasCover
? CachedCoverImage(
imageUrl: track.coverUrl!,
width: thumbWidth,
height: thumbHeight,
fit: BoxFit.cover,
)
: Container(
width: thumbWidth,
height: thumbHeight,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
@@ -417,12 +432,14 @@ class _CollectionItemWidget extends StatelessWidget {
final Track item;
final bool showDivider;
final VoidCallback onTap;
final VoidCallback onSaveCover;
const _CollectionItemWidget({
super.key,
required this.item,
required this.showDivider,
required this.onTap,
required this.onSaveCover,
});
@override
@@ -435,21 +452,37 @@ class _CollectionItemWidget extends StatelessWidget {
if (isPlaylist) placeholderIcon = Icons.playlist_play;
if (isArtist) placeholderIcon = Icons.person;
final cover = ClipRRect(
borderRadius: BorderRadius.circular(isArtist ? 28 : 10),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedCoverImage(
imageUrl: item.coverUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
)
: Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: Icon(placeholderIcon, color: colorScheme.onSurfaceVariant),
),
final hasCover = item.coverUrl != null && item.coverUrl!.isNotEmpty;
final cover = Semantics(
label:
'${context.l10n.dialogDownload} '
'${context.l10n.editMetadataFieldCover}: ${item.name}',
button: hasCover,
onLongPress: hasCover ? onSaveCover : null,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onLongPress: hasCover ? onSaveCover : null,
child: ClipRRect(
borderRadius: BorderRadius.circular(isArtist ? 28 : 10),
child: hasCover
? CachedCoverImage(
imageUrl: item.coverUrl!,
width: 56,
height: 56,
fit: BoxFit.cover,
)
: Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: Icon(
placeholderIcon,
color: colorScheme.onSurfaceVariant,
),
),
),
),
);
return Column(
mainAxisSize: MainAxisSize.min,