diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index ead7bc15..c7a1d3aa 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -18,6 +18,134 @@ class _AutoFillPreview { }); } +class _MetadataCandidateArtwork extends StatefulWidget { + final String? coverUrl; + + const _MetadataCandidateArtwork({required this.coverUrl}); + + @override + State<_MetadataCandidateArtwork> createState() => + _MetadataCandidateArtworkState(); +} + +class _MetadataCandidateArtworkState extends State<_MetadataCandidateArtwork> { + String? _tempDir; + String? _coverPath; + ({int width, int height, int bytes})? _details; + + @override + void initState() { + super.initState(); + if (widget.coverUrl?.isNotEmpty == true) { + unawaited(_loadMaxQualityCover()); + } + } + + Future _loadMaxQualityCover() async { + final tempDir = await Directory.systemTemp.createTemp( + 'metadata_candidate_cover_', + ); + final path = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + try { + final result = await PlatformBridge.downloadCoverToFile( + widget.coverUrl!, + path, + maxQuality: true, + ); + if (result['error'] != null || !await File(path).exists()) { + await tempDir.delete(recursive: true); + return; + } + final dimensions = await FFmpegService.probeImageDimensions(path); + final bytes = await File(path).length(); + if (!mounted) { + await tempDir.delete(recursive: true); + return; + } + setState(() { + _tempDir = tempDir.path; + _coverPath = path; + if (dimensions != null) { + _details = ( + width: dimensions.width, + height: dimensions.height, + bytes: bytes, + ); + } + }); + } catch (_) { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } + + @override + void dispose() { + final tempDir = _tempDir; + if (tempDir != null) { + unawaited(() async { + try { + final directory = Directory(tempDir); + if (await directory.exists()) await directory.delete(recursive: true); + } catch (_) {} + }()); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final coverUrl = widget.coverUrl; + final details = _details; + final image = _coverPath != null + ? Image.file( + File(_coverPath!), + width: 56, + height: 56, + fit: BoxFit.cover, + ) + : coverUrl != null + ? CachedCoverImage( + imageUrl: coverUrl, + width: 56, + height: 56, + fit: BoxFit.cover, + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: const Icon(Icons.music_note), + ); + + return SizedBox( + width: 72, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect(borderRadius: BorderRadius.circular(10), child: image), + if (details != null) ...[ + const SizedBox(height: 3), + Text( + '${details.width}×${details.height} · ' + '${(details.bytes / 1024).toStringAsFixed(1)} KB', + maxLines: 2, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontSize: 9, + height: 1.1, + ), + ), + ], + ], + ), + ); + } +} + class _EditMetadataSheet extends StatefulWidget { static const _onlineCoverSentinel = '__online_cover__'; final ColorScheme colorScheme; @@ -1078,6 +1206,119 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } } + String? _metadataCandidateCoverUrl(Map track) { + String? fromValue(Object? value) { + if (value is String) { + final normalized = value.trim(); + return normalized.isEmpty ? null : normalized; + } + if (value is List) { + for (final item in value) { + final resolved = fromValue(item); + if (resolved != null) return resolved; + } + } + if (value is Map) { + for (final key in const ['url', 'original', 'large', 'src']) { + final resolved = fromValue(value[key]); + if (resolved != null) return resolved; + } + } + return null; + } + + return fromValue(track['cover_url']) ?? fromValue(track['images']); + } + + Future?> _showAutoFillCandidatePicker({ + required List> candidates, + required String sourceName, + required String currentTitle, + required String currentArtist, + }) { + return showAppBottomSheet>( + context: context, + useRootNavigator: true, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, + title: context.l10n.editMetadataAutoFillSource, + subtitle: [ + currentTitle, + currentArtist, + sourceName, + ].where((value) => value.trim().isNotEmpty).join(' · '), + maxHeightFactor: 0.82, + builder: (sheetContext) => ListView( + padding: const EdgeInsets.only(bottom: 16), + children: [ + SettingsGroup( + children: [ + for (var index = 0; index < candidates.length; index++) ...[ + Builder( + builder: (context) { + final candidate = candidates[index]; + final title = + (candidate['name'] ?? candidate['title'] ?? '') + .toString() + .trim(); + final artists = + (candidate['artists'] ?? candidate['artist'] ?? '') + .toString() + .trim(); + final album = + (candidate['album_name'] ?? candidate['album'] ?? '') + .toString() + .trim(); + final date = + candidate['release_date']?.toString().trim() ?? ''; + final isrc = candidate['isrc']?.toString().trim() ?? ''; + final coverUrl = _metadataCandidateCoverUrl(candidate); + final subtitle = [ + artists, + album, + if (date.isNotEmpty) date, + if (isrc.isNotEmpty) 'ISRC $isrc', + ].where((value) => value.isNotEmpty).join('\n'); + + return ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 6, + ), + leading: _MetadataCandidateArtwork(coverUrl: coverUrl), + title: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: subtitle.isEmpty + ? null + : Text( + subtitle, + maxLines: 4, + overflow: TextOverflow.ellipsis, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.pop(sheetContext, candidate), + ); + }, + ), + if (index != candidates.length - 1) + Divider( + height: 1, + indent: 88, + endIndent: 16, + color: Theme.of( + sheetContext, + ).colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ], + ), + ], + ), + ); + } + Future _fetchAutoFillPreview() async { if (_autoFillFields.isEmpty) { _showSheetSnackBar(context.l10n.editMetadataAutoFillNoneSelected); @@ -1097,6 +1338,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { final title = _titleCtrl.text.trim(); final artist = _artistCtrl.text.trim(); final album = _albumCtrl.text.trim(); + final albumArtist = _albumArtistCtrl.text.trim(); + final searchArtist = primaryArtistName(artist, albumArtist: albumArtist); final currentIsrc = _isrcCtrl.text.trim().toUpperCase(); final configuredProviderId = _selectedMetadataProviderId?.trim(); final extensionState = ProviderScope.containerOf( @@ -1124,7 +1367,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { final queryParts = []; if (title.isNotEmpty) queryParts.add(title); - if (artist.isNotEmpty) queryParts.add(artist); + if (searchArtist.isNotEmpty) queryParts.add(searchArtist); if (queryParts.isEmpty && album.isNotEmpty) queryParts.add(album); if (needsTrackLookup && queryParts.isEmpty) { @@ -1135,7 +1378,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } final normalizedTitle = _normalizeMetadataText(title); - final normalizedArtist = _normalizeMetadataText(artist); + final normalizedArtist = _normalizeMetadataText(searchArtist); final normalizedAlbum = _normalizeMetadataText(album); if (needsTrackLookup) { @@ -1154,32 +1397,44 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { return; } - // Pick best match using current metadata, not only provider order. - best = results.first; - var bestScore = -1; - for (final result in results) { - final score = _metadataMatchScore( - result, - currentTitle: normalizedTitle, - currentArtist: normalizedArtist, - currentAlbum: normalizedAlbum, - currentIsrc: currentIsrc, - ); - if (score > bestScore) { - bestScore = score; - best = result; - } - } - - if (best != null && - !_metadataMatchIsConfident( - best, + final rankedResults = [...results] + ..sort((a, b) { + final aScore = _metadataMatchScore( + a, currentTitle: normalizedTitle, currentArtist: normalizedArtist, currentAlbum: normalizedAlbum, currentIsrc: currentIsrc, - )) { - best = null; + ); + final bScore = _metadataMatchScore( + b, + currentTitle: normalizedTitle, + currentArtist: normalizedArtist, + currentAlbum: normalizedAlbum, + currentIsrc: currentIsrc, + ); + return bScore.compareTo(aScore); + }); + + if (usesAutomaticProvider) { + best = rankedResults.first; + if (!_metadataMatchIsConfident( + best, + currentTitle: normalizedTitle, + currentArtist: normalizedArtist, + currentAlbum: normalizedAlbum, + currentIsrc: currentIsrc, + )) { + best = null; + } + } else { + best = await _showAutoFillCandidatePicker( + candidates: rankedResults, + sourceName: _metadataProviderName(selectedProviderId), + currentTitle: title, + currentArtist: searchArtist, + ); + if (!mounted || best == null) return; } if (best == null) { @@ -1375,9 +1630,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } final coverUrl = selectedBest == null ? null - : (selectedBest['cover_url'] ?? selectedBest['images'] ?? '') - .toString() - .trim(); + : _metadataCandidateCoverUrl(selectedBest); final hasSelectedValue = _autoFillFields.any( (key) => key == 'cover' ? coverUrl != null && coverUrl.isNotEmpty diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index df59dcd1..5175dc52 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -25,6 +25,7 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/utils/audio_format_utils.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; diff --git a/lib/services/cross_extension_share_service.dart b/lib/services/cross_extension_share_service.dart index ce19a0b2..606cb700 100644 --- a/lib/services/cross_extension_share_service.dart +++ b/lib/services/cross_extension_share_service.dart @@ -1,4 +1,5 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; class CrossExtensionShareResult { final String extensionId; @@ -41,9 +42,10 @@ class CrossExtensionShareService { required String type, required String sourceExtensionId, }) async { + final queryArtist = primaryArtistName(artists); final results = await PlatformBridge.findCollectionAcrossExtensions( name: name, - artists: artists, + artists: queryArtist, type: type, sourceExtensionId: sourceExtensionId, ); diff --git a/lib/utils/artist_utils.dart b/lib/utils/artist_utils.dart index 0c8bcb78..7ff0da81 100644 --- a/lib/utils/artist_utils.dart +++ b/lib/utils/artist_utils.dart @@ -1,5 +1,5 @@ final RegExp _artistNameSplitPattern = RegExp( - r'\s*(?:,|&|\bx\b)\s*|\s+\b(?:feat(?:uring)?|ft|with)\.?(?=\s|$)\s*', + r'\s*(?:,|;|&|\bx\b)\s*|\s+\b(?:feat(?:uring)?|ft|with)\.?(?=\s|$)\s*', caseSensitive: false, ); @@ -17,6 +17,22 @@ List splitArtistNames(String rawArtists) { .toList(growable: false); } +String primaryArtistName(String artists, {String? albumArtist}) { + final preferred = albumArtist?.trim() ?? ''; + final normalizedPreferred = preferred.toLowerCase(); + final useAlbumArtist = + preferred.isNotEmpty && + normalizedPreferred != 'various artists' && + normalizedPreferred != 'various' && + normalizedPreferred != 'va'; + final source = useAlbumArtist ? preferred : artists.trim(); + final split = splitArtistNames(source); + if (split.isNotEmpty) return split.first; + + final fallback = splitArtistNames(artists); + return fallback.isNotEmpty ? fallback.first : artists.trim(); +} + bool shouldSplitVorbisArtistTags(String mode) { return mode == artistTagModeSplitVorbis; } diff --git a/test/artist_utils_test.dart b/test/artist_utils_test.dart new file mode 100644 index 00000000..fdb60e63 --- /dev/null +++ b/test/artist_utils_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; + +void main() { + test('primary artist prefers the first album artist', () { + expect( + primaryArtistName( + 'Track Artist, Guest Artist', + albumArtist: 'Album Artist & Collaborator', + ), + 'Album Artist', + ); + }); + + test('primary artist handles provider separator variants', () { + expect(primaryArtistName('One; Two & Three'), 'One'); + expect(primaryArtistName('One feat. Two'), 'One'); + expect(primaryArtistName('AC/DC'), 'AC/DC'); + expect( + primaryArtistName('Actual Artist, Guest', albumArtist: 'Various Artists'), + 'Actual Artist', + ); + }); +}