From c9b1898cc04932f5b703b00e61f1d5f8a21c66e0 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 27 Jul 2026 00:39:44 +0700 Subject: [PATCH] refactor(metadata): split cover cache, display helpers, and options menu into part files --- lib/screens/track_metadata_screen.dart | 781 +----------------- lib/screens/track_metadata_screen_cover.dart | 330 ++++++++ .../track_metadata_screen_display.dart | 227 +++++ lib/screens/track_metadata_screen_menu.dart | 244 ++++++ 4 files changed, 806 insertions(+), 776 deletions(-) create mode 100644 lib/screens/track_metadata_screen_cover.dart create mode 100644 lib/screens/track_metadata_screen_display.dart create mode 100644 lib/screens/track_metadata_screen_menu.dart diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index fae98086..8ab84139 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -27,6 +27,7 @@ import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; + import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; @@ -41,6 +42,10 @@ import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/constants/music_services.dart'; import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart'; +part 'track_metadata_screen_cover.dart'; +part 'track_metadata_screen_display.dart'; +part 'track_metadata_screen_menu.dart'; + part 'track_metadata_edit_sheet.dart'; part 'track_metadata_cards.dart'; part 'track_metadata_lyrics.dart'; @@ -158,96 +163,6 @@ class _TrackMetadataScreenState extends ConsumerState String get _coverCacheKey => _itemId; - bool _isCacheTrackedPath(String? path) { - if (!_hasPath(path)) return false; - return _embeddedCoverPreviewCache.values.any( - (entry) => entry.previewPath == path, - ); - } - - bool _isVolatileSafTempPath(String path) { - if (path.isEmpty) return false; - return path.contains( - '${Platform.pathSeparator}cache${Platform.pathSeparator}saf_', - ); - } - - Future _readLocalFileValidationToken(String path) async { - if (path.isEmpty || isContentUri(path) || _isVolatileSafTempPath(path)) { - return null; - } - try { - final stat = await fileStat(path); - if (stat == null) return null; - return '${stat.modified?.millisecondsSinceEpoch ?? 0}:${stat.size ?? 0}'; - } catch (_) { - return null; - } - } - - Future _cacheEmbeddedCoverPreview( - String cacheKey, - String sourcePath, - String previewPath, - ) async { - final sourceValidationToken = await _readLocalFileValidationToken( - sourcePath, - ); - final existing = _embeddedCoverPreviewCache[cacheKey]; - _embeddedCoverPreviewCache[cacheKey] = _EmbeddedCoverPreviewCacheEntry( - previewPath: previewPath, - sourceValidationToken: sourceValidationToken, - ); - if (existing != null && existing.previewPath != previewPath) { - await _cleanupTempFileAndParentIfNotCached(existing.previewPath); - } - - while (_embeddedCoverPreviewCache.length > _maxCoverPreviewCacheEntries) { - final oldestKey = _embeddedCoverPreviewCache.keys.first; - final removed = _embeddedCoverPreviewCache.remove(oldestKey); - if (removed != null) { - await _cleanupTempFileAndParentIfNotCached(removed.previewPath); - } - } - } - - Future _invalidateEmbeddedCoverPreviewCacheForPath( - String cacheKey, - ) async { - if (cacheKey.isEmpty) return; - final removed = _embeddedCoverPreviewCache.remove(cacheKey); - if (removed != null) { - await _cleanupTempFileAndParentIfNotCached(removed.previewPath); - } - } - - Future _getCachedEmbeddedCoverPreviewPathIfValid( - String cacheKey, - String sourcePath, - ) async { - if (cacheKey.isEmpty) return null; - final cached = _embeddedCoverPreviewCache[cacheKey]; - if (cached == null) return null; - - if (!await fileExists(cached.previewPath)) { - _embeddedCoverPreviewCache.remove(cacheKey); - return null; - } - - if (!isContentUri(sourcePath) && !_isVolatileSafTempPath(sourcePath)) { - final currentToken = await _readLocalFileValidationToken(sourcePath); - if (currentToken != null && - cached.sourceValidationToken != null && - currentToken != cached.sourceValidationToken) { - _embeddedCoverPreviewCache.remove(cacheKey); - await _cleanupTempFileAndParentIfNotCached(cached.previewPath); - return null; - } - } - - return cached.previewPath; - } - @override void initState() { super.initState(); @@ -588,453 +503,6 @@ class _TrackMetadataScreenState extends ConsumerState } } - Future _refreshEmbeddedCoverPreview({bool force = false}) async { - final generation = _metadataLoadGeneration; - final cacheKey = _coverCacheKey; - final sourcePath = cleanFilePath; - if (!force) { - final cachedPath = await _getCachedEmbeddedCoverPreviewPathIfValid( - cacheKey, - sourcePath, - ); - if (_hasPath(cachedPath)) { - if (mounted && - generation == _metadataLoadGeneration && - sourcePath == cleanFilePath && - _embeddedCoverPreviewPath != cachedPath) { - setState(() => _embeddedCoverPreviewPath = cachedPath); - } - return; - } - } - - String? newPreviewPath; - try { - if (!_fileExists) { - await _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey); - await _cleanupTempFileAndParentIfNotCached(_embeddedCoverPreviewPath); - if (mounted && - generation == _metadataLoadGeneration && - sourcePath == cleanFilePath) { - setState(() => _embeddedCoverPreviewPath = null); - } - return; - } - if (force) { - await _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey); - } - final tempDir = await Directory.systemTemp.createTemp( - 'track_cover_preview_', - ); - final outputPath = - '${tempDir.path}${Platform.pathSeparator}cover_preview.jpg'; - final result = await PlatformBridge.extractCoverToFile( - sourcePath, - outputPath, - ); - if (result['error'] == null && await File(outputPath).exists()) { - newPreviewPath = outputPath; - await _cacheEmbeddedCoverPreview(cacheKey, sourcePath, outputPath); - } else { - try { - await tempDir.delete(recursive: true); - } catch (_) {} - } - } catch (_) {} - - final oldPreviewPath = _embeddedCoverPreviewPath; - if (!mounted || - generation != _metadataLoadGeneration || - sourcePath != cleanFilePath) { - if (newPreviewPath != null) { - await _cleanupTempFileAndParentIfNotCached(newPreviewPath); - } - return; - } - - setState(() => _embeddedCoverPreviewPath = newPreviewPath); - if (oldPreviewPath != null && oldPreviewPath != newPreviewPath) { - await _cleanupTempFileAndParentIfNotCached(oldPreviewPath); - } - } - - bool get _isLocalItem => _currentLocalLibraryItem != null; - DownloadHistoryItem? get _downloadItem => _currentDownloadItem; - LocalLibraryItem? get _localLibraryItem => _currentLocalLibraryItem; - bool get _hasHistoryNavigation => - widget.historyNavigationItems != null && widget.navigationIndex != null; - bool get _hasLocalNavigation => - widget.localNavigationItems != null && widget.navigationIndex != null; - bool get _hasTrackSwipeNavigation => - _hasHistoryNavigation || _hasLocalNavigation; - int? get _navigationIndex => _currentNavigationIndex; - int get _navigationLength => - widget.historyNavigationItems?.length ?? - widget.localNavigationItems?.length ?? - 0; - - String get _itemId => - _isLocalItem ? _localLibraryItem!.id : _downloadItem!.id; - String get trackName => - _editedMetadata?['title']?.toString() ?? - (_isLocalItem ? _localLibraryItem!.trackName : _downloadItem!.trackName); - String get artistName => - _editedMetadata?['artist']?.toString() ?? - (_isLocalItem - ? _localLibraryItem!.artistName - : _downloadItem!.artistName); - String get albumName => - _editedMetadata?['album']?.toString() ?? - (_isLocalItem ? _localLibraryItem!.albumName : _downloadItem!.albumName); - String? get albumArtist { - final edited = _editedMetadata?['album_artist']?.toString(); - if (edited != null && edited.isNotEmpty) return edited; - return normalizeOptionalString( - _isLocalItem - ? _localLibraryItem!.albumArtist - : _downloadItem!.albumArtist, - ); - } - - int? get trackNumber { - final edited = _editedMetadata?['track_number']; - if (edited != null) { - final v = int.tryParse(edited.toString()); - if (v != null && v > 0) return v; - } - return _isLocalItem - ? _localLibraryItem!.trackNumber - : _downloadItem!.trackNumber; - } - - int? get totalTracks => - readPositiveInt(_editedMetadata?['total_tracks']) ?? - (_isLocalItem - ? _localLibraryItem!.totalTracks - : _downloadItem!.totalTracks); - - int? get discNumber { - final edited = _editedMetadata?['disc_number']; - if (edited != null) { - final v = int.tryParse(edited.toString()); - if (v != null && v > 0) return v; - } - return _isLocalItem - ? _localLibraryItem!.discNumber - : _downloadItem!.discNumber; - } - - int? get totalDiscs => - readPositiveInt(_editedMetadata?['total_discs']) ?? - (_isLocalItem - ? _localLibraryItem!.totalDiscs - : _downloadItem!.totalDiscs); - - String? get releaseDate => - _editedMetadata?['date']?.toString() ?? - (_isLocalItem - ? _localLibraryItem!.releaseDate - : _downloadItem!.releaseDate); - String? get isrc { - final raw = - _editedMetadata?['isrc']?.toString() ?? - (_isLocalItem ? _localLibraryItem!.isrc : _downloadItem!.isrc); - if (raw == null || raw.trim().isEmpty) return null; - final upper = raw.trim().toUpperCase(); - // Only accept valid ISRC codes (CC-XXX-YY-NNNNN, 12 alphanumeric chars). - // Strip hyphens/spaces that some sources include. - final stripped = upper.replaceAll(RegExp(r'[-\s]'), ''); - if (_isrcValidationPattern.hasMatch(stripped)) return stripped; - return null; - } - - static final RegExp _isrcValidationPattern = RegExp( - r'^[A-Z]{2}[A-Z0-9]{3}\d{7}$', - ); - String? get genre => - _editedMetadata?['genre']?.toString() ?? - (_isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre); - String? get label => - _editedMetadata?['label']?.toString() ?? - (_isLocalItem ? _localLibraryItem!.label : _downloadItem!.label); - String? get copyright => - _editedMetadata?['copyright']?.toString() ?? - (_isLocalItem ? _localLibraryItem!.copyright : _downloadItem!.copyright); - String? get composer => - _editedMetadata?['composer']?.toString() ?? - (_isLocalItem ? _localLibraryItem!.composer : null); - int? get duration => - readPositiveInt(_editedMetadata?['duration']) ?? - (_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration); - int? get bitDepth => - readPositiveInt(_editedMetadata?['bit_depth']) ?? - (_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth); - int? get sampleRate => - readPositiveInt(_editedMetadata?['sample_rate']) ?? - (_isLocalItem - ? _localLibraryItem!.sampleRate - : _downloadItem!.sampleRate); - int? get _audioBitrate => - _isLocalItem ? _localLibraryItem!.bitrate : _downloadItem?.bitrate; - String? get _storedAudioFormat => - _resolvedAudioFormat ?? - (_isLocalItem ? _localLibraryItem?.format : _downloadItem?.format); - - String get _filePath => - _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath; - String get _coverHeroTag => - widget.coverHeroTag ?? - (_isLocalItem ? 'cover_lib_$_itemId' : 'cover_$_itemId'); - String? get _coverUrl => _isLocalItem - ? null - : highResCoverUrl(normalizeRemoteHttpUrl(_downloadItem!.coverUrl)); - - String? get _localCoverPath => - _isLocalItem ? _localLibraryItem!.coverPath : null; - String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId; - String get _service => - _isLocalItem ? MusicServices.local : _downloadItem!.service; - DateTime get _addedAt { - if (_isLocalItem) { - final modTime = _localLibraryItem!.fileModTime; - if (modTime != null && modTime > 0) { - return DateTime.fromMillisecondsSinceEpoch(modTime); - } - return _localLibraryItem!.scannedAt; - } - return _downloadItem!.downloadedAt; - } - - String? get _quality => _isLocalItem ? null : _downloadItem!.quality; - - int? _readPlausibleBitrateKbps(dynamic value) { - final parsed = readPositiveInt(value); - if (parsed == null) return null; - final kbps = parsed >= 10000 ? (parsed / 1000).round() : parsed; - return kbps >= 16 ? kbps : null; - } - - bool _isBitrateFormatValue(String? value) { - return const { - 'aac', - 'eac3', - 'ac3', - 'ac4', - 'mp3', - 'opus', - 'm4a', - }.contains(normalizeAudioFormatValue(value)); - } - - String? _usableStoredQuality(String? quality) { - final normalized = normalizeOptionalString(quality); - if (normalized == null || isPlaceholderQualityLabel(normalized)) { - return null; - } - final bitrateMatch = RegExp( - r'\b(\d+)\s*kbps\b', - caseSensitive: false, - ).firstMatch(normalized); - if (bitrateMatch != null) { - final bitrate = int.tryParse(bitrateMatch.group(1) ?? ''); - if (bitrate != null && bitrate < 16) return null; - } - return normalized; - } - - String? _displayQualityForValues({ - required String? format, - int? bitDepth, - int? sampleRate, - int? bitrateKbps, - String? storedQuality, - }) { - final normalizedFormat = normalizeAudioFormatValue(format); - final formatLabel = normalizedFormat == null - ? normalizeOptionalString(format)?.toUpperCase() - : _formatLabelForRaw(normalizedFormat); - if (_isBitrateFormatValue(normalizedFormat)) { - return buildDisplayAudioQuality( - bitrateKbps: bitrateKbps, - format: formatLabel, - ) ?? - _usableStoredQuality(storedQuality) ?? - formatLabel; - } - return buildDisplayAudioQuality( - bitDepth: bitDepth, - sampleRate: sampleRate, - storedQuality: _usableStoredQuality(storedQuality), - ); - } - - String _displayServiceTrackId(String value) { - final raw = value.trim(); - if (raw.isEmpty) return raw; - final spotifyTrackIdPattern = RegExp(r'^[A-Za-z0-9]{22}$'); - - if (raw.startsWith('deezer:')) return raw.substring('deezer:'.length); - if (raw.startsWith('tidal:')) return raw.substring('tidal:'.length); - if (raw.startsWith('qobuz:')) return raw.substring('qobuz:'.length); - if (spotifyTrackIdPattern.hasMatch(raw)) return raw; - - if (raw.startsWith('spotify:')) { - final last = raw.split(':').last.trim(); - if (spotifyTrackIdPattern.hasMatch(last)) return last; - return raw; - } - - final uri = Uri.tryParse(raw); - if (uri != null && - uri.host.contains('spotify.com') && - uri.pathSegments.length >= 2 && - uri.pathSegments.first == 'track') { - final candidate = uri.pathSegments[1].trim(); - if (spotifyTrackIdPattern.hasMatch(candidate)) { - return candidate; - } - } - - return raw; - } - - String _serviceForTrackId(String value, {required String fallbackService}) { - final raw = value.trim(); - if (raw.isEmpty) return fallbackService; - final spotifyTrackIdPattern = RegExp(r'^[A-Za-z0-9]{22}$'); - - if (raw.startsWith('deezer:')) return MusicServices.deezer; - if (raw.startsWith('tidal:')) return MusicServices.tidal; - if (raw.startsWith('qobuz:')) return MusicServices.qobuz; - if (raw.startsWith('spotify:')) return MusicServices.spotify; - if (spotifyTrackIdPattern.hasMatch(raw)) return MusicServices.spotify; - - final uri = Uri.tryParse(raw); - if (uri != null) { - final host = uri.host.toLowerCase(); - if (host.contains('spotify.com')) return MusicServices.spotify; - if (host.contains('deezer.com')) return MusicServices.deezer; - if (host.contains('tidal.com')) return MusicServices.tidal; - if (host.contains('qobuz.com')) return MusicServices.qobuz; - } - - return fallbackService; - } - - String? get _displayAudioQuality { - final fileName = _extractFileNameFromPathOrUri(cleanFilePath); - final fileExt = fileName.contains('.') - ? fileName.split('.').last.toUpperCase() - : null; - - return _displayQualityForValues( - format: _storedAudioFormat ?? fileExt, - bitDepth: bitDepth, - sampleRate: sampleRate, - bitrateKbps: _audioBitrate, - storedQuality: _quality, - ); - } - - /// The raw file path, with EXISTS: prefix stripped but #trackNN preserved. - /// Use this when you need the full virtual path (e.g. for display or DB lookups). - String get rawFilePath { - final path = _filePath; - return path.startsWith('EXISTS:') ? path.substring(7) : path; - } - - /// The clean file path with both EXISTS: prefix and #trackNN suffix stripped. - /// Use this for actual filesystem/SAF operations. - String get cleanFilePath { - var path = _filePath; - if (path.startsWith('EXISTS:')) path = path.substring(7); - if (isCueVirtualPath(path)) path = stripCueTrackSuffix(path); - return path; - } - - bool get _isCueVirtualTrack => isCueVirtualPath(rawFilePath); - - String _cueVirtualTrackGuidance(BuildContext context) { - return 'This CUE track is virtual. Use ${context.l10n.cueSplitButton} first.'; - } - - void _showCueVirtualTrackSnackBar(BuildContext context) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(_cueVirtualTrackGuidance(context)))); - } - - void _hideCurrentSnackBar() { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - } - - String get _l10nCueSplitFailed => context.l10n.cueSplitFailed; - String get _l10nCueSplitNoAudioFile => context.l10n.cueSplitNoAudioFile; - - String _l10nCueSplitSplitting(int current, int total) { - return context.l10n.cueSplitSplitting(current, total); - } - - String _l10nCueSplitSuccess(int count) { - return context.l10n.cueSplitSuccess(count); - } - - void _showSnackBarMessage(String message) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); - } - - void _showLongSnackBarMessage(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), duration: const Duration(seconds: 60)), - ); - } - - String _formatPathForDisplay(String pathOrUri) { - if (pathOrUri.isEmpty || !pathOrUri.startsWith('content://')) { - return pathOrUri; - } - - try { - final uri = Uri.parse(pathOrUri); - final segments = uri.pathSegments; - String? documentId; - - final documentIndex = segments.indexOf('document'); - if (documentIndex != -1 && documentIndex + 1 < segments.length) { - documentId = Uri.decodeComponent(segments[documentIndex + 1]); - } - - if (documentId == null || documentId.isEmpty) { - final treeIndex = segments.indexOf('tree'); - if (treeIndex != -1 && treeIndex + 1 < segments.length) { - documentId = Uri.decodeComponent(segments[treeIndex + 1]); - } - } - - if (documentId == null || documentId.isEmpty) return pathOrUri; - - final separatorIndex = documentId.indexOf(':'); - if (separatorIndex <= 0) return documentId; - - final volumeId = documentId.substring(0, separatorIndex); - final relativePath = documentId - .substring(separatorIndex + 1) - .replaceAll('\\', '/'); - - if (volumeId.toLowerCase() == 'primary') { - if (relativePath.isEmpty) return '/storage/emulated/0'; - return '/storage/emulated/0/$relativePath'; - } - - if (relativePath.isEmpty) return volumeId; - return 'SD Card/$relativePath'; - } catch (_) { - return pathOrUri; - } - } - void _markMetadataChanged() { _hasMetadataChanges = true; } @@ -1278,245 +746,6 @@ class _TrackMetadataScreenState extends ConsumerState ], ); } - - void _showOptionsMenu( - BuildContext screenContext, - WidgetRef ref, - ColorScheme colorScheme, - ) { - showModalBottomSheet( - context: screenContext, - useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - isScrollControlled: true, - builder: (sheetContext) { - final l10n = sheetContext.l10n; - - final options = <_MetadataOption>[ - if (_fileExists) - _MetadataOption( - icon: Icons.playlist_play, - label: l10n.trackPlayNext, - onTap: () => _enqueueThis(ref, playNext: true), - ), - if (_fileExists) - _MetadataOption( - icon: Icons.queue_music, - label: l10n.trackAddToQueue, - onTap: () => _enqueueThis(ref, playNext: false), - ), - _MetadataOption( - icon: Icons.copy_outlined, - label: l10n.trackCopyFilePath, - dividerAbove: _fileExists, - onTap: () => _copyToClipboard(screenContext, cleanFilePath), - ), - if (_fileExists) - _MetadataOption( - icon: Icons.edit_outlined, - label: l10n.trackEditMetadata, - onTap: () => - _showEditMetadataSheet(screenContext, ref, colorScheme), - ), - if (!_isLocalItem && (_coverUrl != null || _fileExists)) - _MetadataOption( - icon: Icons.image_outlined, - label: l10n.trackSaveCoverArt, - onTap: _saveCoverArt, - ), - if (!_isLocalItem) - _MetadataOption( - icon: Icons.lyrics_outlined, - label: l10n.trackSaveLyrics, - onTap: _saveLyrics, - ), - if (_fileExists) - _MetadataOption( - icon: Icons.travel_explore, - label: l10n.trackReEnrich, - onTap: _reEnrichMetadata, - ), - if (_fileExists && _isConvertibleFormat) - _MetadataOption( - icon: Icons.swap_horiz, - label: l10n.trackConvertFormat, - onTap: () => _showConvertSheet(screenContext), - ), - if (_fileExists && !_isCueFile) - _MetadataOption( - icon: Icons.graphic_eq, - label: l10n.trackReplayGain, - onTap: () => _rescanReplayGain(), - ), - if (_fileExists && _isCueFile) - _MetadataOption( - icon: Icons.call_split, - label: l10n.cueSplitTitle, - onTap: () => _showCueSplitSheet(screenContext), - ), - if (_spotifyId != null || (isrc?.isNotEmpty ?? false)) - _MetadataOption( - icon: Icons.open_in_new, - label: l10n.trackOpenOn, - dividerAbove: true, - onTap: () => OpenOnPlatformSheet.show( - screenContext, - spotifyId: _spotifyId ?? '', - isrc: isrc ?? '', - ), - ), - _MetadataOption( - icon: Icons.share_outlined, - label: l10n.trackMetadataShare, - dividerAbove: _spotifyId == null && !(isrc?.isNotEmpty ?? false), - onTap: () => _shareFile(screenContext), - ), - _MetadataOption( - icon: Icons.delete_outline, - label: l10n.trackRemoveFromDevice, - destructive: true, - onTap: () => _confirmDelete(screenContext, ref, colorScheme), - ), - ]; - - return SafeArea( - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.85, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 8), - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.4, - ), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: _buildOptionsHeaderCover(colorScheme), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(sheetContext) - .textTheme - .titleMedium - ?.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 2), - Text( - artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(sheetContext) - .textTheme - .bodyMedium - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - ), - Divider( - height: 1, - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), - const SizedBox(height: 8), - SettingsGroup( - children: [ - for (int i = 0; i < options.length; i++) ...[ - if (options[i].dividerAbove && i != 0) - Divider( - height: 1, - thickness: 1, - color: colorScheme.outlineVariant.withValues( - alpha: 0.3, - ), - ), - _MetadataOptionTile( - option: options[i], - colorScheme: colorScheme, - onTap: () => _closeOptionsMenuAndRun( - sheetContext, - options[i].onTap, - ), - ), - ], - ], - ), - const SizedBox(height: 16), - ], - ), - ), - ), - ); - }, - ); - } - - Widget _buildOptionsHeaderCover(ColorScheme colorScheme) { - const size = 56.0; - const cacheWidth = 112; - - Widget placeholder() => Container( - width: size, - height: size, - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), - ); - - if (_coverUrl != null) { - return CachedCoverImage( - imageUrl: _coverUrl!, - width: size, - height: size, - fit: BoxFit.cover, - memCacheWidth: cacheWidth, - memCacheHeight: cacheWidth, - errorWidget: (_, _, _) => placeholder(), - ); - } - - if (_localCoverPath != null && _localCoverPath!.isNotEmpty) { - return Image.file( - File(_localCoverPath!), - width: size, - height: size, - fit: BoxFit.cover, - cacheWidth: cacheWidth, - errorBuilder: (_, _, _) => placeholder(), - ); - } - - return placeholder(); - } } class _MetadataOption { diff --git a/lib/screens/track_metadata_screen_cover.dart b/lib/screens/track_metadata_screen_cover.dart new file mode 100644 index 00000000..f04887eb --- /dev/null +++ b/lib/screens/track_metadata_screen_cover.dart @@ -0,0 +1,330 @@ +// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member +part of 'track_metadata_screen.dart'; + +// Embedded-cover preview: disk cache keyed by file validation token, +// plus the FFmpeg-backed refresh that extracts the preview image. +extension _TrackMetadataCover on _TrackMetadataScreenState { + bool _isCacheTrackedPath(String? path) { + if (!_hasPath(path)) return false; + return _TrackMetadataScreenState._embeddedCoverPreviewCache.values.any( + (entry) => entry.previewPath == path, + ); + } + + bool _isVolatileSafTempPath(String path) { + if (path.isEmpty) return false; + return path.contains( + '${Platform.pathSeparator}cache${Platform.pathSeparator}saf_', + ); + } + + Future _readLocalFileValidationToken(String path) async { + if (path.isEmpty || isContentUri(path) || _isVolatileSafTempPath(path)) { + return null; + } + try { + final stat = await fileStat(path); + if (stat == null) return null; + return '${stat.modified?.millisecondsSinceEpoch ?? 0}:${stat.size ?? 0}'; + } catch (_) { + return null; + } + } + + Future _cacheEmbeddedCoverPreview( + String cacheKey, + String sourcePath, + String previewPath, + ) async { + final sourceValidationToken = await _readLocalFileValidationToken( + sourcePath, + ); + final existing = + _TrackMetadataScreenState._embeddedCoverPreviewCache[cacheKey]; + _TrackMetadataScreenState._embeddedCoverPreviewCache[cacheKey] = + _EmbeddedCoverPreviewCacheEntry( + previewPath: previewPath, + sourceValidationToken: sourceValidationToken, + ); + if (existing != null && existing.previewPath != previewPath) { + await _cleanupTempFileAndParentIfNotCached(existing.previewPath); + } + + while (_TrackMetadataScreenState._embeddedCoverPreviewCache.length > + _TrackMetadataScreenState._maxCoverPreviewCacheEntries) { + final oldestKey = + _TrackMetadataScreenState._embeddedCoverPreviewCache.keys.first; + final removed = _TrackMetadataScreenState._embeddedCoverPreviewCache + .remove(oldestKey); + if (removed != null) { + await _cleanupTempFileAndParentIfNotCached(removed.previewPath); + } + } + } + + Future _invalidateEmbeddedCoverPreviewCacheForPath( + String cacheKey, + ) async { + if (cacheKey.isEmpty) return; + final removed = _TrackMetadataScreenState._embeddedCoverPreviewCache.remove( + cacheKey, + ); + if (removed != null) { + await _cleanupTempFileAndParentIfNotCached(removed.previewPath); + } + } + + Future _getCachedEmbeddedCoverPreviewPathIfValid( + String cacheKey, + String sourcePath, + ) async { + if (cacheKey.isEmpty) return null; + final cached = + _TrackMetadataScreenState._embeddedCoverPreviewCache[cacheKey]; + if (cached == null) return null; + + if (!await fileExists(cached.previewPath)) { + _TrackMetadataScreenState._embeddedCoverPreviewCache.remove(cacheKey); + return null; + } + + if (!isContentUri(sourcePath) && !_isVolatileSafTempPath(sourcePath)) { + final currentToken = await _readLocalFileValidationToken(sourcePath); + if (currentToken != null && + cached.sourceValidationToken != null && + currentToken != cached.sourceValidationToken) { + _TrackMetadataScreenState._embeddedCoverPreviewCache.remove(cacheKey); + await _cleanupTempFileAndParentIfNotCached(cached.previewPath); + return null; + } + } + + return cached.previewPath; + } + + Future _refreshEmbeddedCoverPreview({bool force = false}) async { + final generation = _metadataLoadGeneration; + final cacheKey = _coverCacheKey; + final sourcePath = cleanFilePath; + if (!force) { + final cachedPath = await _getCachedEmbeddedCoverPreviewPathIfValid( + cacheKey, + sourcePath, + ); + if (_hasPath(cachedPath)) { + if (mounted && + generation == _metadataLoadGeneration && + sourcePath == cleanFilePath && + _embeddedCoverPreviewPath != cachedPath) { + setState(() => _embeddedCoverPreviewPath = cachedPath); + } + return; + } + } + + String? newPreviewPath; + try { + if (!_fileExists) { + await _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey); + await _cleanupTempFileAndParentIfNotCached(_embeddedCoverPreviewPath); + if (mounted && + generation == _metadataLoadGeneration && + sourcePath == cleanFilePath) { + setState(() => _embeddedCoverPreviewPath = null); + } + return; + } + if (force) { + await _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey); + } + final tempDir = await Directory.systemTemp.createTemp( + 'track_cover_preview_', + ); + final outputPath = + '${tempDir.path}${Platform.pathSeparator}cover_preview.jpg'; + final result = await PlatformBridge.extractCoverToFile( + sourcePath, + outputPath, + ); + if (result['error'] == null && await File(outputPath).exists()) { + newPreviewPath = outputPath; + await _cacheEmbeddedCoverPreview(cacheKey, sourcePath, outputPath); + } else { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } catch (_) {} + + final oldPreviewPath = _embeddedCoverPreviewPath; + if (!mounted || + generation != _metadataLoadGeneration || + sourcePath != cleanFilePath) { + if (newPreviewPath != null) { + await _cleanupTempFileAndParentIfNotCached(newPreviewPath); + } + return; + } + + setState(() => _embeddedCoverPreviewPath = newPreviewPath); + if (oldPreviewPath != null && oldPreviewPath != newPreviewPath) { + await _cleanupTempFileAndParentIfNotCached(oldPreviewPath); + } + } + + bool get _isLocalItem => _currentLocalLibraryItem != null; + DownloadHistoryItem? get _downloadItem => _currentDownloadItem; + LocalLibraryItem? get _localLibraryItem => _currentLocalLibraryItem; + bool get _hasHistoryNavigation => + widget.historyNavigationItems != null && widget.navigationIndex != null; + bool get _hasLocalNavigation => + widget.localNavigationItems != null && widget.navigationIndex != null; + bool get _hasTrackSwipeNavigation => + _hasHistoryNavigation || _hasLocalNavigation; + int? get _navigationIndex => _currentNavigationIndex; + int get _navigationLength => + widget.historyNavigationItems?.length ?? + widget.localNavigationItems?.length ?? + 0; + + String get _itemId => + _isLocalItem ? _localLibraryItem!.id : _downloadItem!.id; + String get trackName => + _editedMetadata?['title']?.toString() ?? + (_isLocalItem ? _localLibraryItem!.trackName : _downloadItem!.trackName); + String get artistName => + _editedMetadata?['artist']?.toString() ?? + (_isLocalItem + ? _localLibraryItem!.artistName + : _downloadItem!.artistName); + String get albumName => + _editedMetadata?['album']?.toString() ?? + (_isLocalItem ? _localLibraryItem!.albumName : _downloadItem!.albumName); + String? get albumArtist { + final edited = _editedMetadata?['album_artist']?.toString(); + if (edited != null && edited.isNotEmpty) return edited; + return normalizeOptionalString( + _isLocalItem + ? _localLibraryItem!.albumArtist + : _downloadItem!.albumArtist, + ); + } + + int? get trackNumber { + final edited = _editedMetadata?['track_number']; + if (edited != null) { + final v = int.tryParse(edited.toString()); + if (v != null && v > 0) return v; + } + return _isLocalItem + ? _localLibraryItem!.trackNumber + : _downloadItem!.trackNumber; + } + + int? get totalTracks => + readPositiveInt(_editedMetadata?['total_tracks']) ?? + (_isLocalItem + ? _localLibraryItem!.totalTracks + : _downloadItem!.totalTracks); + + int? get discNumber { + final edited = _editedMetadata?['disc_number']; + if (edited != null) { + final v = int.tryParse(edited.toString()); + if (v != null && v > 0) return v; + } + return _isLocalItem + ? _localLibraryItem!.discNumber + : _downloadItem!.discNumber; + } + + int? get totalDiscs => + readPositiveInt(_editedMetadata?['total_discs']) ?? + (_isLocalItem + ? _localLibraryItem!.totalDiscs + : _downloadItem!.totalDiscs); + + String? get releaseDate => + _editedMetadata?['date']?.toString() ?? + (_isLocalItem + ? _localLibraryItem!.releaseDate + : _downloadItem!.releaseDate); + String? get isrc { + final raw = + _editedMetadata?['isrc']?.toString() ?? + (_isLocalItem ? _localLibraryItem!.isrc : _downloadItem!.isrc); + if (raw == null || raw.trim().isEmpty) return null; + final upper = raw.trim().toUpperCase(); + // Only accept valid ISRC codes (CC-XXX-YY-NNNNN, 12 alphanumeric chars). + // Strip hyphens/spaces that some sources include. + final stripped = upper.replaceAll(RegExp(r'[-\s]'), ''); + if (_isrcValidationPattern.hasMatch(stripped)) return stripped; + return null; + } + + static final RegExp _isrcValidationPattern = RegExp( + r'^[A-Z]{2}[A-Z0-9]{3}\d{7}$', + ); + String? get genre => + _editedMetadata?['genre']?.toString() ?? + (_isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre); + String? get label => + _editedMetadata?['label']?.toString() ?? + (_isLocalItem ? _localLibraryItem!.label : _downloadItem!.label); + String? get copyright => + _editedMetadata?['copyright']?.toString() ?? + (_isLocalItem ? _localLibraryItem!.copyright : _downloadItem!.copyright); + String? get composer => + _editedMetadata?['composer']?.toString() ?? + (_isLocalItem ? _localLibraryItem!.composer : null); + int? get duration => + readPositiveInt(_editedMetadata?['duration']) ?? + (_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration); + int? get bitDepth => + readPositiveInt(_editedMetadata?['bit_depth']) ?? + (_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth); + int? get sampleRate => + readPositiveInt(_editedMetadata?['sample_rate']) ?? + (_isLocalItem + ? _localLibraryItem!.sampleRate + : _downloadItem!.sampleRate); + int? get _audioBitrate => + _isLocalItem ? _localLibraryItem!.bitrate : _downloadItem?.bitrate; + String? get _storedAudioFormat => + _resolvedAudioFormat ?? + (_isLocalItem ? _localLibraryItem?.format : _downloadItem?.format); + + String get _filePath => + _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath; + String get _coverHeroTag => + widget.coverHeroTag ?? + (_isLocalItem ? 'cover_lib_$_itemId' : 'cover_$_itemId'); + String? get _coverUrl => _isLocalItem + ? null + : highResCoverUrl(normalizeRemoteHttpUrl(_downloadItem!.coverUrl)); + + String? get _localCoverPath => + _isLocalItem ? _localLibraryItem!.coverPath : null; + String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId; + String get _service => + _isLocalItem ? MusicServices.local : _downloadItem!.service; + DateTime get _addedAt { + if (_isLocalItem) { + final modTime = _localLibraryItem!.fileModTime; + if (modTime != null && modTime > 0) { + return DateTime.fromMillisecondsSinceEpoch(modTime); + } + return _localLibraryItem!.scannedAt; + } + return _downloadItem!.downloadedAt; + } + + String? get _quality => _isLocalItem ? null : _downloadItem!.quality; + + int? _readPlausibleBitrateKbps(dynamic value) { + final parsed = readPositiveInt(value); + if (parsed == null) return null; + final kbps = parsed >= 10000 ? (parsed / 1000).round() : parsed; + return kbps >= 16 ? kbps : null; + } +} diff --git a/lib/screens/track_metadata_screen_display.dart b/lib/screens/track_metadata_screen_display.dart new file mode 100644 index 00000000..b7b984eb --- /dev/null +++ b/lib/screens/track_metadata_screen_display.dart @@ -0,0 +1,227 @@ +// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member +part of 'track_metadata_screen.dart'; + +// Display helpers: quality/bitrate labels, service track-id formatting, +// snackbars, and path formatting. +extension _TrackMetadataDisplay on _TrackMetadataScreenState { + bool _isBitrateFormatValue(String? value) { + return const { + 'aac', + 'eac3', + 'ac3', + 'ac4', + 'mp3', + 'opus', + 'm4a', + }.contains(normalizeAudioFormatValue(value)); + } + + String? _usableStoredQuality(String? quality) { + final normalized = normalizeOptionalString(quality); + if (normalized == null || isPlaceholderQualityLabel(normalized)) { + return null; + } + final bitrateMatch = RegExp( + r'\b(\d+)\s*kbps\b', + caseSensitive: false, + ).firstMatch(normalized); + if (bitrateMatch != null) { + final bitrate = int.tryParse(bitrateMatch.group(1) ?? ''); + if (bitrate != null && bitrate < 16) return null; + } + return normalized; + } + + String? _displayQualityForValues({ + required String? format, + int? bitDepth, + int? sampleRate, + int? bitrateKbps, + String? storedQuality, + }) { + final normalizedFormat = normalizeAudioFormatValue(format); + final formatLabel = normalizedFormat == null + ? normalizeOptionalString(format)?.toUpperCase() + : _formatLabelForRaw(normalizedFormat); + if (_isBitrateFormatValue(normalizedFormat)) { + return buildDisplayAudioQuality( + bitrateKbps: bitrateKbps, + format: formatLabel, + ) ?? + _usableStoredQuality(storedQuality) ?? + formatLabel; + } + return buildDisplayAudioQuality( + bitDepth: bitDepth, + sampleRate: sampleRate, + storedQuality: _usableStoredQuality(storedQuality), + ); + } + + String _displayServiceTrackId(String value) { + final raw = value.trim(); + if (raw.isEmpty) return raw; + final spotifyTrackIdPattern = RegExp(r'^[A-Za-z0-9]{22}$'); + + if (raw.startsWith('deezer:')) return raw.substring('deezer:'.length); + if (raw.startsWith('tidal:')) return raw.substring('tidal:'.length); + if (raw.startsWith('qobuz:')) return raw.substring('qobuz:'.length); + if (spotifyTrackIdPattern.hasMatch(raw)) return raw; + + if (raw.startsWith('spotify:')) { + final last = raw.split(':').last.trim(); + if (spotifyTrackIdPattern.hasMatch(last)) return last; + return raw; + } + + final uri = Uri.tryParse(raw); + if (uri != null && + uri.host.contains('spotify.com') && + uri.pathSegments.length >= 2 && + uri.pathSegments.first == 'track') { + final candidate = uri.pathSegments[1].trim(); + if (spotifyTrackIdPattern.hasMatch(candidate)) { + return candidate; + } + } + + return raw; + } + + String _serviceForTrackId(String value, {required String fallbackService}) { + final raw = value.trim(); + if (raw.isEmpty) return fallbackService; + final spotifyTrackIdPattern = RegExp(r'^[A-Za-z0-9]{22}$'); + + if (raw.startsWith('deezer:')) return MusicServices.deezer; + if (raw.startsWith('tidal:')) return MusicServices.tidal; + if (raw.startsWith('qobuz:')) return MusicServices.qobuz; + if (raw.startsWith('spotify:')) return MusicServices.spotify; + if (spotifyTrackIdPattern.hasMatch(raw)) return MusicServices.spotify; + + final uri = Uri.tryParse(raw); + if (uri != null) { + final host = uri.host.toLowerCase(); + if (host.contains('spotify.com')) return MusicServices.spotify; + if (host.contains('deezer.com')) return MusicServices.deezer; + if (host.contains('tidal.com')) return MusicServices.tidal; + if (host.contains('qobuz.com')) return MusicServices.qobuz; + } + + return fallbackService; + } + + String? get _displayAudioQuality { + final fileName = _extractFileNameFromPathOrUri(cleanFilePath); + final fileExt = fileName.contains('.') + ? fileName.split('.').last.toUpperCase() + : null; + + return _displayQualityForValues( + format: _storedAudioFormat ?? fileExt, + bitDepth: bitDepth, + sampleRate: sampleRate, + bitrateKbps: _audioBitrate, + storedQuality: _quality, + ); + } + + /// The raw file path, with EXISTS: prefix stripped but #trackNN preserved. + /// Use this when you need the full virtual path (e.g. for display or DB lookups). + String get rawFilePath { + final path = _filePath; + return path.startsWith('EXISTS:') ? path.substring(7) : path; + } + + /// The clean file path with both EXISTS: prefix and #trackNN suffix stripped. + /// Use this for actual filesystem/SAF operations. + String get cleanFilePath { + var path = _filePath; + if (path.startsWith('EXISTS:')) path = path.substring(7); + if (isCueVirtualPath(path)) path = stripCueTrackSuffix(path); + return path; + } + + bool get _isCueVirtualTrack => isCueVirtualPath(rawFilePath); + + String _cueVirtualTrackGuidance(BuildContext context) { + return 'This CUE track is virtual. Use ${context.l10n.cueSplitButton} first.'; + } + + void _showCueVirtualTrackSnackBar(BuildContext context) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(_cueVirtualTrackGuidance(context)))); + } + + void _hideCurrentSnackBar() { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + } + + String get _l10nCueSplitFailed => context.l10n.cueSplitFailed; + String get _l10nCueSplitNoAudioFile => context.l10n.cueSplitNoAudioFile; + + String _l10nCueSplitSplitting(int current, int total) { + return context.l10n.cueSplitSplitting(current, total); + } + + String _l10nCueSplitSuccess(int count) { + return context.l10n.cueSplitSuccess(count); + } + + void _showSnackBarMessage(String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + void _showLongSnackBarMessage(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 60)), + ); + } + + String _formatPathForDisplay(String pathOrUri) { + if (pathOrUri.isEmpty || !pathOrUri.startsWith('content://')) { + return pathOrUri; + } + + try { + final uri = Uri.parse(pathOrUri); + final segments = uri.pathSegments; + String? documentId; + + final documentIndex = segments.indexOf('document'); + if (documentIndex != -1 && documentIndex + 1 < segments.length) { + documentId = Uri.decodeComponent(segments[documentIndex + 1]); + } + + if (documentId == null || documentId.isEmpty) { + final treeIndex = segments.indexOf('tree'); + if (treeIndex != -1 && treeIndex + 1 < segments.length) { + documentId = Uri.decodeComponent(segments[treeIndex + 1]); + } + } + + if (documentId == null || documentId.isEmpty) return pathOrUri; + + final separatorIndex = documentId.indexOf(':'); + if (separatorIndex <= 0) return documentId; + + final volumeId = documentId.substring(0, separatorIndex); + final relativePath = documentId + .substring(separatorIndex + 1) + .replaceAll('\\', '/'); + + if (volumeId.toLowerCase() == 'primary') { + if (relativePath.isEmpty) return '/storage/emulated/0'; + return '/storage/emulated/0/$relativePath'; + } + + if (relativePath.isEmpty) return volumeId; + return 'SD Card/$relativePath'; + } catch (_) { + return pathOrUri; + } + } +} diff --git a/lib/screens/track_metadata_screen_menu.dart b/lib/screens/track_metadata_screen_menu.dart new file mode 100644 index 00000000..d7901d97 --- /dev/null +++ b/lib/screens/track_metadata_screen_menu.dart @@ -0,0 +1,244 @@ +// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member +part of 'track_metadata_screen.dart'; + +// The overflow options sheet and its header cover. +extension _TrackMetadataMenu on _TrackMetadataScreenState { + void _showOptionsMenu( + BuildContext screenContext, + WidgetRef ref, + ColorScheme colorScheme, + ) { + showModalBottomSheet( + context: screenContext, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + isScrollControlled: true, + builder: (sheetContext) { + final l10n = sheetContext.l10n; + + final options = <_MetadataOption>[ + if (_fileExists) + _MetadataOption( + icon: Icons.playlist_play, + label: l10n.trackPlayNext, + onTap: () => _enqueueThis(ref, playNext: true), + ), + if (_fileExists) + _MetadataOption( + icon: Icons.queue_music, + label: l10n.trackAddToQueue, + onTap: () => _enqueueThis(ref, playNext: false), + ), + _MetadataOption( + icon: Icons.copy_outlined, + label: l10n.trackCopyFilePath, + dividerAbove: _fileExists, + onTap: () => _copyToClipboard(screenContext, cleanFilePath), + ), + if (_fileExists) + _MetadataOption( + icon: Icons.edit_outlined, + label: l10n.trackEditMetadata, + onTap: () => + _showEditMetadataSheet(screenContext, ref, colorScheme), + ), + if (!_isLocalItem && (_coverUrl != null || _fileExists)) + _MetadataOption( + icon: Icons.image_outlined, + label: l10n.trackSaveCoverArt, + onTap: _saveCoverArt, + ), + if (!_isLocalItem) + _MetadataOption( + icon: Icons.lyrics_outlined, + label: l10n.trackSaveLyrics, + onTap: _saveLyrics, + ), + if (_fileExists) + _MetadataOption( + icon: Icons.travel_explore, + label: l10n.trackReEnrich, + onTap: _reEnrichMetadata, + ), + if (_fileExists && _isConvertibleFormat) + _MetadataOption( + icon: Icons.swap_horiz, + label: l10n.trackConvertFormat, + onTap: () => _showConvertSheet(screenContext), + ), + if (_fileExists && !_isCueFile) + _MetadataOption( + icon: Icons.graphic_eq, + label: l10n.trackReplayGain, + onTap: () => _rescanReplayGain(), + ), + if (_fileExists && _isCueFile) + _MetadataOption( + icon: Icons.call_split, + label: l10n.cueSplitTitle, + onTap: () => _showCueSplitSheet(screenContext), + ), + if (_spotifyId != null || (isrc?.isNotEmpty ?? false)) + _MetadataOption( + icon: Icons.open_in_new, + label: l10n.trackOpenOn, + dividerAbove: true, + onTap: () => OpenOnPlatformSheet.show( + screenContext, + spotifyId: _spotifyId ?? '', + isrc: isrc ?? '', + ), + ), + _MetadataOption( + icon: Icons.share_outlined, + label: l10n.trackMetadataShare, + dividerAbove: _spotifyId == null && !(isrc?.isNotEmpty ?? false), + onTap: () => _shareFile(screenContext), + ), + _MetadataOption( + icon: Icons.delete_outline, + label: l10n.trackRemoveFromDevice, + destructive: true, + onTap: () => _confirmDelete(screenContext, ref, colorScheme), + ), + ]; + + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.85, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: _buildOptionsHeaderCover(colorScheme), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(sheetContext) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + Text( + artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(sheetContext) + .textTheme + .bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: 8), + SettingsGroup( + children: [ + for (int i = 0; i < options.length; i++) ...[ + if (options[i].dividerAbove && i != 0) + Divider( + height: 1, + thickness: 1, + color: colorScheme.outlineVariant.withValues( + alpha: 0.3, + ), + ), + _MetadataOptionTile( + option: options[i], + colorScheme: colorScheme, + onTap: () => _closeOptionsMenuAndRun( + sheetContext, + options[i].onTap, + ), + ), + ], + ], + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ); + }, + ); + } + + Widget _buildOptionsHeaderCover(ColorScheme colorScheme) { + const size = 56.0; + const cacheWidth = 112; + + Widget placeholder() => Container( + width: size, + height: size, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ); + + if (_coverUrl != null) { + return CachedCoverImage( + imageUrl: _coverUrl!, + width: size, + height: size, + fit: BoxFit.cover, + memCacheWidth: cacheWidth, + memCacheHeight: cacheWidth, + errorWidget: (_, _, _) => placeholder(), + ); + } + + if (_localCoverPath != null && _localCoverPath!.isNotEmpty) { + return Image.file( + File(_localCoverPath!), + width: size, + height: size, + fit: BoxFit.cover, + cacheWidth: cacheWidth, + errorBuilder: (_, _, _) => placeholder(), + ); + } + + return placeholder(); + } +}