diff --git a/lib/screens/track_metadata_actions.dart b/lib/screens/track_metadata_actions.dart new file mode 100644 index 00000000..bff11828 --- /dev/null +++ b/lib/screens/track_metadata_actions.dart @@ -0,0 +1,298 @@ +part of 'track_metadata_screen.dart'; + +extension _TrackMetadataFileActions on _TrackMetadataScreenState { + void _showEditMetadataSheet( + BuildContext context, + WidgetRef ref, + ColorScheme colorScheme, + ) async { + Map? fileMetadata; + try { + final result = await PlatformBridge.readFileMetadata(cleanFilePath); + if (result['error'] == null) { + fileMetadata = result; + } + } catch (e) { + debugPrint('readFileMetadata failed, using item data: $e'); + } + + String val(String key, String? fallback) { + final v = fileMetadata?[key]?.toString(); + return (v != null && v.isNotEmpty) ? v : (fallback ?? ''); + } + + final initialValues = { + 'title': val('title', trackName), + 'artist': val('artist', artistName), + 'album': val('album', albumName), + 'album_artist': val('album_artist', albumArtist), + 'date': val('date', releaseDate), + 'track_number': (fileMetadata?['track_number'] ?? trackNumber ?? '') + .toString(), + 'total_tracks': (fileMetadata?['total_tracks'] ?? totalTracks ?? '') + .toString(), + 'disc_number': (fileMetadata?['disc_number'] ?? discNumber ?? '') + .toString(), + 'total_discs': (fileMetadata?['total_discs'] ?? totalDiscs ?? '') + .toString(), + 'genre': val('genre', genre), + 'isrc': val('isrc', isrc), + 'label': val('label', label), + 'copyright': val('copyright', copyright), + 'composer': val('composer', composer), + 'comment': fileMetadata?['comment']?.toString() ?? '', + 'lyrics': fileMetadata?['lyrics']?.toString() ?? '', + }; + + final initialDurationSeconds = + readPositiveInt(fileMetadata?['duration']) ?? duration ?? 0; + + if (!context.mounted) return; + + final saved = await showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + backgroundColor: colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => _EditMetadataSheet( + colorScheme: colorScheme, + initialValues: initialValues, + filePath: cleanFilePath, + sourceTrackId: _spotifyId, + durationMs: initialDurationSeconds > 0 + ? initialDurationSeconds * 1000 + : 0, + artistTagMode: ref.read(settingsProvider).artistTagMode, + ), + ); + + if (saved == true && mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar(content: Text(this.context.l10n.snackbarMetadataSaved)), + ); + try { + final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath); + final refreshedLyrics = refreshed['lyrics']?.toString().trim() ?? ''; + _setState(() { + _editedMetadata = refreshed; + _lyricsError = null; + _isInstrumental = false; + _embeddedLyricsChecked = true; + if (refreshedLyrics.isNotEmpty) { + _lyrics = _cleanLrcForDisplay(refreshedLyrics); + _rawLyrics = refreshedLyrics; + _lyricsSource = context.l10n.trackLyricsEmbeddedSource; + _lyricsEmbedded = true; + } else { + _lyrics = null; + _rawLyrics = null; + _lyricsSource = null; + _lyricsEmbedded = false; + } + }); + } catch (_) { + _setState(() {}); + } + await _refreshEmbeddedCoverPreview(force: true); + _markMetadataChanged(); + await _syncDownloadHistoryMetadata(); + } + } + + void _confirmDelete( + BuildContext screenContext, + WidgetRef ref, + ColorScheme colorScheme, + ) { + showDialog( + context: screenContext, + useRootNavigator: true, + builder: (dialogContext) => AlertDialog( + title: Text(dialogContext.l10n.trackDeleteConfirmTitle), + content: Text(dialogContext.l10n.trackDeleteConfirmMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(dialogContext.l10n.dialogCancel), + ), + TextButton( + onPressed: () async { + if (_isLocalItem) { + if (_isCueVirtualTrack && _localLibraryItem != null) { + await ref + .read(localLibraryProvider.notifier) + .removeItem(_localLibraryItem!.id); + } else { + try { + await deleteFile(cleanFilePath); + } catch (e) { + debugPrint('Failed to delete file: $e'); + } + if (_localLibraryItem != null) { + await ref + .read(localLibraryProvider.notifier) + .removeItem(_localLibraryItem!.id); + } + } + } else { + try { + await deleteFile(cleanFilePath); + } catch (e) { + debugPrint('Failed to delete file: $e'); + } + + ref + .read(downloadHistoryProvider.notifier) + .removeFromHistory(_downloadItem!.id); + } + + if (dialogContext.mounted) { + Navigator.pop(dialogContext); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(true); + } + }); + }, + child: Text( + dialogContext.l10n.dialogDelete, + style: TextStyle(color: colorScheme.error), + ), + ), + ], + ), + ); + } + + void _closeOptionsMenuAndRun(BuildContext sheetContext, VoidCallback action) { + Navigator.pop(sheetContext); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + action(); + }); + } + + Future _openFile(BuildContext context, String filePath) async { + if (isCueVirtualPath(filePath)) { + _showCueVirtualTrackSnackBar(context); + return; + } + try { + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: filePath, + title: trackName, + artist: artistName, + album: albumName, + coverUrl: _coverUrl ?? '', + ); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarCannotOpenFile(e.toString())), + ), + ); + } + } + } + + void _copyToClipboard(BuildContext context, String text) { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.trackCopiedToClipboard), + duration: const Duration(seconds: 2), + ), + ); + } + + Future _shareFile(BuildContext context) async { + if (_isCueVirtualTrack) { + _showCueVirtualTrackSnackBar(context); + return; + } + + String sharePath = cleanFilePath; + if (!await fileExists(sharePath)) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarFileNotFound)), + ); + } + return; + } + + final shareTitle = '$trackName - $artistName'; + + // For SAF content URIs, use native share intent directly (zero-copy) + if (isContentUri(sharePath)) { + try { + await PlatformBridge.shareContentUri(sharePath, title: shareTitle); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarCannotOpenFile('Failed to share file'), + ), + ), + ); + } + } + return; + } + + await SharePlus.instance.share( + ShareParams(files: [XFile(sharePath)], text: shareTitle), + ); + } + + String _formatFullDate(DateTime date) { + return '${date.day} ${_TrackMetadataScreenState._months[date.month - 1]} ${date.year}, ' + '${date.hour.toString().padLeft(2, '0')}:' + '${date.minute.toString().padLeft(2, '0')}'; + } + + String _formatFileSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; + } + + IconData _getServiceIcon(String service) { + switch (service.toLowerCase()) { + case 'tidal': + return Icons.waves; + case 'qobuz': + return Icons.album; + case 'amazon': + return Icons.shopping_cart; + default: + return Icons.cloud_download; + } + } + + Color _getServiceColor(String service, ColorScheme colorScheme) { + switch (service.toLowerCase()) { + case 'tidal': + return const Color(0xFF0077B5); + case 'qobuz': + return const Color(0xFF0052CC); + case 'amazon': + return const Color(0xFFFF9900); + default: + return colorScheme.primary; + } + } +} diff --git a/lib/screens/track_metadata_cards.dart b/lib/screens/track_metadata_cards.dart new file mode 100644 index 00000000..5d3cfbb0 --- /dev/null +++ b/lib/screens/track_metadata_cards.dart @@ -0,0 +1,857 @@ +part of 'track_metadata_screen.dart'; + +extension _TrackMetadataCards on _TrackMetadataScreenState { + Widget _buildAnimatedTrackContent( + BuildContext context, + WidgetRef ref, + ColorScheme colorScheme, + ) { + final currentKey = ValueKey('metadata_content_$_itemId'); + return AnimatedSwitcher( + duration: const Duration(milliseconds: 240), + reverseDuration: const Duration(milliseconds: 180), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + layoutBuilder: (currentChild, previousChildren) { + return Stack( + alignment: Alignment.topCenter, + children: [...previousChildren, ?currentChild], + ); + }, + transitionBuilder: (child, animation) { + if (_metadataTransitionDirection == 0) { + return child; + } + final isIncoming = child.key == currentKey; + final direction = _metadataTransitionDirection.toDouble(); + final begin = Offset( + isIncoming ? 0.18 * direction : -0.18 * direction, + 0, + ); + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + return ClipRect( + child: SlideTransition( + position: Tween( + begin: begin, + end: Offset.zero, + ).animate(curved), + child: FadeTransition(opacity: animation, child: child), + ), + ); + }, + child: Padding( + key: currentKey, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildMetadataCard(context, colorScheme, _fileSize), + + const SizedBox(height: 16), + + _buildFileInfoCard(context, colorScheme, _fileExists, _fileSize), + + const SizedBox(height: 16), + + _buildLyricsCard(context, colorScheme), + + if (_fileExists) ...[ + const SizedBox(height: 16), + AudioAnalysisCard(filePath: _filePath), + ], + + const SizedBox(height: 24), + + _buildActionButtons(context, ref, colorScheme, _fileExists), + + const SizedBox(height: 32), + ], + ), + ), + ); + } + + Widget _buildHeaderBackground( + BuildContext context, + ColorScheme colorScheme, + double expandedHeight, + bool showContent, + ) { + final cacheWidth = coverCacheWidthForViewport(context); + final coverChild = _hasPath(_embeddedCoverPreviewPath) + ? Image.file( + File(_embeddedCoverPreviewPath!), + fit: BoxFit.cover, + cacheWidth: cacheWidth, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + errorBuilder: (_, _, _) => Container(color: colorScheme.surface), + ) + : _coverUrl != null + ? CachedCoverImage( + imageUrl: _coverUrl!, + fit: BoxFit.cover, + memCacheWidth: cacheWidth, + placeholder: (_, _) => Container(color: colorScheme.surface), + errorWidget: (_, _, _) => Container(color: colorScheme.surface), + ) + : _localCoverPath != null && _localCoverPath!.isNotEmpty + ? Image.file( + File(_localCoverPath!), + fit: BoxFit.cover, + cacheWidth: cacheWidth, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + errorBuilder: (_, _, _) => Container(color: colorScheme.surface), + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + size: 80, + color: colorScheme.onSurfaceVariant, + ), + ); + + return Stack( + fit: StackFit.expand, + children: [ + Hero( + tag: _coverHeroTag, + child: Material(color: Colors.transparent, child: coverChild), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + height: expandedHeight * 0.65, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.85), + ], + ), + ), + ), + ), + Positioned( + left: 20, + right: 20, + bottom: 40, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + trackName, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + height: 1.2, + ), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + Text( + artistName, + style: const TextStyle( + color: Colors.white70, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + albumName, + style: const TextStyle(color: Colors.white54, fontSize: 14), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + if (_displayAudioQuality != null && + _displayAudioQuality!.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _displayAudioQuality!, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + if (duration != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _formatDuration(duration!), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + if (_service != 'local') + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _service[0].toUpperCase() + _service.substring(1), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ) + else + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.folder, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + context.l10n.librarySourceLocal, + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + if (_hasCheckedFile && !_fileExists) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.warning_rounded, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + context.l10n.trackFileNotFound, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ], + ); + } + + RoundedRectangleBorder _sectionCardShape(ColorScheme colorScheme) { + return RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ); + } + + Widget _buildMetadataCard( + BuildContext context, + ColorScheme colorScheme, + int? fileSize, + ) { + return Card( + elevation: 0, + color: settingsGroupColor(context), + shape: _sectionCardShape(colorScheme), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline, size: 20, color: colorScheme.primary), + const SizedBox(width: 8), + Text( + context.l10n.trackMetadata, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 16), + + _buildMetadataGrid(context, colorScheme), + + if (_spotifyId != null && _spotifyId!.isNotEmpty) ...[ + const SizedBox(height: 8), + Builder( + builder: (context) { + final openService = _serviceForTrackId( + _spotifyId!, + fallbackService: _service.toLowerCase(), + ); + String buttonLabel; + if (openService == 'deezer') { + buttonLabel = context.l10n.trackOpenInDeezer; + } else if (openService == 'amazon') { + buttonLabel = context.l10n.trackOpenInService( + 'Amazon Music', + ); + } else if (openService == 'tidal') { + buttonLabel = context.l10n.trackOpenInService('Tidal'); + } else if (openService == 'qobuz') { + buttonLabel = context.l10n.trackOpenInService('Qobuz'); + } else { + buttonLabel = context.l10n.trackOpenInSpotify; + } + return OutlinedButton.icon( + onPressed: () => _openServiceUrl(context), + icon: const Icon(Icons.open_in_new, size: 18), + label: Text(buttonLabel), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ); + }, + ), + ], + ], + ), + ), + ); + } + + Future _openServiceUrl(BuildContext context) async { + if (_spotifyId == null) return; + + final openService = _serviceForTrackId( + _spotifyId!, + fallbackService: _service.toLowerCase(), + ); + final rawId = _displayServiceTrackId(_spotifyId!); + + String webUrl; + Uri? appUri; + String serviceName; + + if (openService == 'deezer') { + webUrl = 'https://www.deezer.com/track/$rawId'; + appUri = Uri.parse('deezer://www.deezer.com/track/$rawId'); + serviceName = 'Deezer'; + } else if (openService == 'amazon') { + webUrl = 'https://music.amazon.com/search/$rawId'; + appUri = Uri.parse('amznm://search/$rawId'); + serviceName = 'Amazon Music'; + } else if (openService == 'tidal') { + webUrl = 'https://listen.tidal.com/track/$rawId'; + appUri = Uri.parse('tidal://track/$rawId'); + serviceName = 'Tidal'; + } else if (openService == 'qobuz') { + webUrl = 'https://play.qobuz.com/track/$rawId'; + appUri = Uri.parse('qobuz://track/$rawId'); + serviceName = 'Qobuz'; + } else { + webUrl = 'https://open.spotify.com/track/$rawId'; + appUri = Uri.parse('spotify:track:$rawId'); + serviceName = 'Spotify'; + } + + try { + final launched = await launchUrl( + appUri, + mode: LaunchMode.externalApplication, + ); + + if (!launched) { + await launchUrl( + Uri.parse(webUrl), + mode: LaunchMode.externalApplication, + ); + } + } catch (e) { + try { + await launchUrl( + Uri.parse(webUrl), + mode: LaunchMode.externalApplication, + ); + } catch (_) { + if (context.mounted) { + _copyToClipboard(context, webUrl); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarUrlCopied(serviceName)), + ), + ); + } + } + } + } + + Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) { + final audioQualityStr = _displayAudioQuality; + + final items = <_MetadataItem>[ + _MetadataItem(context.l10n.trackTrackName, trackName), + _MetadataItem(context.l10n.trackArtist, artistName), + if (albumArtist != null && albumArtist != artistName) + _MetadataItem(context.l10n.trackAlbumArtist, albumArtist!), + _MetadataItem(context.l10n.trackAlbum, albumName), + if (trackNumber != null && trackNumber! > 0) + _MetadataItem(context.l10n.trackTrackNumber, trackNumber.toString()), + if (totalTracks != null && totalTracks! > 0) + _MetadataItem( + context.l10n.editMetadataFieldTrackTotal, + totalTracks.toString(), + ), + if (discNumber != null && discNumber! > 0) + _MetadataItem(context.l10n.trackDiscNumber, discNumber.toString()), + if (totalDiscs != null && totalDiscs! > 0) + _MetadataItem( + context.l10n.editMetadataFieldDiscTotal, + totalDiscs.toString(), + ), + if (duration != null) + _MetadataItem(context.l10n.trackDuration, _formatDuration(duration!)), + if (audioQualityStr != null) + _MetadataItem(context.l10n.trackAudioQuality, audioQualityStr), + if (releaseDate != null && releaseDate!.isNotEmpty) + _MetadataItem(context.l10n.trackReleaseDate, releaseDate!), + if (genre != null && genre!.isNotEmpty) + _MetadataItem(context.l10n.trackGenre, genre!), + if (label != null && label!.isNotEmpty) + _MetadataItem(context.l10n.trackLabel, label!), + if (copyright != null && copyright!.isNotEmpty) + _MetadataItem(context.l10n.trackCopyright, copyright!), + if (composer != null && composer!.isNotEmpty) + _MetadataItem(context.l10n.editMetadataFieldComposer, composer!), + if (isrc != null && isrc!.isNotEmpty) _MetadataItem('ISRC', isrc!), + ]; + + if (!_isLocalItem && _spotifyId != null && _spotifyId!.isNotEmpty) { + final idService = _serviceForTrackId( + _spotifyId!, + fallbackService: _service.toLowerCase(), + ); + final cleanId = _displayServiceTrackId(_spotifyId!); + String idLabel; + switch (idService) { + case 'deezer': + idLabel = 'Deezer ID'; + case 'amazon': + idLabel = 'Amazon ASIN'; + case 'tidal': + idLabel = 'Tidal ID'; + case 'qobuz': + idLabel = 'Qobuz ID'; + default: + idLabel = 'Spotify ID'; + } + items.add(_MetadataItem(idLabel, cleanId)); + } + + items.add( + _MetadataItem(context.l10n.trackMetadataService, _service.toUpperCase()), + ); + items.add( + _MetadataItem(context.l10n.trackDownloaded, _formatFullDate(_addedAt)), + ); + + return Column( + children: items.map((metadata) { + final isCopyable = + metadata.label == 'ISRC' || + metadata.label == 'Spotify ID' || + metadata.label == 'Deezer ID' || + metadata.label == 'Amazon ASIN' || + metadata.label == 'Tidal ID' || + metadata.label == 'Qobuz ID'; + return InkWell( + onTap: isCopyable + ? () => _copyToClipboard(context, metadata.value) + : null, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + metadata.label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + metadata.value, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface, + ), + ), + ), + if (isCopyable) + Icon( + Icons.copy, + size: 14, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + ], + ), + ), + ); + }).toList(), + ); + } + + String _formatDuration(int seconds) { + final minutes = seconds ~/ 60; + final secs = seconds % 60; + return '$minutes:${secs.toString().padLeft(2, '0')}'; + } + + String _formatLabelForRaw(String raw) { + final normalized = raw.toLowerCase().replaceAll('-', '_'); + return switch (normalized) { + 'flac' => 'FLAC', + 'alac' => 'ALAC', + 'wav' || 'wave' => 'WAV', + 'aiff' || 'aif' || 'aifc' => 'AIFF', + 'eac3' || 'ec_3' => 'EAC3', + 'ac3' || 'ac_3' => 'AC3', + 'ac4' || 'ac_4' => 'AC4', + 'aac' || 'mp4a' => 'AAC', + 'm4a' || 'mp4' => 'M4A', + 'mp3' => 'MP3', + 'opus' => 'Opus', + 'ogg' => 'OGG', + _ => raw.toUpperCase(), + }; + } + + String _displayFormatLabelForFile(String fileName) { + final storedFormat = normalizeOptionalString(_storedAudioFormat); + final raw = + storedFormat ?? + (fileName.contains('.') ? fileName.split('.').last : 'Unknown'); + return _formatLabelForRaw(raw); + } + + bool _isBitrateFormatLabel(String label) { + return const { + 'MP3', + 'OPUS', + 'OGG', + 'M4A', + 'AAC', + 'EAC3', + 'AC3', + 'AC4', + }.contains(label.toUpperCase()); + } + + Widget _buildFileInfoCard( + BuildContext context, + ColorScheme colorScheme, + bool fileExists, + int? fileSize, + ) { + final displayFilePath = _formatPathForDisplay(rawFilePath); + final fileName = _extractFileNameFromPathOrUri(rawFilePath); + final fileExtension = _displayFormatLabelForFile(fileName); + final resolvedQuality = _displayAudioQuality; + final lossyBitrateLabel = _extractLossyBitrateLabel(resolvedQuality); + + return Card( + elevation: 0, + color: settingsGroupColor(context), + shape: _sectionCardShape(colorScheme), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.folder_outlined, + size: 20, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + context.l10n.trackFileInfo, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 16), + + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + fileExtension, + style: TextStyle( + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + if (fileSize != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _formatFileSize(fileSize), + style: TextStyle( + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + if (_isBitrateFormatLabel(fileExtension) && + lossyBitrateLabel != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + lossyBitrateLabel, + style: TextStyle( + color: colorScheme.onTertiaryContainer, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ) + else if (_audioBitrate != null && + _audioBitrate! > 0 && + _isBitrateFormatLabel(fileExtension)) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${_audioBitrate}kbps', + style: TextStyle( + color: colorScheme.onTertiaryContainer, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ) + else if (bitDepth != null && + bitDepth! > 0 && + sampleRate != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + buildDisplayAudioQuality( + bitDepth: bitDepth, + sampleRate: sampleRate, + ) ?? + '', + style: TextStyle( + color: colorScheme.onTertiaryContainer, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: _getServiceColor(_service, colorScheme), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getServiceIcon(_service), + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + _service.toUpperCase(), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 16), + + InkWell( + onTap: () => _copyToClipboard(context, cleanFilePath), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: Text( + displayFilePath, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: colorScheme.onSurfaceVariant, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Icon( + Icons.copy, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + +} diff --git a/lib/screens/track_metadata_convert.dart b/lib/screens/track_metadata_convert.dart new file mode 100644 index 00000000..8dfbfdc5 --- /dev/null +++ b/lib/screens/track_metadata_convert.dart @@ -0,0 +1,1547 @@ +part of 'track_metadata_screen.dart'; + +extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { + /// Whether the current file format supports conversion + bool get _isConvertibleFormat { + final lower = cleanFilePath.toLowerCase(); + return lower.endsWith('.flac') || + lower.endsWith('.m4a') || + lower.endsWith('.aac') || + lower.endsWith('.wav') || + lower.endsWith('.aiff') || + lower.endsWith('.aif') || + lower.endsWith('.mp3') || + lower.endsWith('.opus') || + lower.endsWith('.ogg'); + } + + /// Whether the current file is a CUE sheet (or CUE-referenced) + bool get _isCueFile { + if (isCueVirtualPath(rawFilePath)) return true; + final lower = cleanFilePath.toLowerCase(); + if (lower.endsWith('.cue')) return true; + if (_isLocalItem && _localLibraryItem != null) { + final format = _localLibraryItem!.format ?? ''; + if (format.startsWith('cue+')) return true; + } + return false; + } + + String get _currentFileFormat { + // For CUE tracks, use the format from the library item (e.g. "cue+flac") + if (_isCueFile && _isLocalItem && _localLibraryItem != null) { + final format = _localLibraryItem!.format ?? ''; + if (format.startsWith('cue+')) { + final audioFmt = format.substring(4).toUpperCase(); + return 'CUE+$audioFmt'; + } + } + if (_isLocalItem && _localLibraryItem != null) { + final format = normalizeOptionalString( + _localLibraryItem!.format, + )?.toLowerCase().replaceAll('-', '_'); + switch (format) { + case 'flac': + return 'FLAC'; + case 'alac': + return 'ALAC'; + case 'm4a': + return 'M4A'; + case 'aac': + case 'mp4a': + return 'AAC'; + case 'mp3': + return 'MP3'; + case 'opus': + case 'ogg': + return 'Opus'; + case 'wav': + case 'wave': + return 'WAV'; + case 'aiff': + case 'aif': + case 'aifc': + return 'AIFF'; + } + } + final lower = cleanFilePath.toLowerCase(); + if (lower.endsWith('.flac')) return 'FLAC'; + if (lower.endsWith('.m4a')) return 'M4A'; + if (lower.endsWith('.aac')) return 'AAC'; + if (lower.endsWith('.wav')) return 'WAV'; + if (lower.endsWith('.aiff') || lower.endsWith('.aif')) return 'AIFF'; + if (lower.endsWith('.mp3')) return 'MP3'; + if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'Opus'; + if (lower.endsWith('.cue')) return 'CUE'; + return 'Unknown'; + } + + Map _buildFallbackMetadata() { + String formatIndexTag(int number, int? total) { + if (total != null && total > 0) { + return '$number/$total'; + } + return number.toString(); + } + + return { + 'TITLE': trackName, + 'ARTIST': artistName, + 'ALBUM': albumName, + if (albumArtist != null && albumArtist!.isNotEmpty) + 'ALBUMARTIST': albumArtist!, + if (trackNumber != null) + 'TRACKNUMBER': formatIndexTag(trackNumber!, totalTracks), + if (discNumber != null) + 'DISCNUMBER': formatIndexTag(discNumber!, totalDiscs), + if (releaseDate != null && releaseDate!.isNotEmpty) 'DATE': releaseDate!, + if (isrc != null && isrc!.isNotEmpty) 'ISRC': isrc!, + if (genre != null && genre!.isNotEmpty) 'GENRE': genre!, + if (label != null && label!.isNotEmpty) 'LABEL': label!, + if (copyright != null && copyright!.isNotEmpty) 'COPYRIGHT': copyright!, + if (composer != null && composer!.isNotEmpty) 'COMPOSER': composer!, + }; + } + + Map _mapMetadataForTagEmbed(Map source) { + final mapped = {}; + + void put(String key, dynamic value) { + final normalized = value?.toString().trim(); + if (normalized == null || normalized.isEmpty) return; + mapped[key] = normalized; + } + + put('TITLE', source['title']); + put('ARTIST', source['artist']); + put('ALBUM', source['album']); + put('ALBUMARTIST', source['album_artist']); + put('DATE', source['date']); + put('ISRC', source['isrc']); + put('GENRE', source['genre']); + put('ORGANIZATION', source['label']); + put('COPYRIGHT', source['copyright']); + put('COMPOSER', source['composer']); + put('COMMENT', source['comment']); + put('LYRICS', source['lyrics']); + put('UNSYNCEDLYRICS', source['lyrics']); + + final trackNumber = source['track_number']; + final totalTracks = source['total_tracks']; + if (trackNumber != null && trackNumber.toString() != '0') { + final trackTag = + totalTracks != null && + totalTracks.toString().isNotEmpty && + totalTracks.toString() != '0' + ? '${trackNumber.toString()}/${totalTracks.toString()}' + : trackNumber; + put('TRACKNUMBER', trackTag); + } + final discNumber = source['disc_number']; + final totalDiscs = source['total_discs']; + if (discNumber != null && discNumber.toString() != '0') { + final discTag = + totalDiscs != null && + totalDiscs.toString().isNotEmpty && + totalDiscs.toString() != '0' + ? '${discNumber.toString()}/${totalDiscs.toString()}' + : discNumber; + put('DISCNUMBER', discTag); + } + + return mapped; + } + + String? _extractLossyBitrateLabel(String? quality) { + if (quality == null || quality.isEmpty) return null; + final match = RegExp( + r'(\d+)\s*k(?:bps)?', + caseSensitive: false, + ).firstMatch(quality); + if (match == null) return null; + return '${match.group(1)}kbps'; + } + + String _extractFileNameFromPathOrUri(String pathOrUri) { + if (pathOrUri.isEmpty) return ''; + try { + if (pathOrUri.startsWith('content://')) { + final uri = Uri.parse(pathOrUri); + if (uri.pathSegments.isNotEmpty) { + var last = Uri.decodeComponent(uri.pathSegments.last); + if (last.contains('/')) { + last = last.split('/').last; + } + if (last.contains(':')) { + last = last.split(':').last; + } + if (last.isNotEmpty) return last; + } + } + } catch (_) {} + + final normalized = pathOrUri.replaceAll('\\', '/'); + if (normalized.contains('/')) { + return normalized.split('/').last; + } + return normalized; + } + + Future _rescanReplayGain() async { + if (!_fileExists) return; + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + content: Text(context.l10n.trackReplayGainScanning), + duration: const Duration(seconds: 30), + ), + ); + bool ok = false; + try { + ok = await ReplayGainService.applyToFile(cleanFilePath); + } catch (e) { + _log.w('ReplayGain rescan failed: $e'); + } + if (!mounted) return; + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + content: Text( + ok + ? context.l10n.trackReplayGainSuccess + : context.l10n.trackReplayGainFailed, + ), + ), + ); + } + + void _showConvertSheet(BuildContext context) { + final currentFormat = _currentFileFormat; + final isLosslessSource = isLosslessConversionSource(currentFormat); + + final formats = []; + if (currentFormat == 'FLAC') { + formats.addAll(['ALAC', 'WAV', 'AIFF', 'AAC', 'MP3', 'Opus']); + } else if (currentFormat == 'ALAC') { + formats.addAll(['FLAC', 'WAV', 'AIFF', 'AAC', 'MP3', 'Opus']); + } else if (currentFormat == 'M4A') { + formats.addAll(['ALAC', 'FLAC', 'WAV', 'AIFF', 'AAC', 'MP3', 'Opus']); + } else if (currentFormat == 'WAV') { + formats.addAll(['FLAC', 'ALAC', 'AIFF', 'AAC', 'MP3', 'Opus']); + } else if (currentFormat == 'AIFF') { + formats.addAll(['FLAC', 'ALAC', 'WAV', 'AAC', 'MP3', 'Opus']); + } else if (currentFormat == 'AAC') { + formats.addAll(['MP3', 'Opus']); + } else if (currentFormat == 'MP3') { + formats.addAll(['AAC', 'Opus']); + } else if (currentFormat == 'Opus') { + formats.addAll(['AAC', 'MP3']); + } else { + formats.addAll(['AAC', 'MP3', 'Opus']); + } + + String selectedFormat = formats.first; + String defaultBitrateForFormat(String format) { + if (format == 'Opus') return '128k'; + if (format == 'AAC') return '256k'; + return '320k'; + } + + String selectedBitrate = defaultBitrateForFormat(selectedFormat); + bool isLosslessTarget = isLosslessConversionTarget(selectedFormat); + int? selectedMaxBitDepth; + int? selectedMaxSampleRate; + String selectedDither = 'none'; + String selectedResampler = 'swr'; + final bitDepthOptions = availableLosslessBitDepthOptions(bitDepth); + final sampleRateOptions = availableLosslessSampleRateOptions(sampleRate); + + showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + return StatefulBuilder( + builder: (context, setSheetState) { + final colorScheme = Theme.of(context).colorScheme; + final labels = context.l10n.losslessConversionLabels; + final bitrates = ['128k', '192k', '256k', '320k']; + + Widget card({required Widget child}) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: settingsGroupColor(context), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: child, + ); + } + + Widget sectionLabel(String text) { + return Padding( + padding: const EdgeInsets.only(left: 2, bottom: 12), + child: Text( + text, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + ), + ), + ); + } + + Widget choice({ + required String label, + required bool selected, + required VoidCallback onTap, + }) { + return Material( + color: selected + ? colorScheme.primaryContainer + : colorScheme.surface, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 11, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected + ? Colors.transparent + : colorScheme.outlineVariant.withValues(alpha: 0.6), + ), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: selected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurface, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + ), + ), + ); + } + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: DraggableScrollableSheet( + initialChildSize: 0.85, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => SafeArea( + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 18), + Text( + context.l10n.trackConvertTitle, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + currentFormat, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 20), + + card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + sectionLabel( + context.l10n.trackConvertTargetFormat, + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: formats.map((format) { + return choice( + label: format, + selected: format == selectedFormat, + onTap: () { + setSheetState(() { + selectedFormat = format; + isLosslessTarget = + isLosslessConversionTarget(format); + if (!isLosslessTarget) { + selectedBitrate = + defaultBitrateForFormat(format); + } else { + selectedMaxBitDepth = null; + selectedMaxSampleRate = null; + selectedDither = 'none'; + selectedResampler = 'swr'; + } + }); + }, + ); + }).toList(), + ), + ], + ), + ), + + if (!isLosslessTarget) + card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + sectionLabel(context.l10n.trackConvertBitrate), + Wrap( + spacing: 8, + runSpacing: 8, + children: bitrates.map((br) { + return choice( + label: br, + selected: br == selectedBitrate, + onTap: () => setSheetState( + () => selectedBitrate = br, + ), + ); + }).toList(), + ), + ], + ), + ), + + if (isLosslessTarget && bitDepthOptions.isNotEmpty) + card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + sectionLabel( + context.l10n.audioAnalysisBitDepth, + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + choice( + label: losslessBitDepthLabel( + null, + originalLabel: labels.original, + ), + selected: selectedMaxBitDepth == null, + onTap: () => setSheetState(() { + selectedMaxBitDepth = null; + selectedDither = 'none'; + }), + ), + ...bitDepthOptions.map((depth) { + return choice( + label: losslessBitDepthLabel( + depth, + originalLabel: labels.original, + ), + selected: depth == selectedMaxBitDepth, + onTap: () => setSheetState( + () => selectedMaxBitDepth = depth, + ), + ); + }), + ], + ), + ], + ), + ), + + if (isLosslessTarget && sampleRateOptions.isNotEmpty) + card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + sectionLabel( + context.l10n.audioAnalysisSampleRate, + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + choice( + label: losslessSampleRateLabel( + null, + originalLabel: labels.original, + ), + selected: selectedMaxSampleRate == null, + onTap: () => setSheetState(() { + selectedMaxSampleRate = null; + selectedResampler = 'swr'; + }), + ), + ...sampleRateOptions.map((rate) { + return choice( + label: losslessSampleRateLabel( + rate, + originalLabel: labels.original, + ), + selected: rate == selectedMaxSampleRate, + onTap: () => setSheetState( + () => selectedMaxSampleRate = rate, + ), + ); + }), + ], + ), + ], + ), + ), + + if (isLosslessTarget && selectedMaxBitDepth != null) + card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + sectionLabel( + context.l10n.trackConvertDithering, + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: losslessDitherOptions.map((mode) { + return choice( + label: context.l10n + .losslessDitherOptionLabel(mode), + selected: mode == selectedDither, + onTap: () => setSheetState( + () => selectedDither = mode, + ), + ); + }).toList(), + ), + ], + ), + ), + + if (isLosslessTarget && selectedMaxSampleRate != null) + card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + sectionLabel( + context.l10n.trackConvertResampler, + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: losslessResamplerOptions.map(( + mode, + ) { + return choice( + label: context.l10n + .losslessResamplerOptionLabel(mode), + selected: mode == selectedResampler, + onTap: () => setSheetState( + () => selectedResampler = mode, + ), + ); + }).toList(), + ), + ], + ), + ), + + if (isLosslessTarget && isLosslessSource) + Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon( + Icons.verified, + size: 18, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + selectedMaxBitDepth == null && + selectedMaxSampleRate == null + ? context.l10n.trackConvertLosslessHint + : context.l10n + .trackConvertLosslessOutputWithCap( + losslessQualityLabel( + LosslessConversionQuality( + maxBitDepth: + selectedMaxBitDepth, + maxSampleRate: + selectedMaxSampleRate, + ), + originalLabel: + labels.original, + originalQualityLabel: + labels.originalQuality, + ), + ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.primary), + ), + ), + ], + ), + ), + + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () { + Navigator.pop(context); + _confirmAndConvert( + context: this.context, + sourceFormat: currentFormat, + targetFormat: selectedFormat, + bitrate: selectedBitrate, + losslessQuality: LosslessConversionQuality( + maxBitDepth: selectedMaxBitDepth, + maxSampleRate: selectedMaxSampleRate, + ), + losslessProcessing: + LosslessConversionProcessing( + dither: selectedDither, + resampler: selectedResampler, + ), + ); + }, + icon: const Icon(Icons.swap_horiz), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + label: Text( + isLosslessTarget + ? context.l10n + .trackConvertActionLabelLossless( + currentFormat, + selectedFormat, + losslessQualityLabel( + LosslessConversionQuality( + maxBitDepth: selectedMaxBitDepth, + maxSampleRate: + selectedMaxSampleRate, + ), + originalLabel: labels.original, + originalQualityLabel: + labels.originalQuality, + ), + ) + : context.l10n.trackConvertActionLabelLossy( + currentFormat, + selectedFormat, + selectedBitrate, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + }, + ); + } + + void _showCueSplitSheet(BuildContext context) async { + var cuePath = cleanFilePath; + final unknownAlbum = context.l10n.unknownAlbum; + final unknownArtist = context.l10n.unknownArtist; + final trackSuffix = RegExp(r'#track\d+$'); + if (trackSuffix.hasMatch(cuePath)) { + cuePath = cuePath.replaceFirst(trackSuffix, ''); + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarLoadingCueSheet)), + ); + + try { + final cueInfo = await PlatformBridge.parseCueSheet(cuePath); + + if (!mounted) return; + _hideCurrentSnackBar(); + + if (cueInfo.containsKey('error')) { + _showSnackBarMessage(_l10nCueSplitNoAudioFile); + return; + } + + final album = cueInfo['album'] as String? ?? unknownAlbum; + final artist = cueInfo['artist'] as String? ?? unknownArtist; + final audioPath = cueInfo['audio_path'] as String? ?? ''; + final genre = cueInfo['genre'] as String? ?? ''; + final date = cueInfo['date'] as String? ?? ''; + final tracksRaw = cueInfo['tracks'] as List? ?? []; + + if (audioPath.isEmpty) { + _showSnackBarMessage(_l10nCueSplitNoAudioFile); + return; + } + + final tracks = tracksRaw + .map((t) => CueSplitTrackInfo.fromJson(t as Map)) + .toList(); + + if (tracks.isEmpty) { + _showSnackBarMessage(_l10nCueSplitFailed); + return; + } + + if (!mounted) return; + + showModalBottomSheet( + context: this.context, + useRootNavigator: true, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + final colorScheme = Theme.of(sheetContext).colorScheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Text( + sheetContext.l10n.cueSplitTitle, + style: Theme.of(sheetContext).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + Text( + sheetContext.l10n.cueSplitAlbum(album), + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 4), + Text( + sheetContext.l10n.cueSplitArtist(artist), + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 4), + Text( + sheetContext.l10n.cueSplitTrackCount(tracks.length), + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: ListView.builder( + shrinkWrap: true, + itemCount: tracks.length, + itemBuilder: (context, index) { + final track = tracks[index]; + final duration = track.endSec > 0 + ? track.endSec - track.startSec + : 0.0; + final durationStr = duration > 0 + ? '${(duration ~/ 60).toString().padLeft(2, '0')}:${(duration.toInt() % 60).toString().padLeft(2, '0')}' + : ''; + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + radius: 14, + backgroundColor: colorScheme.primaryContainer, + child: Text( + '${track.number}', + style: TextStyle( + fontSize: 11, + color: colorScheme.onPrimaryContainer, + ), + ), + ), + title: Text( + track.title, + style: const TextStyle(fontSize: 13), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: track.artist.isNotEmpty + ? Text( + track.artist, + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + trailing: durationStr.isNotEmpty + ? Text( + durationStr, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ) + : null, + ); + }, + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () { + Navigator.pop(sheetContext); + _confirmAndSplitCue( + context: this.context, + audioPath: audioPath, + album: album, + artist: artist, + genre: genre, + date: date, + tracks: tracks, + ); + }, + icon: const Icon(Icons.call_split), + label: Text(sheetContext.l10n.cueSplitButton), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + }, + ); + } catch (e) { + if (!mounted) return; + _hideCurrentSnackBar(); + _showSnackBarMessage(_l10nCueSplitFailed); + _log.e('Failed to parse CUE sheet: $e'); + } + } + + void _confirmAndSplitCue({ + required BuildContext context, + required String audioPath, + required String album, + required String artist, + required String genre, + required String date, + required List tracks, + }) { + showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.cueSplitConfirmTitle), + content: Text( + dialogContext.l10n.cueSplitConfirmMessage(album, tracks.length), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + Navigator.pop(dialogContext); + _performCueSplit( + audioPath: audioPath, + album: album, + artist: artist, + genre: genre, + date: date, + tracks: tracks, + ); + }, + child: Text(dialogContext.l10n.cueSplitButton), + ), + ], + ); + }, + ); + } + + Future _resolvePersistentCueSplitOutputDir() async { + final settings = ref.read(settingsProvider); + final queueState = ref.read(downloadQueueProvider); + final configuredOutputDir = queueState.outputDir.trim(); + if (settings.storageMode != 'saf' && + configuredOutputDir.isNotEmpty && + !isContentUri(configuredOutputDir)) { + final dir = Directory(configuredOutputDir); + await dir.create(recursive: true); + return dir; + } + + if (Platform.isAndroid) { + final externalDir = await getExternalStorageDirectory(); + if (externalDir != null) { + final musicDir = Directory( + '${externalDir.parent.parent.parent.parent.path}' + '${Platform.pathSeparator}Music' + '${Platform.pathSeparator}SpotiFLAC', + ); + await musicDir.create(recursive: true); + return musicDir; + } + } + + final docsDir = await getApplicationDocumentsDirectory(); + final fallbackDir = Directory( + '${docsDir.path}${Platform.pathSeparator}SpotiFLAC', + ); + await fallbackDir.create(recursive: true); + return fallbackDir; + } + + Future?> _exportCueSplitOutputsToSaf({ + required List outputPaths, + required String treeUri, + required String relativeDir, + }) async { + final exportedUris = []; + for (final path in outputPaths) { + final fileName = path.split(Platform.pathSeparator).last; + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: fileName, + mimeType: audioMimeTypeForPath(path), + srcPath: path, + ); + if (safUri != null && safUri.isNotEmpty) { + exportedUris.add(safUri); + } + } + return exportedUris.isEmpty ? null : exportedUris; + } + + Future _performCueSplit({ + required String audioPath, + required String album, + required String artist, + required String genre, + required String date, + required List tracks, + }) async { + if (_isConverting) return; + _setState(() => _isConverting = true); + + String? safTempAudioPath; + Directory? tempSplitDir; + try { + // For SAF content:// audio paths, copy to temp for FFmpeg processing + String workingAudioPath = audioPath; + final isSafSource = isContentUri(audioPath); + if (isSafSource) { + final tempPath = await PlatformBridge.copyContentUriToTemp(audioPath); + if (tempPath == null || tempPath.isEmpty) { + throw Exception('Failed to copy SAF audio file to temp'); + } + safTempAudioPath = tempPath; + workingAudioPath = tempPath; + } + + final String outputDir; + final treeUri = !_isLocalItem + ? (_downloadItem?.downloadTreeUri ?? '') + : ''; + final relativeDir = !_isLocalItem + ? (_downloadItem?.safRelativeDir ?? '') + : ''; + final writeBackToSaf = isSafSource && treeUri.isNotEmpty; + if (writeBackToSaf) { + final tempDir = await getTemporaryDirectory(); + tempSplitDir = Directory( + '${tempDir.path}${Platform.pathSeparator}' + 'cue_split_${DateTime.now().millisecondsSinceEpoch}', + ); + await tempSplitDir.create(recursive: true); + outputDir = tempSplitDir.path; + } else if (isSafSource) { + final persistentDir = await _resolvePersistentCueSplitOutputDir(); + outputDir = persistentDir.path; + } else { + outputDir = File(audioPath).parent.path; + } + + if (!mounted) return; + _showLongSnackBarMessage(_l10nCueSplitSplitting(1, tracks.length)); + + String? coverPath; + try { + final tempDir = await getTemporaryDirectory(); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}cue_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + workingAudioPath, + coverOutput, + ); + if (coverResult['error'] == null) { + coverPath = coverOutput; + } + } catch (_) {} + + final albumMetadata = { + 'artist': artist, + 'album': album, + 'genre': genre, + 'date': date, + }; + + final outputPaths = await FFmpegService.splitCueToTracks( + audioPath: workingAudioPath, + outputDir: outputDir, + tracks: tracks, + albumMetadata: albumMetadata, + coverPath: coverPath, + onProgress: (current, total) { + if (mounted) { + _hideCurrentSnackBar(); + _showLongSnackBarMessage(_l10nCueSplitSplitting(current, total)); + } + }, + ); + + var finalOutputPaths = outputPaths; + + if (coverPath != null && finalOutputPaths != null) { + for (final path in finalOutputPaths) { + if (path.toLowerCase().endsWith('.flac')) { + try { + // Only send the cover_path field — EditFlacFields uses + // field-presence semantics, so omitting artist/album_artist + // means those keys won't be rewritten. This preserves any + // existing split artist Vorbis Comments. + await PlatformBridge.editFileMetadata(path, { + 'cover_path': coverPath, + }); + } catch (e) { + _log.w('Failed to embed cover to split track: $e'); + } + } + } + } + + if (writeBackToSaf && finalOutputPaths != null) { + final exportedUris = await _exportCueSplitOutputsToSaf( + outputPaths: finalOutputPaths, + treeUri: treeUri, + relativeDir: relativeDir, + ); + finalOutputPaths = exportedUris; + } + + if (coverPath != null) { + try { + await File(coverPath).delete(); + } catch (_) {} + } + + if (mounted) { + _hideCurrentSnackBar(); + if (finalOutputPaths != null && finalOutputPaths.isNotEmpty) { + _showSnackBarMessage(_l10nCueSplitSuccess(finalOutputPaths.length)); + } else { + _showSnackBarMessage(_l10nCueSplitFailed); + } + } + } catch (e) { + _log.e('CUE split failed: $e'); + if (mounted) { + _hideCurrentSnackBar(); + _showSnackBarMessage(_l10nCueSplitFailed); + } + } finally { + if (safTempAudioPath != null) { + try { + await File(safTempAudioPath).delete(); + } catch (_) {} + } + if (tempSplitDir != null) { + try { + await tempSplitDir.delete(recursive: true); + } catch (_) {} + } + if (mounted) { + _setState(() => _isConverting = false); + } + } + } + + void _confirmAndConvert({ + required BuildContext context, + required String sourceFormat, + required String targetFormat, + required String bitrate, + LosslessConversionQuality losslessQuality = + const LosslessConversionQuality(), + LosslessConversionProcessing losslessProcessing = + const LosslessConversionProcessing(), + }) { + final isLossless = isLosslessConversionTarget(targetFormat); + showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.trackConvertConfirmTitle), + content: Text( + isLossless && losslessQuality.hasCaps + ? dialogContext.l10n.trackConvertConfirmMessageLosslessCapped( + sourceFormat, + targetFormat, + losslessQualityLabel( + losslessQuality, + originalLabel: + dialogContext.l10n.losslessConversionLabels.original, + originalQualityLabel: dialogContext + .l10n + .losslessConversionLabels + .originalQuality, + ), + ) + : isLossless + ? dialogContext.l10n.trackConvertConfirmMessageLossless( + sourceFormat, + targetFormat, + ) + : dialogContext.l10n.trackConvertConfirmMessage( + sourceFormat, + targetFormat, + bitrate, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + Navigator.pop(dialogContext); + _performConversion( + targetFormat: targetFormat, + bitrate: bitrate, + losslessQuality: losslessQuality, + losslessProcessing: losslessProcessing, + ); + }, + child: Text(dialogContext.l10n.trackConvertFormat), + ), + ], + ); + }, + ); + } + + Future _performConversion({ + required String targetFormat, + required String bitrate, + LosslessConversionQuality losslessQuality = + const LosslessConversionQuality(), + LosslessConversionProcessing losslessProcessing = + const LosslessConversionProcessing(), + }) async { + if (_isConverting) return; + _setState(() => _isConverting = true); + final losslessLabels = context.l10n.losslessConversionLabels; + + try { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackConvertConverting)), + ); + + final settings = ref.read(settingsProvider); + final shouldEmbedLyrics = + settings.embedLyrics && settings.lyricsMode != 'external'; + final metadata = _buildFallbackMetadata(); + try { + final result = await PlatformBridge.readFileMetadata(cleanFilePath); + if (result['error'] == null) { + mergePlatformMetadataForTagEmbed(target: metadata, source: result); + } else { + _log.w('readFileMetadata returned error, using fallback metadata'); + } + } catch (e) { + _log.w('readFileMetadata threw, using fallback metadata: $e'); + } + await ensureLyricsMetadataForConversion( + metadata: metadata, + sourcePath: cleanFilePath, + shouldEmbedLyrics: shouldEmbedLyrics, + trackName: trackName, + artistName: artistName, + spotifyId: _spotifyId ?? '', + durationMs: (duration ?? 0) * 1000, + ); + + String? coverPath; + try { + final tempDir = await getTemporaryDirectory(); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}convert_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + cleanFilePath, + coverOutput, + ); + if (coverResult['error'] == null) { + coverPath = coverOutput; + } + } catch (_) {} + + String workingPath = cleanFilePath; + final isSaf = _isSafFile; + String? safTempPath; + + if (isSaf) { + safTempPath = await PlatformBridge.copyContentUriToTemp(cleanFilePath); + if (safTempPath == null) { + if (mounted) { + _setState(() => _isConverting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackConvertFailed)), + ); + } + return; + } + workingPath = safTempPath; + } + + final newPath = await FFmpegService.convertAudioFormat( + inputPath: workingPath, + targetFormat: targetFormat.toLowerCase(), + bitrate: bitrate, + metadata: metadata, + coverPath: coverPath, + artistTagMode: ref.read(settingsProvider).artistTagMode, + deleteOriginal: !isSaf, + sourceBitDepth: bitDepth, + losslessQuality: losslessQuality, + losslessProcessing: losslessProcessing, + ); + + if (coverPath != null) { + try { + await File(coverPath).delete(); + } catch (_) {} + } + + if (newPath == null) { + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + if (mounted) { + _setState(() => _isConverting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackConvertFailed)), + ); + } + return; + } + + final isLosslessOutput = isLosslessConversionTarget(targetFormat); + int? convertedBitDepth; + int? convertedSampleRate; + if (isLosslessOutput) { + try { + final convertedMetadata = await PlatformBridge.readFileMetadata( + newPath, + ); + if (convertedMetadata['error'] == null) { + convertedBitDepth = readPositiveAudioInt( + convertedMetadata['bit_depth'], + ); + convertedSampleRate = readPositiveAudioInt( + convertedMetadata['sample_rate'], + ); + } + } catch (_) {} + convertedBitDepth ??= losslessQuality.effectiveBitDepth(bitDepth); + convertedSampleRate ??= losslessQuality.effectiveSampleRate(sampleRate); + } + final newQuality = convertedAudioQualityLabel( + targetFormat: targetFormat, + bitrate: bitrate, + labels: losslessLabels, + losslessQuality: losslessQuality, + actualBitDepth: convertedBitDepth, + actualSampleRate: convertedSampleRate, + ); + + if (isSaf) { + String? treeUri; + String relativeDir = ''; + String oldFileName = ''; + if (_isLocalItem) { + final uri = Uri.parse(cleanFilePath); + final pathSegments = uri.pathSegments; + final treeIdx = pathSegments.indexOf('tree'); + final docIdx = pathSegments.indexOf('document'); + if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) { + final treeId = pathSegments[treeIdx + 1]; + treeUri = + 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; + } + if (docIdx >= 0 && docIdx + 1 < pathSegments.length) { + final docPath = Uri.decodeFull(pathSegments[docIdx + 1]); + final slashIdx = docPath.lastIndexOf('/'); + if (slashIdx >= 0) { + oldFileName = docPath.substring(slashIdx + 1); + final treeId = treeIdx >= 0 && treeIdx + 1 < pathSegments.length + ? Uri.decodeFull(pathSegments[treeIdx + 1]) + : ''; + if (treeId.isNotEmpty && docPath.startsWith(treeId)) { + final afterTree = docPath.substring(treeId.length); + final trimmed = afterTree.startsWith('/') + ? afterTree.substring(1) + : afterTree; + final lastSlash = trimmed.lastIndexOf('/'); + relativeDir = lastSlash >= 0 + ? trimmed.substring(0, lastSlash) + : ''; + } + } else { + oldFileName = docPath; + } + } + } else { + treeUri = _downloadItem?.downloadTreeUri; + relativeDir = _downloadItem?.safRelativeDir ?? ''; + oldFileName = + (_downloadItem?.safFileName != null && + _downloadItem!.safFileName!.isNotEmpty) + ? _downloadItem!.safFileName! + : _extractFileNameFromPathOrUri(cleanFilePath); + } + if (treeUri == null || treeUri.isEmpty) { + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + if (mounted) { + _setState(() => _isConverting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackConvertFailed)), + ); + } + return; + } + + final dotIdx = oldFileName.lastIndexOf('.'); + final baseName = dotIdx > 0 + ? oldFileName.substring(0, dotIdx) + : oldFileName; + final convTarget = convertTargetExtAndMime(targetFormat); + final newExt = convTarget.ext; + final mimeType = convTarget.mime; + final newFileName = '$baseName$newExt'; + + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: newFileName, + mimeType: mimeType, + srcPath: newPath, + ); + + if (safUri == null || safUri.isEmpty) { + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + if (mounted) { + _setState(() => _isConverting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackConvertFailed)), + ); + } + return; + } + + if (!isSameContentUri(cleanFilePath, safUri)) { + final deletedOriginal = await PlatformBridge.safDelete( + cleanFilePath, + ).catchError((_) => false); + if (deletedOriginal != true) { + _log.w( + 'Converted SAF file created but failed deleting original URI', + ); + } + } + + if (!_isLocalItem) { + await HistoryDatabase.instance.updateFilePath( + _downloadItem!.id, + safUri, + newSafFileName: newFileName, + newQuality: newQuality, + newFormat: normalizedConvertedAudioFormat(targetFormat), + newBitrate: convertedAudioBitrateKbps( + targetFormat: targetFormat, + bitrate: bitrate, + ), + newBitDepth: convertedBitDepth, + newSampleRate: convertedSampleRate, + clearAudioSpecs: !isLosslessOutput, + ); + await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); + } else { + await LibraryDatabase.instance.replaceWithConvertedItem( + item: _localLibraryItem!, + newFilePath: safUri, + targetFormat: targetFormat, + bitrate: bitrate, + bitDepth: convertedBitDepth, + sampleRate: convertedSampleRate, + ); + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + } else { + if (!_isLocalItem) { + await HistoryDatabase.instance.updateFilePath( + _downloadItem!.id, + newPath, + newQuality: newQuality, + newFormat: normalizedConvertedAudioFormat(targetFormat), + newBitrate: convertedAudioBitrateKbps( + targetFormat: targetFormat, + bitrate: bitrate, + ), + newBitDepth: convertedBitDepth, + newSampleRate: convertedSampleRate, + clearAudioSpecs: !isLosslessOutput, + ); + await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); + } else { + await LibraryDatabase.instance.replaceWithConvertedItem( + item: _localLibraryItem!, + newFilePath: newPath, + targetFormat: targetFormat, + bitrate: bitrate, + bitDepth: convertedBitDepth, + sampleRate: convertedSampleRate, + ); + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + } + + if (mounted) { + _setState(() => _isConverting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.trackConvertSuccess(targetFormat)), + ), + ); + Navigator.pop(context, true); + } + } catch (e) { + if (mounted) { + _setState(() => _isConverting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))), + ); + } + } + } + +} diff --git a/lib/screens/track_metadata_lyrics.dart b/lib/screens/track_metadata_lyrics.dart new file mode 100644 index 00000000..5db3d1c1 --- /dev/null +++ b/lib/screens/track_metadata_lyrics.dart @@ -0,0 +1,1217 @@ +part of 'track_metadata_screen.dart'; + +extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { + Widget _buildLyricsCard(BuildContext context, ColorScheme colorScheme) { + return Card( + elevation: 0, + color: settingsGroupColor(context), + shape: _sectionCardShape(colorScheme), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.lyrics_outlined, + size: 20, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + context.l10n.trackLyrics, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + const Spacer(), + if (_lyrics != null) + IconButton( + icon: const Icon(Icons.copy, size: 20), + onPressed: () => _copyToClipboard(context, _lyrics!), + tooltip: context.l10n.trackCopyLyrics, + ), + ], + ), + if (_lyricsSource != null && _lyricsSource!.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + context.l10n.trackLyricsSource(_lyricsSource!), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 12), + + if (_lyricsLoading) + const Center( + child: Padding( + padding: EdgeInsets.all(20), + child: CircularProgressIndicator(), + ), + ) + else if (_lyricsError != null) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: colorScheme.error, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _lyricsError!, + style: TextStyle(color: colorScheme.onErrorContainer), + ), + ), + TextButton( + onPressed: _fetchOnlineLyrics, + child: Text(context.l10n.dialogRetry), + ), + ], + ), + ) + else if (_isInstrumental) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.music_note, + color: colorScheme.tertiary, + size: 20, + ), + const SizedBox(width: 12), + Text( + context.l10n.trackInstrumental, + style: TextStyle( + color: colorScheme.onTertiaryContainer, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ) + else if (_lyrics != null) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + constraints: const BoxConstraints(maxHeight: 300), + child: SingleChildScrollView( + child: Text( + _lyrics!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface, + height: 1.6, + ), + ), + ), + ), + if (!_lyricsEmbedded && _fileExists) ...[ + const SizedBox(height: 16), + Center( + child: FilledButton.tonalIcon( + onPressed: _isEmbedding ? null : _embedLyrics, + icon: _isEmbedding + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.save_alt), + label: Text(context.l10n.trackEmbedLyrics), + ), + ), + ], + ], + ) + else if (_embeddedLyricsChecked && _fileExists) + Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues( + alpha: 0.5, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.lyrics_outlined, + color: colorScheme.onSurfaceVariant, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + context.l10n.trackLyricsNotInFile, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + Center( + child: FilledButton.tonalIcon( + onPressed: _fetchOnlineLyrics, + icon: const Icon(Icons.cloud_download_outlined), + label: Text(context.l10n.trackFetchOnlineLyrics), + ), + ), + ], + ) + else + Center( + child: FilledButton.tonalIcon( + onPressed: _fetchLyrics, + icon: const Icon(Icons.download), + label: Text(context.l10n.trackLoadLyrics), + ), + ), + ], + ), + ), + ); + } + + /// Check for lyrics embedded in the audio file only (no network requests). + /// Called automatically when the screen opens. + Future _checkEmbeddedLyrics() async { + if (_lyricsLoading || !_fileExists) return; + final generation = _metadataLoadGeneration; + final sourcePath = cleanFilePath; + if (!mounted) return; + + _setState(() { + _lyricsLoading = true; + _lyricsError = null; + _isInstrumental = false; + _lyricsSource = null; + }); + + try { + final embeddedResult = + await PlatformBridge.getLyricsLRCWithSource( + '', + trackName, + artistName, + filePath: sourcePath, + durationMs: 0, + ).timeout( + const Duration(seconds: 5), + onTimeout: () => {'lyrics': '', 'source': ''}, + ); + + final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? ''; + final embeddedSource = embeddedResult['source']?.toString() ?? ''; + + if (mounted && + generation == _metadataLoadGeneration && + sourcePath == cleanFilePath) { + if (embeddedLyrics.isNotEmpty) { + final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics); + _setState(() { + _lyrics = cleanLyrics; + _rawLyrics = embeddedLyrics; + _lyricsSource = embeddedSource.isNotEmpty + ? embeddedSource + : context.l10n.trackLyricsEmbeddedSource; + _lyricsEmbedded = true; + _lyricsLoading = false; + _embeddedLyricsChecked = true; + }); + } else { + _setState(() { + _lyricsLoading = false; + _embeddedLyricsChecked = true; + }); + } + } + } catch (e) { + if (mounted && + generation == _metadataLoadGeneration && + sourcePath == cleanFilePath) { + _setState(() { + _lyricsLoading = false; + _embeddedLyricsChecked = true; + }); + } + } + } + + /// Fetch lyrics from online providers. Only called by user action. + Future _fetchOnlineLyrics() async { + if (_lyricsLoading) return; + + _setState(() { + _lyricsLoading = true; + _lyricsError = null; + _isInstrumental = false; + _lyricsSource = null; + }); + + try { + final durationMs = (duration ?? 0) * 1000; + + final result = await PlatformBridge.getLyricsLRCWithSource( + _spotifyId ?? '', + trackName, + artistName, + filePath: null, + durationMs: durationMs, + ).timeout(const Duration(seconds: 20)); + + final lrcText = result['lyrics']?.toString() ?? ''; + final source = result['source']?.toString() ?? ''; + final instrumental = + (result['instrumental'] as bool? ?? false) || + lrcText == '[instrumental:true]'; + + if (mounted) { + if (instrumental) { + _setState(() { + _isInstrumental = true; + _lyricsSource = source.isNotEmpty ? source : null; + _lyricsLoading = false; + }); + } else if (lrcText.isEmpty) { + _setState(() { + _lyricsError = context.l10n.trackLyricsNotAvailable; + _lyricsLoading = false; + }); + } else { + final cleanLyrics = _cleanLrcForDisplay(lrcText); + _setState(() { + _lyrics = cleanLyrics; + _rawLyrics = lrcText; + _lyricsSource = source.isNotEmpty ? source : null; + _lyricsEmbedded = false; + _lyricsLoading = false; + }); + } + } + } on TimeoutException { + if (mounted) { + _setState(() { + _lyricsError = context.l10n.trackLyricsTimeout; + _lyricsLoading = false; + }); + } + } catch (e) { + if (mounted) { + _setState(() { + _lyricsError = context.l10n.trackLyricsLoadFailed; + _lyricsLoading = false; + }); + } + } + } + + /// Full lyrics fetch: check embedded first, then online. + /// Used by the "Load Lyrics" button when file doesn't exist (non-local items). + Future _fetchLyrics() async { + if (_lyricsLoading) return; + + _setState(() { + _lyricsLoading = true; + _lyricsError = null; + _isInstrumental = false; + _lyricsSource = null; + }); + + try { + final durationMs = (duration ?? 0) * 1000; + + if (_fileExists) { + final embeddedResult = + await PlatformBridge.getLyricsLRCWithSource( + '', + trackName, + artistName, + filePath: cleanFilePath, + durationMs: 0, + ).timeout( + const Duration(seconds: 5), + onTimeout: () => {'lyrics': '', 'source': ''}, + ); + + final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? ''; + final embeddedSource = embeddedResult['source']?.toString() ?? ''; + + if (embeddedLyrics.isNotEmpty) { + if (mounted) { + final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics); + _setState(() { + _lyrics = cleanLyrics; + _rawLyrics = embeddedLyrics; + _lyricsSource = embeddedSource.isNotEmpty + ? embeddedSource + : context.l10n.trackLyricsEmbeddedSource; + _lyricsEmbedded = true; + _lyricsLoading = false; + _embeddedLyricsChecked = true; + }); + } + return; + } + } + + final result = await PlatformBridge.getLyricsLRCWithSource( + _spotifyId ?? '', + trackName, + artistName, + filePath: null, + durationMs: durationMs, + ).timeout(const Duration(seconds: 20)); + + final lrcText = result['lyrics']?.toString() ?? ''; + final source = result['source']?.toString() ?? ''; + final instrumental = + (result['instrumental'] as bool? ?? false) || + lrcText == '[instrumental:true]'; + + if (mounted) { + if (instrumental) { + _setState(() { + _isInstrumental = true; + _lyricsSource = source.isNotEmpty ? source : null; + _lyricsLoading = false; + }); + } else if (lrcText.isEmpty) { + _setState(() { + _lyricsError = context.l10n.trackLyricsNotAvailable; + _lyricsLoading = false; + }); + } else { + final cleanLyrics = _cleanLrcForDisplay(lrcText); + _setState(() { + _lyrics = cleanLyrics; + _rawLyrics = lrcText; + _lyricsSource = source.isNotEmpty ? source : null; + _lyricsEmbedded = false; + _lyricsLoading = false; + }); + } + } + } on TimeoutException { + if (mounted) { + _setState(() { + _lyricsError = context.l10n.trackLyricsTimeout; + _lyricsLoading = false; + }); + } + } catch (e) { + if (mounted) { + _setState(() { + _lyricsError = context.l10n.trackLyricsLoadFailed; + _lyricsLoading = false; + }); + } + } + } + + Future _embedLyrics() async { + if (_isEmbedding || _rawLyrics == null || !_fileExists) return; + + _setState(() => _isEmbedding = true); + + final l10nFailedToWriteStorage = context.l10n.snackbarFailedToWriteStorage; + final l10nFailedToEmbedLyrics = context.l10n.snackbarFailedToEmbedLyrics; + final l10nUnsupportedFormat = context.l10n.snackbarUnsupportedAudioFormat; + + String? safTempPath; + String? coverPath; + + try { + final rawLyrics = _rawLyrics!; + var workingPath = cleanFilePath; + + if (_isSafFile) { + safTempPath = await PlatformBridge.copyContentUriToTemp(cleanFilePath); + if (safTempPath == null || safTempPath.isEmpty) { + throw Exception('Failed to access SAF file'); + } + workingPath = safTempPath; + } + + final lower = workingPath.toLowerCase(); + final isFlac = lower.endsWith('.flac'); + final isMp3 = lower.endsWith('.mp3'); + final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg'); + final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac'); + + bool success = false; + String? error; + + if (isFlac) { + final result = await PlatformBridge.embedLyricsToFile( + workingPath, + rawLyrics, + ); + if (result['success'] == true) { + if (_isSafFile) { + final ok = await PlatformBridge.writeTempToSaf( + workingPath, + cleanFilePath, + ); + success = ok; + if (!ok) { + error = l10nFailedToWriteStorage; + } + } else { + success = true; + } + } else { + error = result['error']?.toString() ?? l10nFailedToEmbedLyrics; + } + } else if (isMp3 || isOpus || isM4A) { + final metadata = _buildFallbackMetadata(); + try { + final result = await PlatformBridge.readFileMetadata(workingPath); + if (result['error'] == null) { + final mapped = _mapMetadataForTagEmbed(result); + metadata.addAll(mapped); + } + } catch (e) { + _log.w('Failed reading file metadata before lyrics embed: $e'); + } + + metadata['LYRICS'] = rawLyrics; + metadata['UNSYNCEDLYRICS'] = rawLyrics; + + try { + final tempDir = await getTemporaryDirectory(); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}lyrics_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + workingPath, + coverOutput, + ); + if (coverResult['error'] == null) { + coverPath = coverOutput; + } + } catch (_) {} + + final artistTagMode = ref.read(settingsProvider).artistTagMode; + String? ffmpegResult; + if (isMp3) { + ffmpegResult = await FFmpegService.embedMetadataToMp3( + mp3Path: workingPath, + coverPath: coverPath, + metadata: metadata, + ); + } else if (isM4A) { + ffmpegResult = await FFmpegService.embedMetadataToM4a( + m4aPath: workingPath, + coverPath: coverPath, + metadata: metadata, + ); + } else { + ffmpegResult = await FFmpegService.embedMetadataToOpus( + opusPath: workingPath, + coverPath: coverPath, + metadata: metadata, + artistTagMode: artistTagMode, + ); + } + + if (ffmpegResult == null) { + error = l10nFailedToEmbedLyrics; + } else if (_isSafFile) { + final ok = await PlatformBridge.writeTempToSaf( + ffmpegResult, + cleanFilePath, + ); + success = ok; + if (!ok) { + error = l10nFailedToWriteStorage; + } + } else { + success = true; + } + } else { + error = l10nUnsupportedFormat; + } + + if (mounted) { + if (success) { + _setState(() { + _lyricsEmbedded = true; + _isEmbedding = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackLyricsEmbedded)), + ); + } else { + _setState(() => _isEmbedding = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error ?? context.l10n.snackbarFailedToEmbedLyrics), + ), + ); + } + } + } catch (e) { + if (mounted) { + _setState(() => _isEmbedding = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + ); + } + } finally { + if (coverPath != null) { + try { + await File(coverPath).delete(); + } catch (_) {} + } + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + } + } + + String _sanitizeFileNameSegment(String value) { + var sanitized = value.replaceAll(_TrackMetadataScreenState._invalidFileNameChars, '_').trim(); + sanitized = sanitized.replaceAll(_TrackMetadataScreenState._leadingOrTrailingDots, ''); + sanitized = sanitized.replaceAll(_TrackMetadataScreenState._multiUnderscore, '_'); + if (sanitized.isEmpty) { + return 'untitled'; + } + return sanitized; + } + + String _buildSaveBaseName() { + final artist = _sanitizeFileNameSegment(artistName); + final track = _sanitizeFileNameSegment(trackName); + return '$artist - $track'; + } + + String _getFileDirectory() { + if (isContentUri(cleanFilePath)) { + // SAF URIs don't have a filesystem parent directory + return ''; + } + final file = File(cleanFilePath); + return file.parent.path; + } + + bool get _isSafFile => isContentUri(cleanFilePath); + + Future _saveCoverArt() async { + try { + final baseName = _buildSaveBaseName(); + + if (_isSafFile) { + final tempDir = await Directory.systemTemp.createTemp('cover_'); + final tempOutput = + '${tempDir.path}${Platform.pathSeparator}$baseName.jpg'; + + Map result; + if (_fileExists) { + // Prefer extracting cover from the already-downloaded file to avoid + // a redundant network request. + result = await PlatformBridge.extractCoverToFile( + cleanFilePath, + tempOutput, + ); + if (result['error'] != null && + _coverUrl != null && + _coverUrl!.isNotEmpty) { + result = await PlatformBridge.downloadCoverToFile( + _coverUrl!, + tempOutput, + maxQuality: true, + ); + } + } else if (_coverUrl != null && _coverUrl!.isNotEmpty) { + result = await PlatformBridge.downloadCoverToFile( + _coverUrl!, + tempOutput, + maxQuality: true, + ); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackCoverNoSource)), + ); + } + return; + } + + if (result['error'] != null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.trackSaveFailed(result['error'].toString()), + ), + ), + ); + } + try { + await Directory(tempDir.path).delete(recursive: true); + } catch (_) {} + return; + } + + final treeUri = _downloadItem?.downloadTreeUri; + final relativeDir = _downloadItem?.safRelativeDir ?? ''; + if (treeUri != null && treeUri.isNotEmpty) { + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: '$baseName.jpg', + mimeType: 'image/jpeg', + srcPath: tempOutput, + ); + try { + await Directory(tempDir.path).delete(recursive: true); + } catch (_) {} + if (mounted) { + if (safUri != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackCoverSaved(baseName))), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.trackSaveFailed('Failed to write to storage'), + ), + ), + ); + } + } + } else { + try { + await Directory(tempDir.path).delete(recursive: true); + } catch (_) {} + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.trackSaveFailed('No storage access'), + ), + ), + ); + } + } + return; + } + + final dir = _getFileDirectory(); + final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg'; + + Map result; + if (_fileExists) { + result = await PlatformBridge.extractCoverToFile( + cleanFilePath, + outputPath, + ); + if (result['error'] != null && + _coverUrl != null && + _coverUrl!.isNotEmpty) { + result = await PlatformBridge.downloadCoverToFile( + _coverUrl!, + outputPath, + maxQuality: true, + ); + } + } else if (_coverUrl != null && _coverUrl!.isNotEmpty) { + result = await PlatformBridge.downloadCoverToFile( + _coverUrl!, + outputPath, + maxQuality: true, + ); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackCoverNoSource)), + ); + } + return; + } + + if (mounted) { + if (result['error'] != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.trackSaveFailed(result['error'].toString()), + ), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackCoverSaved(baseName))), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))), + ); + } + } + } + + Future _saveLyrics() async { + try { + final baseName = _buildSaveBaseName(); + final durationMs = (duration ?? 0) * 1000; + if (mounted) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveLyricsProgress)), + ); + } + + if (_isSafFile) { + final tempDir = await Directory.systemTemp.createTemp('lyrics_'); + final tempOutput = + '${tempDir.path}${Platform.pathSeparator}$baseName.lrc'; + + final result = await PlatformBridge.fetchAndSaveLyrics( + trackName: trackName, + artistName: artistName, + spotifyId: _spotifyId ?? '', + durationMs: durationMs, + outputPath: tempOutput, + audioFilePath: _fileExists ? cleanFilePath : '', + ); + + if (result['error'] != null) { + if (mounted) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + context.l10n.trackSaveFailed(result['error'].toString()), + ), + ), + ); + } + try { + await Directory(tempDir.path).delete(recursive: true); + } catch (_) {} + return; + } + + final treeUri = _downloadItem?.downloadTreeUri; + final relativeDir = _downloadItem?.safRelativeDir ?? ''; + if (treeUri != null && treeUri.isNotEmpty) { + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: '$baseName.lrc', + mimeType: 'application/octet-stream', + srcPath: tempOutput, + ); + try { + await Directory(tempDir.path).delete(recursive: true); + } catch (_) {} + if (mounted) { + if (safUri != null) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(context.l10n.trackLyricsSaved(baseName)), + ), + ); + } else { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + context.l10n.trackSaveFailed( + 'Failed to write to storage', + ), + ), + ), + ); + } + } + } else { + try { + await Directory(tempDir.path).delete(recursive: true); + } catch (_) {} + if (mounted) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + context.l10n.trackSaveFailed('No storage access'), + ), + ), + ); + } + } + return; + } + + final dir = _getFileDirectory(); + final outputPath = '$dir${Platform.pathSeparator}$baseName.lrc'; + + final result = await PlatformBridge.fetchAndSaveLyrics( + trackName: trackName, + artistName: artistName, + spotifyId: _spotifyId ?? '', + durationMs: durationMs, + outputPath: outputPath, + audioFilePath: _fileExists ? cleanFilePath : '', + ); + + if (mounted) { + if (result['error'] != null) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + context.l10n.trackSaveFailed(result['error'].toString()), + ), + ), + ); + } else { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(context.l10n.trackLyricsSaved(baseName))), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))), + ); + } + } + } + + Future _reEnrichMetadata() async { + if (!_fileExists) return; + + try { + final settings = ref.read(settingsProvider); + final artistTagMode = settings.artistTagMode; + final messenger = ScaffoldMessenger.of(context); + final l10n = context.l10n; + await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend(); + if (!mounted) return; + messenger.showSnackBar( + SnackBar(content: Text(l10n.trackReEnrichSearching)), + ); + + final durationMs = (duration ?? 0) * 1000; + final request = { + 'file_path': cleanFilePath, + 'cover_url': _coverUrl ?? '', + 'max_quality': true, + 'embed_lyrics': settings.embedLyrics, + 'lyrics_mode': settings.lyricsMode, + 'artist_tag_mode': artistTagMode, + 'spotify_id': _spotifyId ?? '', + 'track_name': trackName, + 'artist_name': artistName, + 'album_name': albumName, + 'album_artist': albumArtist ?? '', + 'track_number': trackNumber ?? 0, + 'total_tracks': totalTracks ?? 0, + 'disc_number': discNumber ?? 0, + 'total_discs': totalDiscs ?? 0, + 'release_date': releaseDate ?? '', + 'isrc': isrc ?? '', + 'genre': genre ?? '', + 'label': label ?? '', + 'copyright': copyright ?? '', + 'composer': composer ?? '', + 'duration_ms': durationMs, + 'search_online': true, + }; + + final result = await PlatformBridge.reEnrichFile(request); + final method = result['method'] as String?; + + final enriched = result['enriched_metadata'] as Map?; + if (enriched != null && mounted) { + _setState(() { + _editedMetadata = { + 'title': enriched['track_name'] ?? trackName, + 'artist': enriched['artist_name'] ?? artistName, + 'album': enriched['album_name'] ?? albumName, + 'album_artist': enriched['album_artist'] ?? albumArtist, + 'date': enriched['release_date'] ?? releaseDate, + 'track_number': enriched['track_number'] ?? trackNumber, + 'total_tracks': enriched['total_tracks'] ?? totalTracks, + 'disc_number': enriched['disc_number'] ?? discNumber, + 'total_discs': enriched['total_discs'] ?? totalDiscs, + 'isrc': enriched['isrc'] ?? isrc, + 'genre': enriched['genre'] ?? genre, + 'label': enriched['label'] ?? label, + 'copyright': enriched['copyright'] ?? copyright, + 'composer': enriched['composer'] ?? composer, + }; + }); + } + + if (method == 'native') { + // FLAC - handled natively by Go (SAF write-back handled in Kotlin). + // External .lrc sidecar for filesystem files is written here; SAF + // sidecars are created natively in the Kotlin reEnrichFile handler. + await writeReEnrichSidecarLrc( + audioFilePath: cleanFilePath, + reEnrichResult: result, + ); + await _refreshEmbeddedCoverPreview(force: true); + _markMetadataChanged(); + await _syncDownloadHistoryMetadata(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackReEnrichSuccess)), + ); + } + } else if (method == 'ffmpeg') { + // MP3/Opus - need FFmpeg from Dart side + // For SAF files, Kotlin returns temp_path + saf_uri + final tempPath = result['temp_path'] as String?; + final safUri = result['saf_uri'] as String?; + final ffmpegTarget = tempPath ?? cleanFilePath; + + final downloadedCoverPath = result['cover_path'] as String?; + String? effectiveCoverPath = downloadedCoverPath; + String? extractedCoverPath; + if (!_hasPath(effectiveCoverPath)) { + try { + final tempDir = await Directory.systemTemp.createTemp( + 'reenrich_cover_', + ); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + final extracted = await PlatformBridge.extractCoverToFile( + ffmpegTarget, + coverOutput, + ); + if (extracted['error'] == null) { + effectiveCoverPath = coverOutput; + extractedCoverPath = coverOutput; + } else { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } catch (_) {} + } + final metadata = (result['metadata'] as Map?)?.map( + (k, v) => MapEntry(k, v.toString()), + ); + final lower = cleanFilePath.toLowerCase(); + + String? ffmpegResult; + if (lower.endsWith('.mp3')) { + ffmpegResult = await FFmpegService.embedMetadataToMp3( + mp3Path: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + ); + } else if (lower.endsWith('.m4a') || lower.endsWith('.aac')) { + ffmpegResult = await FFmpegService.embedMetadataToM4a( + m4aPath: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + ); + } else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) { + ffmpegResult = await FFmpegService.embedMetadataToOpus( + opusPath: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + artistTagMode: artistTagMode, + ); + } + + if (ffmpegResult != null && tempPath != null && safUri != null) { + final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); + if (!ok && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.trackSaveFailed( + context.l10n.snackbarFailedToWriteStorage, + ), + ), + ), + ); + if (_hasPath(downloadedCoverPath)) { + try { + await File(downloadedCoverPath!).delete(); + } catch (_) {} + } + if (_hasPath(extractedCoverPath)) { + await _cleanupTempFileAndParent(extractedCoverPath); + } + if (tempPath.isNotEmpty) { + try { + await File(tempPath).delete(); + } catch (_) {} + } + return; + } + await writeReEnrichSafSidecarLrc( + safUri: safUri, + reEnrichResult: result, + ); + } + + if (tempPath != null && tempPath.isNotEmpty) { + try { + await File(tempPath).delete(); + } catch (_) {} + } + + if (ffmpegResult != null) { + // Filesystem .lrc sidecar. SAF sidecar is written only after + // writeTempToSaf succeeds. + await writeReEnrichSidecarLrc( + audioFilePath: cleanFilePath, + reEnrichResult: result, + ); + await _refreshEmbeddedCoverPreview(force: true); + _markMetadataChanged(); + await _syncDownloadHistoryMetadata(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackReEnrichSuccess)), + ); + } + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackReEnrichFfmpegFailed)), + ); + } + + if (_hasPath(downloadedCoverPath)) { + try { + await File(downloadedCoverPath!).delete(); + } catch (_) {} + } + if (_hasPath(extractedCoverPath)) { + await _cleanupTempFileAndParent(extractedCoverPath); + } + } else { + if (mounted) { + final error = result['error']?.toString() ?? 'Unknown error'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed(error))), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))), + ); + } + } + } + + Future _syncDownloadHistoryMetadata() async { + if (_isLocalItem || _downloadItem == null) return; + + String? normalizedOrNull(String? value) { + if (value == null) return null; + final trimmed = value.trim(); + if (trimmed.isEmpty) return null; + return trimmed; + } + + try { + await ref + .read(downloadHistoryProvider.notifier) + .updateMetadataForItem( + id: _downloadItem!.id, + trackName: trackName, + artistName: artistName, + albumName: albumName, + albumArtist: normalizedOrNull(albumArtist), + isrc: normalizedOrNull(isrc), + trackNumber: trackNumber, + totalTracks: totalTracks, + discNumber: discNumber, + totalDiscs: totalDiscs, + releaseDate: normalizedOrNull(releaseDate), + genre: normalizedOrNull(genre), + composer: normalizedOrNull(composer), + label: normalizedOrNull(label), + copyright: normalizedOrNull(copyright), + ); + } catch (e) { + _log.w('Failed to sync download history metadata: $e'); + } + } + + String _cleanLrcForDisplay(String lrc) { + final lines = lrc.split('\n'); + final cleanLines = []; + + for (final line in lines) { + var cleaned = line.trim(); + + if (_TrackMetadataScreenState._lrcMetadataPattern.hasMatch(cleaned) && + !_TrackMetadataScreenState._lrcBackgroundLinePattern.hasMatch(cleaned)) { + continue; + } + + // Convert [bg:...] wrapper to a plain secondary vocal line. + final bgMatch = _TrackMetadataScreenState._lrcBackgroundLinePattern.firstMatch(cleaned); + if (bgMatch != null) { + cleaned = bgMatch.group(1)?.trim() ?? ''; + } + + cleaned = cleaned.replaceAll(_TrackMetadataScreenState._lrcTimestampPattern, '').trim(); + cleaned = cleaned.replaceAll(_TrackMetadataScreenState._lrcInlineTimestampPattern, ''); + cleaned = cleaned.replaceFirst(_TrackMetadataScreenState._lrcSpeakerPrefixPattern, ''); + cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim(); + + if (cleaned.isNotEmpty) { + cleanLines.add(cleaned); + } + } + + return cleanLines.join('\n'); + } + +} diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 039cee73..b60490c3 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -32,6 +32,10 @@ import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; part 'track_metadata_edit_sheet.dart'; +part 'track_metadata_cards.dart'; +part 'track_metadata_lyrics.dart'; +part 'track_metadata_convert.dart'; +part 'track_metadata_actions.dart'; final _log = AppLogger('TrackMetadata'); @@ -252,6 +256,10 @@ class _TrackMetadataScreenState extends ConsumerState { super.dispose(); } + /// setState is @protected, so the extension part files route rebuilds + /// through this forwarder. + void _setState(VoidCallback fn) => setState(fn); + void _onScroll() { final expandedHeight = _calculateExpandedHeight(context); final shouldShow = @@ -1230,2072 +1238,6 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - Widget _buildAnimatedTrackContent( - BuildContext context, - WidgetRef ref, - ColorScheme colorScheme, - ) { - final currentKey = ValueKey('metadata_content_$_itemId'); - return AnimatedSwitcher( - duration: const Duration(milliseconds: 240), - reverseDuration: const Duration(milliseconds: 180), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - layoutBuilder: (currentChild, previousChildren) { - return Stack( - alignment: Alignment.topCenter, - children: [...previousChildren, ?currentChild], - ); - }, - transitionBuilder: (child, animation) { - if (_metadataTransitionDirection == 0) { - return child; - } - final isIncoming = child.key == currentKey; - final direction = _metadataTransitionDirection.toDouble(); - final begin = Offset( - isIncoming ? 0.18 * direction : -0.18 * direction, - 0, - ); - final curved = CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ); - return ClipRect( - child: SlideTransition( - position: Tween( - begin: begin, - end: Offset.zero, - ).animate(curved), - child: FadeTransition(opacity: animation, child: child), - ), - ); - }, - child: Padding( - key: currentKey, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildMetadataCard(context, colorScheme, _fileSize), - - const SizedBox(height: 16), - - _buildFileInfoCard(context, colorScheme, _fileExists, _fileSize), - - const SizedBox(height: 16), - - _buildLyricsCard(context, colorScheme), - - if (_fileExists) ...[ - const SizedBox(height: 16), - AudioAnalysisCard(filePath: _filePath), - ], - - const SizedBox(height: 24), - - _buildActionButtons(context, ref, colorScheme, _fileExists), - - const SizedBox(height: 32), - ], - ), - ), - ); - } - - Widget _buildHeaderBackground( - BuildContext context, - ColorScheme colorScheme, - double expandedHeight, - bool showContent, - ) { - final cacheWidth = coverCacheWidthForViewport(context); - final coverChild = _hasPath(_embeddedCoverPreviewPath) - ? Image.file( - File(_embeddedCoverPreviewPath!), - fit: BoxFit.cover, - cacheWidth: cacheWidth, - gaplessPlayback: true, - filterQuality: FilterQuality.low, - errorBuilder: (_, _, _) => Container(color: colorScheme.surface), - ) - : _coverUrl != null - ? CachedCoverImage( - imageUrl: _coverUrl!, - fit: BoxFit.cover, - memCacheWidth: cacheWidth, - placeholder: (_, _) => Container(color: colorScheme.surface), - errorWidget: (_, _, _) => Container(color: colorScheme.surface), - ) - : _localCoverPath != null && _localCoverPath!.isNotEmpty - ? Image.file( - File(_localCoverPath!), - fit: BoxFit.cover, - cacheWidth: cacheWidth, - gaplessPlayback: true, - filterQuality: FilterQuality.low, - errorBuilder: (_, _, _) => Container(color: colorScheme.surface), - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - size: 80, - color: colorScheme.onSurfaceVariant, - ), - ); - - return Stack( - fit: StackFit.expand, - children: [ - Hero( - tag: _coverHeroTag, - child: Material(color: Colors.transparent, child: coverChild), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - height: expandedHeight * 0.65, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.85), - ], - ), - ), - ), - ), - Positioned( - left: 20, - right: 20, - bottom: 40, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: showContent ? 1.0 : 0.0, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - trackName, - style: const TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - height: 1.2, - ), - textAlign: TextAlign.center, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 6), - Text( - artistName, - style: const TextStyle( - color: Colors.white70, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - albumName, - style: const TextStyle(color: Colors.white54, fontSize: 14), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 12), - Wrap( - alignment: WrapAlignment.center, - spacing: 8, - runSpacing: 8, - children: [ - if (_displayAudioQuality != null && - _displayAudioQuality!.isNotEmpty) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - _displayAudioQuality!, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ), - if (duration != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - _formatDuration(duration!), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ), - if (_service != 'local') - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - _service[0].toUpperCase() + _service.substring(1), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ) - else - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.folder, - size: 14, - color: Colors.white, - ), - const SizedBox(width: 4), - Text( - context.l10n.librarySourceLocal, - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - if (_hasCheckedFile && !_fileExists) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.warning_rounded, - size: 14, - color: Colors.white, - ), - const SizedBox(width: 4), - Text( - context.l10n.trackFileNotFound, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - ], - ), - ], - ), - ), - ), - ], - ); - } - - RoundedRectangleBorder _sectionCardShape(ColorScheme colorScheme) { - return RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - side: BorderSide( - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), - ); - } - - Widget _buildMetadataCard( - BuildContext context, - ColorScheme colorScheme, - int? fileSize, - ) { - return Card( - elevation: 0, - color: settingsGroupColor(context), - shape: _sectionCardShape(colorScheme), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.info_outline, size: 20, color: colorScheme.primary), - const SizedBox(width: 8), - Text( - context.l10n.trackMetadata, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - ], - ), - const SizedBox(height: 16), - - _buildMetadataGrid(context, colorScheme), - - if (_spotifyId != null && _spotifyId!.isNotEmpty) ...[ - const SizedBox(height: 8), - Builder( - builder: (context) { - final openService = _serviceForTrackId( - _spotifyId!, - fallbackService: _service.toLowerCase(), - ); - String buttonLabel; - if (openService == 'deezer') { - buttonLabel = context.l10n.trackOpenInDeezer; - } else if (openService == 'amazon') { - buttonLabel = context.l10n.trackOpenInService( - 'Amazon Music', - ); - } else if (openService == 'tidal') { - buttonLabel = context.l10n.trackOpenInService('Tidal'); - } else if (openService == 'qobuz') { - buttonLabel = context.l10n.trackOpenInService('Qobuz'); - } else { - buttonLabel = context.l10n.trackOpenInSpotify; - } - return OutlinedButton.icon( - onPressed: () => _openServiceUrl(context), - icon: const Icon(Icons.open_in_new, size: 18), - label: Text(buttonLabel), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ); - }, - ), - ], - ], - ), - ), - ); - } - - Future _openServiceUrl(BuildContext context) async { - if (_spotifyId == null) return; - - final openService = _serviceForTrackId( - _spotifyId!, - fallbackService: _service.toLowerCase(), - ); - final rawId = _displayServiceTrackId(_spotifyId!); - - String webUrl; - Uri? appUri; - String serviceName; - - if (openService == 'deezer') { - webUrl = 'https://www.deezer.com/track/$rawId'; - appUri = Uri.parse('deezer://www.deezer.com/track/$rawId'); - serviceName = 'Deezer'; - } else if (openService == 'amazon') { - webUrl = 'https://music.amazon.com/search/$rawId'; - appUri = Uri.parse('amznm://search/$rawId'); - serviceName = 'Amazon Music'; - } else if (openService == 'tidal') { - webUrl = 'https://listen.tidal.com/track/$rawId'; - appUri = Uri.parse('tidal://track/$rawId'); - serviceName = 'Tidal'; - } else if (openService == 'qobuz') { - webUrl = 'https://play.qobuz.com/track/$rawId'; - appUri = Uri.parse('qobuz://track/$rawId'); - serviceName = 'Qobuz'; - } else { - webUrl = 'https://open.spotify.com/track/$rawId'; - appUri = Uri.parse('spotify:track:$rawId'); - serviceName = 'Spotify'; - } - - try { - final launched = await launchUrl( - appUri, - mode: LaunchMode.externalApplication, - ); - - if (!launched) { - await launchUrl( - Uri.parse(webUrl), - mode: LaunchMode.externalApplication, - ); - } - } catch (e) { - try { - await launchUrl( - Uri.parse(webUrl), - mode: LaunchMode.externalApplication, - ); - } catch (_) { - if (context.mounted) { - _copyToClipboard(context, webUrl); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarUrlCopied(serviceName)), - ), - ); - } - } - } - } - - Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) { - final audioQualityStr = _displayAudioQuality; - - final items = <_MetadataItem>[ - _MetadataItem(context.l10n.trackTrackName, trackName), - _MetadataItem(context.l10n.trackArtist, artistName), - if (albumArtist != null && albumArtist != artistName) - _MetadataItem(context.l10n.trackAlbumArtist, albumArtist!), - _MetadataItem(context.l10n.trackAlbum, albumName), - if (trackNumber != null && trackNumber! > 0) - _MetadataItem(context.l10n.trackTrackNumber, trackNumber.toString()), - if (totalTracks != null && totalTracks! > 0) - _MetadataItem( - context.l10n.editMetadataFieldTrackTotal, - totalTracks.toString(), - ), - if (discNumber != null && discNumber! > 0) - _MetadataItem(context.l10n.trackDiscNumber, discNumber.toString()), - if (totalDiscs != null && totalDiscs! > 0) - _MetadataItem( - context.l10n.editMetadataFieldDiscTotal, - totalDiscs.toString(), - ), - if (duration != null) - _MetadataItem(context.l10n.trackDuration, _formatDuration(duration!)), - if (audioQualityStr != null) - _MetadataItem(context.l10n.trackAudioQuality, audioQualityStr), - if (releaseDate != null && releaseDate!.isNotEmpty) - _MetadataItem(context.l10n.trackReleaseDate, releaseDate!), - if (genre != null && genre!.isNotEmpty) - _MetadataItem(context.l10n.trackGenre, genre!), - if (label != null && label!.isNotEmpty) - _MetadataItem(context.l10n.trackLabel, label!), - if (copyright != null && copyright!.isNotEmpty) - _MetadataItem(context.l10n.trackCopyright, copyright!), - if (composer != null && composer!.isNotEmpty) - _MetadataItem(context.l10n.editMetadataFieldComposer, composer!), - if (isrc != null && isrc!.isNotEmpty) _MetadataItem('ISRC', isrc!), - ]; - - if (!_isLocalItem && _spotifyId != null && _spotifyId!.isNotEmpty) { - final idService = _serviceForTrackId( - _spotifyId!, - fallbackService: _service.toLowerCase(), - ); - final cleanId = _displayServiceTrackId(_spotifyId!); - String idLabel; - switch (idService) { - case 'deezer': - idLabel = 'Deezer ID'; - case 'amazon': - idLabel = 'Amazon ASIN'; - case 'tidal': - idLabel = 'Tidal ID'; - case 'qobuz': - idLabel = 'Qobuz ID'; - default: - idLabel = 'Spotify ID'; - } - items.add(_MetadataItem(idLabel, cleanId)); - } - - items.add( - _MetadataItem(context.l10n.trackMetadataService, _service.toUpperCase()), - ); - items.add( - _MetadataItem(context.l10n.trackDownloaded, _formatFullDate(_addedAt)), - ); - - return Column( - children: items.map((metadata) { - final isCopyable = - metadata.label == 'ISRC' || - metadata.label == 'Spotify ID' || - metadata.label == 'Deezer ID' || - metadata.label == 'Amazon ASIN' || - metadata.label == 'Tidal ID' || - metadata.label == 'Qobuz ID'; - return InkWell( - onTap: isCopyable - ? () => _copyToClipboard(context, metadata.value) - : null, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 100, - child: Text( - metadata.label, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - metadata.value, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurface, - ), - ), - ), - if (isCopyable) - Icon( - Icons.copy, - size: 14, - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - ], - ), - ), - ); - }).toList(), - ); - } - - String _formatDuration(int seconds) { - final minutes = seconds ~/ 60; - final secs = seconds % 60; - return '$minutes:${secs.toString().padLeft(2, '0')}'; - } - - String _formatLabelForRaw(String raw) { - final normalized = raw.toLowerCase().replaceAll('-', '_'); - return switch (normalized) { - 'flac' => 'FLAC', - 'alac' => 'ALAC', - 'wav' || 'wave' => 'WAV', - 'aiff' || 'aif' || 'aifc' => 'AIFF', - 'eac3' || 'ec_3' => 'EAC3', - 'ac3' || 'ac_3' => 'AC3', - 'ac4' || 'ac_4' => 'AC4', - 'aac' || 'mp4a' => 'AAC', - 'm4a' || 'mp4' => 'M4A', - 'mp3' => 'MP3', - 'opus' => 'Opus', - 'ogg' => 'OGG', - _ => raw.toUpperCase(), - }; - } - - String _displayFormatLabelForFile(String fileName) { - final storedFormat = normalizeOptionalString(_storedAudioFormat); - final raw = - storedFormat ?? - (fileName.contains('.') ? fileName.split('.').last : 'Unknown'); - return _formatLabelForRaw(raw); - } - - bool _isBitrateFormatLabel(String label) { - return const { - 'MP3', - 'OPUS', - 'OGG', - 'M4A', - 'AAC', - 'EAC3', - 'AC3', - 'AC4', - }.contains(label.toUpperCase()); - } - - Widget _buildFileInfoCard( - BuildContext context, - ColorScheme colorScheme, - bool fileExists, - int? fileSize, - ) { - final displayFilePath = _formatPathForDisplay(rawFilePath); - final fileName = _extractFileNameFromPathOrUri(rawFilePath); - final fileExtension = _displayFormatLabelForFile(fileName); - final resolvedQuality = _displayAudioQuality; - final lossyBitrateLabel = _extractLossyBitrateLabel(resolvedQuality); - - return Card( - elevation: 0, - color: settingsGroupColor(context), - shape: _sectionCardShape(colorScheme), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.folder_outlined, - size: 20, - color: colorScheme.primary, - ), - const SizedBox(width: 8), - Text( - context.l10n.trackFileInfo, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - ], - ), - const SizedBox(height: 16), - - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - fileExtension, - style: TextStyle( - color: colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ), - if (fileSize != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - _formatFileSize(fileSize), - style: TextStyle( - color: colorScheme.onSecondaryContainer, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ), - if (_isBitrateFormatLabel(fileExtension) && - lossyBitrateLabel != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - lossyBitrateLabel, - style: TextStyle( - color: colorScheme.onTertiaryContainer, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ) - else if (_audioBitrate != null && - _audioBitrate! > 0 && - _isBitrateFormatLabel(fileExtension)) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - '${_audioBitrate}kbps', - style: TextStyle( - color: colorScheme.onTertiaryContainer, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ) - else if (bitDepth != null && - bitDepth! > 0 && - sampleRate != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - buildDisplayAudioQuality( - bitDepth: bitDepth, - sampleRate: sampleRate, - ) ?? - '', - style: TextStyle( - color: colorScheme.onTertiaryContainer, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: _getServiceColor(_service, colorScheme), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _getServiceIcon(_service), - size: 14, - color: Colors.white, - ), - const SizedBox(width: 4), - Text( - _service.toUpperCase(), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - ], - ), - - const SizedBox(height: 16), - - InkWell( - onTap: () => _copyToClipboard(context, cleanFilePath), - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Expanded( - child: Text( - displayFilePath, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: colorScheme.onSurfaceVariant, - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - Icon( - Icons.copy, - size: 18, - color: colorScheme.onSurfaceVariant, - ), - ], - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildLyricsCard(BuildContext context, ColorScheme colorScheme) { - return Card( - elevation: 0, - color: settingsGroupColor(context), - shape: _sectionCardShape(colorScheme), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.lyrics_outlined, - size: 20, - color: colorScheme.primary, - ), - const SizedBox(width: 8), - Text( - context.l10n.trackLyrics, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - const Spacer(), - if (_lyrics != null) - IconButton( - icon: const Icon(Icons.copy, size: 20), - onPressed: () => _copyToClipboard(context, _lyrics!), - tooltip: context.l10n.trackCopyLyrics, - ), - ], - ), - if (_lyricsSource != null && _lyricsSource!.trim().isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - context.l10n.trackLyricsSource(_lyricsSource!), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - const SizedBox(height: 12), - - if (_lyricsLoading) - const Center( - child: Padding( - padding: EdgeInsets.all(20), - child: CircularProgressIndicator(), - ), - ) - else if (_lyricsError != null) - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.errorContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon( - Icons.error_outline, - color: colorScheme.error, - size: 20, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - _lyricsError!, - style: TextStyle(color: colorScheme.onErrorContainer), - ), - ), - TextButton( - onPressed: _fetchOnlineLyrics, - child: Text(context.l10n.dialogRetry), - ), - ], - ), - ) - else if (_isInstrumental) - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.music_note, - color: colorScheme.tertiary, - size: 20, - ), - const SizedBox(width: 12), - Text( - context.l10n.trackInstrumental, - style: TextStyle( - color: colorScheme.onTertiaryContainer, - fontStyle: FontStyle.italic, - ), - ), - ], - ), - ) - else if (_lyrics != null) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - constraints: const BoxConstraints(maxHeight: 300), - child: SingleChildScrollView( - child: Text( - _lyrics!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurface, - height: 1.6, - ), - ), - ), - ), - if (!_lyricsEmbedded && _fileExists) ...[ - const SizedBox(height: 16), - Center( - child: FilledButton.tonalIcon( - onPressed: _isEmbedding ? null : _embedLyrics, - icon: _isEmbedding - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.save_alt), - label: Text(context.l10n.trackEmbedLyrics), - ), - ), - ], - ], - ) - else if (_embeddedLyricsChecked && _fileExists) - Column( - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues( - alpha: 0.5, - ), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon( - Icons.lyrics_outlined, - color: colorScheme.onSurfaceVariant, - size: 20, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - context.l10n.trackLyricsNotInFile, - style: TextStyle( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 12), - Center( - child: FilledButton.tonalIcon( - onPressed: _fetchOnlineLyrics, - icon: const Icon(Icons.cloud_download_outlined), - label: Text(context.l10n.trackFetchOnlineLyrics), - ), - ), - ], - ) - else - Center( - child: FilledButton.tonalIcon( - onPressed: _fetchLyrics, - icon: const Icon(Icons.download), - label: Text(context.l10n.trackLoadLyrics), - ), - ), - ], - ), - ), - ); - } - - /// Check for lyrics embedded in the audio file only (no network requests). - /// Called automatically when the screen opens. - Future _checkEmbeddedLyrics() async { - if (_lyricsLoading || !_fileExists) return; - final generation = _metadataLoadGeneration; - final sourcePath = cleanFilePath; - if (!mounted) return; - - setState(() { - _lyricsLoading = true; - _lyricsError = null; - _isInstrumental = false; - _lyricsSource = null; - }); - - try { - final embeddedResult = - await PlatformBridge.getLyricsLRCWithSource( - '', - trackName, - artistName, - filePath: sourcePath, - durationMs: 0, - ).timeout( - const Duration(seconds: 5), - onTimeout: () => {'lyrics': '', 'source': ''}, - ); - - final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? ''; - final embeddedSource = embeddedResult['source']?.toString() ?? ''; - - if (mounted && - generation == _metadataLoadGeneration && - sourcePath == cleanFilePath) { - if (embeddedLyrics.isNotEmpty) { - final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics); - setState(() { - _lyrics = cleanLyrics; - _rawLyrics = embeddedLyrics; - _lyricsSource = embeddedSource.isNotEmpty - ? embeddedSource - : context.l10n.trackLyricsEmbeddedSource; - _lyricsEmbedded = true; - _lyricsLoading = false; - _embeddedLyricsChecked = true; - }); - } else { - setState(() { - _lyricsLoading = false; - _embeddedLyricsChecked = true; - }); - } - } - } catch (e) { - if (mounted && - generation == _metadataLoadGeneration && - sourcePath == cleanFilePath) { - setState(() { - _lyricsLoading = false; - _embeddedLyricsChecked = true; - }); - } - } - } - - /// Fetch lyrics from online providers. Only called by user action. - Future _fetchOnlineLyrics() async { - if (_lyricsLoading) return; - - setState(() { - _lyricsLoading = true; - _lyricsError = null; - _isInstrumental = false; - _lyricsSource = null; - }); - - try { - final durationMs = (duration ?? 0) * 1000; - - final result = await PlatformBridge.getLyricsLRCWithSource( - _spotifyId ?? '', - trackName, - artistName, - filePath: null, - durationMs: durationMs, - ).timeout(const Duration(seconds: 20)); - - final lrcText = result['lyrics']?.toString() ?? ''; - final source = result['source']?.toString() ?? ''; - final instrumental = - (result['instrumental'] as bool? ?? false) || - lrcText == '[instrumental:true]'; - - if (mounted) { - if (instrumental) { - setState(() { - _isInstrumental = true; - _lyricsSource = source.isNotEmpty ? source : null; - _lyricsLoading = false; - }); - } else if (lrcText.isEmpty) { - setState(() { - _lyricsError = context.l10n.trackLyricsNotAvailable; - _lyricsLoading = false; - }); - } else { - final cleanLyrics = _cleanLrcForDisplay(lrcText); - setState(() { - _lyrics = cleanLyrics; - _rawLyrics = lrcText; - _lyricsSource = source.isNotEmpty ? source : null; - _lyricsEmbedded = false; - _lyricsLoading = false; - }); - } - } - } on TimeoutException { - if (mounted) { - setState(() { - _lyricsError = context.l10n.trackLyricsTimeout; - _lyricsLoading = false; - }); - } - } catch (e) { - if (mounted) { - setState(() { - _lyricsError = context.l10n.trackLyricsLoadFailed; - _lyricsLoading = false; - }); - } - } - } - - /// Full lyrics fetch: check embedded first, then online. - /// Used by the "Load Lyrics" button when file doesn't exist (non-local items). - Future _fetchLyrics() async { - if (_lyricsLoading) return; - - setState(() { - _lyricsLoading = true; - _lyricsError = null; - _isInstrumental = false; - _lyricsSource = null; - }); - - try { - final durationMs = (duration ?? 0) * 1000; - - if (_fileExists) { - final embeddedResult = - await PlatformBridge.getLyricsLRCWithSource( - '', - trackName, - artistName, - filePath: cleanFilePath, - durationMs: 0, - ).timeout( - const Duration(seconds: 5), - onTimeout: () => {'lyrics': '', 'source': ''}, - ); - - final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? ''; - final embeddedSource = embeddedResult['source']?.toString() ?? ''; - - if (embeddedLyrics.isNotEmpty) { - if (mounted) { - final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics); - setState(() { - _lyrics = cleanLyrics; - _rawLyrics = embeddedLyrics; - _lyricsSource = embeddedSource.isNotEmpty - ? embeddedSource - : context.l10n.trackLyricsEmbeddedSource; - _lyricsEmbedded = true; - _lyricsLoading = false; - _embeddedLyricsChecked = true; - }); - } - return; - } - } - - final result = await PlatformBridge.getLyricsLRCWithSource( - _spotifyId ?? '', - trackName, - artistName, - filePath: null, - durationMs: durationMs, - ).timeout(const Duration(seconds: 20)); - - final lrcText = result['lyrics']?.toString() ?? ''; - final source = result['source']?.toString() ?? ''; - final instrumental = - (result['instrumental'] as bool? ?? false) || - lrcText == '[instrumental:true]'; - - if (mounted) { - if (instrumental) { - setState(() { - _isInstrumental = true; - _lyricsSource = source.isNotEmpty ? source : null; - _lyricsLoading = false; - }); - } else if (lrcText.isEmpty) { - setState(() { - _lyricsError = context.l10n.trackLyricsNotAvailable; - _lyricsLoading = false; - }); - } else { - final cleanLyrics = _cleanLrcForDisplay(lrcText); - setState(() { - _lyrics = cleanLyrics; - _rawLyrics = lrcText; - _lyricsSource = source.isNotEmpty ? source : null; - _lyricsEmbedded = false; - _lyricsLoading = false; - }); - } - } - } on TimeoutException { - if (mounted) { - setState(() { - _lyricsError = context.l10n.trackLyricsTimeout; - _lyricsLoading = false; - }); - } - } catch (e) { - if (mounted) { - setState(() { - _lyricsError = context.l10n.trackLyricsLoadFailed; - _lyricsLoading = false; - }); - } - } - } - - Future _embedLyrics() async { - if (_isEmbedding || _rawLyrics == null || !_fileExists) return; - - setState(() => _isEmbedding = true); - - final l10nFailedToWriteStorage = context.l10n.snackbarFailedToWriteStorage; - final l10nFailedToEmbedLyrics = context.l10n.snackbarFailedToEmbedLyrics; - final l10nUnsupportedFormat = context.l10n.snackbarUnsupportedAudioFormat; - - String? safTempPath; - String? coverPath; - - try { - final rawLyrics = _rawLyrics!; - var workingPath = cleanFilePath; - - if (_isSafFile) { - safTempPath = await PlatformBridge.copyContentUriToTemp(cleanFilePath); - if (safTempPath == null || safTempPath.isEmpty) { - throw Exception('Failed to access SAF file'); - } - workingPath = safTempPath; - } - - final lower = workingPath.toLowerCase(); - final isFlac = lower.endsWith('.flac'); - final isMp3 = lower.endsWith('.mp3'); - final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg'); - final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac'); - - bool success = false; - String? error; - - if (isFlac) { - final result = await PlatformBridge.embedLyricsToFile( - workingPath, - rawLyrics, - ); - if (result['success'] == true) { - if (_isSafFile) { - final ok = await PlatformBridge.writeTempToSaf( - workingPath, - cleanFilePath, - ); - success = ok; - if (!ok) { - error = l10nFailedToWriteStorage; - } - } else { - success = true; - } - } else { - error = result['error']?.toString() ?? l10nFailedToEmbedLyrics; - } - } else if (isMp3 || isOpus || isM4A) { - final metadata = _buildFallbackMetadata(); - try { - final result = await PlatformBridge.readFileMetadata(workingPath); - if (result['error'] == null) { - final mapped = _mapMetadataForTagEmbed(result); - metadata.addAll(mapped); - } - } catch (e) { - _log.w('Failed reading file metadata before lyrics embed: $e'); - } - - metadata['LYRICS'] = rawLyrics; - metadata['UNSYNCEDLYRICS'] = rawLyrics; - - try { - final tempDir = await getTemporaryDirectory(); - final coverOutput = - '${tempDir.path}${Platform.pathSeparator}lyrics_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; - final coverResult = await PlatformBridge.extractCoverToFile( - workingPath, - coverOutput, - ); - if (coverResult['error'] == null) { - coverPath = coverOutput; - } - } catch (_) {} - - final artistTagMode = ref.read(settingsProvider).artistTagMode; - String? ffmpegResult; - if (isMp3) { - ffmpegResult = await FFmpegService.embedMetadataToMp3( - mp3Path: workingPath, - coverPath: coverPath, - metadata: metadata, - ); - } else if (isM4A) { - ffmpegResult = await FFmpegService.embedMetadataToM4a( - m4aPath: workingPath, - coverPath: coverPath, - metadata: metadata, - ); - } else { - ffmpegResult = await FFmpegService.embedMetadataToOpus( - opusPath: workingPath, - coverPath: coverPath, - metadata: metadata, - artistTagMode: artistTagMode, - ); - } - - if (ffmpegResult == null) { - error = l10nFailedToEmbedLyrics; - } else if (_isSafFile) { - final ok = await PlatformBridge.writeTempToSaf( - ffmpegResult, - cleanFilePath, - ); - success = ok; - if (!ok) { - error = l10nFailedToWriteStorage; - } - } else { - success = true; - } - } else { - error = l10nUnsupportedFormat; - } - - if (mounted) { - if (success) { - setState(() { - _lyricsEmbedded = true; - _isEmbedding = false; - }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackLyricsEmbedded)), - ); - } else { - setState(() => _isEmbedding = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(error ?? context.l10n.snackbarFailedToEmbedLyrics), - ), - ); - } - } - } catch (e) { - if (mounted) { - setState(() => _isEmbedding = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), - ); - } - } finally { - if (coverPath != null) { - try { - await File(coverPath).delete(); - } catch (_) {} - } - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - } - } - - String _sanitizeFileNameSegment(String value) { - var sanitized = value.replaceAll(_invalidFileNameChars, '_').trim(); - sanitized = sanitized.replaceAll(_leadingOrTrailingDots, ''); - sanitized = sanitized.replaceAll(_multiUnderscore, '_'); - if (sanitized.isEmpty) { - return 'untitled'; - } - return sanitized; - } - - String _buildSaveBaseName() { - final artist = _sanitizeFileNameSegment(artistName); - final track = _sanitizeFileNameSegment(trackName); - return '$artist - $track'; - } - - String _getFileDirectory() { - if (isContentUri(cleanFilePath)) { - // SAF URIs don't have a filesystem parent directory - return ''; - } - final file = File(cleanFilePath); - return file.parent.path; - } - - bool get _isSafFile => isContentUri(cleanFilePath); - - Future _saveCoverArt() async { - try { - final baseName = _buildSaveBaseName(); - - if (_isSafFile) { - final tempDir = await Directory.systemTemp.createTemp('cover_'); - final tempOutput = - '${tempDir.path}${Platform.pathSeparator}$baseName.jpg'; - - Map result; - if (_fileExists) { - // Prefer extracting cover from the already-downloaded file to avoid - // a redundant network request. - result = await PlatformBridge.extractCoverToFile( - cleanFilePath, - tempOutput, - ); - if (result['error'] != null && - _coverUrl != null && - _coverUrl!.isNotEmpty) { - result = await PlatformBridge.downloadCoverToFile( - _coverUrl!, - tempOutput, - maxQuality: true, - ); - } - } else if (_coverUrl != null && _coverUrl!.isNotEmpty) { - result = await PlatformBridge.downloadCoverToFile( - _coverUrl!, - tempOutput, - maxQuality: true, - ); - } else { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackCoverNoSource)), - ); - } - return; - } - - if (result['error'] != null) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.trackSaveFailed(result['error'].toString()), - ), - ), - ); - } - try { - await Directory(tempDir.path).delete(recursive: true); - } catch (_) {} - return; - } - - final treeUri = _downloadItem?.downloadTreeUri; - final relativeDir = _downloadItem?.safRelativeDir ?? ''; - if (treeUri != null && treeUri.isNotEmpty) { - final safUri = await PlatformBridge.createSafFileFromPath( - treeUri: treeUri, - relativeDir: relativeDir, - fileName: '$baseName.jpg', - mimeType: 'image/jpeg', - srcPath: tempOutput, - ); - try { - await Directory(tempDir.path).delete(recursive: true); - } catch (_) {} - if (mounted) { - if (safUri != null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackCoverSaved(baseName))), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.trackSaveFailed('Failed to write to storage'), - ), - ), - ); - } - } - } else { - try { - await Directory(tempDir.path).delete(recursive: true); - } catch (_) {} - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.trackSaveFailed('No storage access'), - ), - ), - ); - } - } - return; - } - - final dir = _getFileDirectory(); - final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg'; - - Map result; - if (_fileExists) { - result = await PlatformBridge.extractCoverToFile( - cleanFilePath, - outputPath, - ); - if (result['error'] != null && - _coverUrl != null && - _coverUrl!.isNotEmpty) { - result = await PlatformBridge.downloadCoverToFile( - _coverUrl!, - outputPath, - maxQuality: true, - ); - } - } else if (_coverUrl != null && _coverUrl!.isNotEmpty) { - result = await PlatformBridge.downloadCoverToFile( - _coverUrl!, - outputPath, - maxQuality: true, - ); - } else { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackCoverNoSource)), - ); - } - return; - } - - if (mounted) { - if (result['error'] != null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.trackSaveFailed(result['error'].toString()), - ), - ), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackCoverSaved(baseName))), - ); - } - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))), - ); - } - } - } - - Future _saveLyrics() async { - try { - final baseName = _buildSaveBaseName(); - final durationMs = (duration ?? 0) * 1000; - if (mounted) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveLyricsProgress)), - ); - } - - if (_isSafFile) { - final tempDir = await Directory.systemTemp.createTemp('lyrics_'); - final tempOutput = - '${tempDir.path}${Platform.pathSeparator}$baseName.lrc'; - - final result = await PlatformBridge.fetchAndSaveLyrics( - trackName: trackName, - artistName: artistName, - spotifyId: _spotifyId ?? '', - durationMs: durationMs, - outputPath: tempOutput, - audioFilePath: _fileExists ? cleanFilePath : '', - ); - - if (result['error'] != null) { - if (mounted) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text( - context.l10n.trackSaveFailed(result['error'].toString()), - ), - ), - ); - } - try { - await Directory(tempDir.path).delete(recursive: true); - } catch (_) {} - return; - } - - final treeUri = _downloadItem?.downloadTreeUri; - final relativeDir = _downloadItem?.safRelativeDir ?? ''; - if (treeUri != null && treeUri.isNotEmpty) { - final safUri = await PlatformBridge.createSafFileFromPath( - treeUri: treeUri, - relativeDir: relativeDir, - fileName: '$baseName.lrc', - mimeType: 'application/octet-stream', - srcPath: tempOutput, - ); - try { - await Directory(tempDir.path).delete(recursive: true); - } catch (_) {} - if (mounted) { - if (safUri != null) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text(context.l10n.trackLyricsSaved(baseName)), - ), - ); - } else { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text( - context.l10n.trackSaveFailed( - 'Failed to write to storage', - ), - ), - ), - ); - } - } - } else { - try { - await Directory(tempDir.path).delete(recursive: true); - } catch (_) {} - if (mounted) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text( - context.l10n.trackSaveFailed('No storage access'), - ), - ), - ); - } - } - return; - } - - final dir = _getFileDirectory(); - final outputPath = '$dir${Platform.pathSeparator}$baseName.lrc'; - - final result = await PlatformBridge.fetchAndSaveLyrics( - trackName: trackName, - artistName: artistName, - spotifyId: _spotifyId ?? '', - durationMs: durationMs, - outputPath: outputPath, - audioFilePath: _fileExists ? cleanFilePath : '', - ); - - if (mounted) { - if (result['error'] != null) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text( - context.l10n.trackSaveFailed(result['error'].toString()), - ), - ), - ); - } else { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar(content: Text(context.l10n.trackLyricsSaved(baseName))), - ); - } - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))), - ); - } - } - } - - Future _reEnrichMetadata() async { - if (!_fileExists) return; - - try { - final settings = ref.read(settingsProvider); - final artistTagMode = settings.artistTagMode; - final messenger = ScaffoldMessenger.of(context); - final l10n = context.l10n; - await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend(); - if (!mounted) return; - messenger.showSnackBar( - SnackBar(content: Text(l10n.trackReEnrichSearching)), - ); - - final durationMs = (duration ?? 0) * 1000; - final request = { - 'file_path': cleanFilePath, - 'cover_url': _coverUrl ?? '', - 'max_quality': true, - 'embed_lyrics': settings.embedLyrics, - 'lyrics_mode': settings.lyricsMode, - 'artist_tag_mode': artistTagMode, - 'spotify_id': _spotifyId ?? '', - 'track_name': trackName, - 'artist_name': artistName, - 'album_name': albumName, - 'album_artist': albumArtist ?? '', - 'track_number': trackNumber ?? 0, - 'total_tracks': totalTracks ?? 0, - 'disc_number': discNumber ?? 0, - 'total_discs': totalDiscs ?? 0, - 'release_date': releaseDate ?? '', - 'isrc': isrc ?? '', - 'genre': genre ?? '', - 'label': label ?? '', - 'copyright': copyright ?? '', - 'composer': composer ?? '', - 'duration_ms': durationMs, - 'search_online': true, - }; - - final result = await PlatformBridge.reEnrichFile(request); - final method = result['method'] as String?; - - final enriched = result['enriched_metadata'] as Map?; - if (enriched != null && mounted) { - setState(() { - _editedMetadata = { - 'title': enriched['track_name'] ?? trackName, - 'artist': enriched['artist_name'] ?? artistName, - 'album': enriched['album_name'] ?? albumName, - 'album_artist': enriched['album_artist'] ?? albumArtist, - 'date': enriched['release_date'] ?? releaseDate, - 'track_number': enriched['track_number'] ?? trackNumber, - 'total_tracks': enriched['total_tracks'] ?? totalTracks, - 'disc_number': enriched['disc_number'] ?? discNumber, - 'total_discs': enriched['total_discs'] ?? totalDiscs, - 'isrc': enriched['isrc'] ?? isrc, - 'genre': enriched['genre'] ?? genre, - 'label': enriched['label'] ?? label, - 'copyright': enriched['copyright'] ?? copyright, - 'composer': enriched['composer'] ?? composer, - }; - }); - } - - if (method == 'native') { - // FLAC - handled natively by Go (SAF write-back handled in Kotlin). - // External .lrc sidecar for filesystem files is written here; SAF - // sidecars are created natively in the Kotlin reEnrichFile handler. - await writeReEnrichSidecarLrc( - audioFilePath: cleanFilePath, - reEnrichResult: result, - ); - await _refreshEmbeddedCoverPreview(force: true); - _markMetadataChanged(); - await _syncDownloadHistoryMetadata(); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackReEnrichSuccess)), - ); - } - } else if (method == 'ffmpeg') { - // MP3/Opus - need FFmpeg from Dart side - // For SAF files, Kotlin returns temp_path + saf_uri - final tempPath = result['temp_path'] as String?; - final safUri = result['saf_uri'] as String?; - final ffmpegTarget = tempPath ?? cleanFilePath; - - final downloadedCoverPath = result['cover_path'] as String?; - String? effectiveCoverPath = downloadedCoverPath; - String? extractedCoverPath; - if (!_hasPath(effectiveCoverPath)) { - try { - final tempDir = await Directory.systemTemp.createTemp( - 'reenrich_cover_', - ); - final coverOutput = - '${tempDir.path}${Platform.pathSeparator}cover.jpg'; - final extracted = await PlatformBridge.extractCoverToFile( - ffmpegTarget, - coverOutput, - ); - if (extracted['error'] == null) { - effectiveCoverPath = coverOutput; - extractedCoverPath = coverOutput; - } else { - try { - await tempDir.delete(recursive: true); - } catch (_) {} - } - } catch (_) {} - } - final metadata = (result['metadata'] as Map?)?.map( - (k, v) => MapEntry(k, v.toString()), - ); - final lower = cleanFilePath.toLowerCase(); - - String? ffmpegResult; - if (lower.endsWith('.mp3')) { - ffmpegResult = await FFmpegService.embedMetadataToMp3( - mp3Path: ffmpegTarget, - coverPath: effectiveCoverPath, - metadata: metadata, - ); - } else if (lower.endsWith('.m4a') || lower.endsWith('.aac')) { - ffmpegResult = await FFmpegService.embedMetadataToM4a( - m4aPath: ffmpegTarget, - coverPath: effectiveCoverPath, - metadata: metadata, - ); - } else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) { - ffmpegResult = await FFmpegService.embedMetadataToOpus( - opusPath: ffmpegTarget, - coverPath: effectiveCoverPath, - metadata: metadata, - artistTagMode: artistTagMode, - ); - } - - if (ffmpegResult != null && tempPath != null && safUri != null) { - final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); - if (!ok && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.trackSaveFailed( - context.l10n.snackbarFailedToWriteStorage, - ), - ), - ), - ); - if (_hasPath(downloadedCoverPath)) { - try { - await File(downloadedCoverPath!).delete(); - } catch (_) {} - } - if (_hasPath(extractedCoverPath)) { - await _cleanupTempFileAndParent(extractedCoverPath); - } - if (tempPath.isNotEmpty) { - try { - await File(tempPath).delete(); - } catch (_) {} - } - return; - } - await writeReEnrichSafSidecarLrc( - safUri: safUri, - reEnrichResult: result, - ); - } - - if (tempPath != null && tempPath.isNotEmpty) { - try { - await File(tempPath).delete(); - } catch (_) {} - } - - if (ffmpegResult != null) { - // Filesystem .lrc sidecar. SAF sidecar is written only after - // writeTempToSaf succeeds. - await writeReEnrichSidecarLrc( - audioFilePath: cleanFilePath, - reEnrichResult: result, - ); - await _refreshEmbeddedCoverPreview(force: true); - _markMetadataChanged(); - await _syncDownloadHistoryMetadata(); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackReEnrichSuccess)), - ); - } - } else if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackReEnrichFfmpegFailed)), - ); - } - - if (_hasPath(downloadedCoverPath)) { - try { - await File(downloadedCoverPath!).delete(); - } catch (_) {} - } - if (_hasPath(extractedCoverPath)) { - await _cleanupTempFileAndParent(extractedCoverPath); - } - } else { - if (mounted) { - final error = result['error']?.toString() ?? 'Unknown error'; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed(error))), - ); - } - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))), - ); - } - } - } - - Future _syncDownloadHistoryMetadata() async { - if (_isLocalItem || _downloadItem == null) return; - - String? normalizedOrNull(String? value) { - if (value == null) return null; - final trimmed = value.trim(); - if (trimmed.isEmpty) return null; - return trimmed; - } - - try { - await ref - .read(downloadHistoryProvider.notifier) - .updateMetadataForItem( - id: _downloadItem!.id, - trackName: trackName, - artistName: artistName, - albumName: albumName, - albumArtist: normalizedOrNull(albumArtist), - isrc: normalizedOrNull(isrc), - trackNumber: trackNumber, - totalTracks: totalTracks, - discNumber: discNumber, - totalDiscs: totalDiscs, - releaseDate: normalizedOrNull(releaseDate), - genre: normalizedOrNull(genre), - composer: normalizedOrNull(composer), - label: normalizedOrNull(label), - copyright: normalizedOrNull(copyright), - ); - } catch (e) { - _log.w('Failed to sync download history metadata: $e'); - } - } - - String _cleanLrcForDisplay(String lrc) { - final lines = lrc.split('\n'); - final cleanLines = []; - - for (final line in lines) { - var cleaned = line.trim(); - - if (_lrcMetadataPattern.hasMatch(cleaned) && - !_lrcBackgroundLinePattern.hasMatch(cleaned)) { - continue; - } - - // Convert [bg:...] wrapper to a plain secondary vocal line. - final bgMatch = _lrcBackgroundLinePattern.firstMatch(cleaned); - if (bgMatch != null) { - cleaned = bgMatch.group(1)?.trim() ?? ''; - } - - cleaned = cleaned.replaceAll(_lrcTimestampPattern, '').trim(); - cleaned = cleaned.replaceAll(_lrcInlineTimestampPattern, ''); - cleaned = cleaned.replaceFirst(_lrcSpeakerPrefixPattern, ''); - cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim(); - - if (cleaned.isNotEmpty) { - cleanLines.add(cleaned); - } - } - - return cleanLines.join('\n'); - } - Widget _buildActionButtons( BuildContext context, WidgetRef ref, @@ -3571,1843 +1513,6 @@ class _TrackMetadataScreenState extends ConsumerState { return placeholder(); } - /// Whether the current file format supports conversion - bool get _isConvertibleFormat { - final lower = cleanFilePath.toLowerCase(); - return lower.endsWith('.flac') || - lower.endsWith('.m4a') || - lower.endsWith('.aac') || - lower.endsWith('.wav') || - lower.endsWith('.aiff') || - lower.endsWith('.aif') || - lower.endsWith('.mp3') || - lower.endsWith('.opus') || - lower.endsWith('.ogg'); - } - - /// Whether the current file is a CUE sheet (or CUE-referenced) - bool get _isCueFile { - if (isCueVirtualPath(rawFilePath)) return true; - final lower = cleanFilePath.toLowerCase(); - if (lower.endsWith('.cue')) return true; - if (_isLocalItem && _localLibraryItem != null) { - final format = _localLibraryItem!.format ?? ''; - if (format.startsWith('cue+')) return true; - } - return false; - } - - String get _currentFileFormat { - // For CUE tracks, use the format from the library item (e.g. "cue+flac") - if (_isCueFile && _isLocalItem && _localLibraryItem != null) { - final format = _localLibraryItem!.format ?? ''; - if (format.startsWith('cue+')) { - final audioFmt = format.substring(4).toUpperCase(); - return 'CUE+$audioFmt'; - } - } - if (_isLocalItem && _localLibraryItem != null) { - final format = normalizeOptionalString( - _localLibraryItem!.format, - )?.toLowerCase().replaceAll('-', '_'); - switch (format) { - case 'flac': - return 'FLAC'; - case 'alac': - return 'ALAC'; - case 'm4a': - return 'M4A'; - case 'aac': - case 'mp4a': - return 'AAC'; - case 'mp3': - return 'MP3'; - case 'opus': - case 'ogg': - return 'Opus'; - case 'wav': - case 'wave': - return 'WAV'; - case 'aiff': - case 'aif': - case 'aifc': - return 'AIFF'; - } - } - final lower = cleanFilePath.toLowerCase(); - if (lower.endsWith('.flac')) return 'FLAC'; - if (lower.endsWith('.m4a')) return 'M4A'; - if (lower.endsWith('.aac')) return 'AAC'; - if (lower.endsWith('.wav')) return 'WAV'; - if (lower.endsWith('.aiff') || lower.endsWith('.aif')) return 'AIFF'; - if (lower.endsWith('.mp3')) return 'MP3'; - if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'Opus'; - if (lower.endsWith('.cue')) return 'CUE'; - return 'Unknown'; - } - - Map _buildFallbackMetadata() { - String formatIndexTag(int number, int? total) { - if (total != null && total > 0) { - return '$number/$total'; - } - return number.toString(); - } - - return { - 'TITLE': trackName, - 'ARTIST': artistName, - 'ALBUM': albumName, - if (albumArtist != null && albumArtist!.isNotEmpty) - 'ALBUMARTIST': albumArtist!, - if (trackNumber != null) - 'TRACKNUMBER': formatIndexTag(trackNumber!, totalTracks), - if (discNumber != null) - 'DISCNUMBER': formatIndexTag(discNumber!, totalDiscs), - if (releaseDate != null && releaseDate!.isNotEmpty) 'DATE': releaseDate!, - if (isrc != null && isrc!.isNotEmpty) 'ISRC': isrc!, - if (genre != null && genre!.isNotEmpty) 'GENRE': genre!, - if (label != null && label!.isNotEmpty) 'LABEL': label!, - if (copyright != null && copyright!.isNotEmpty) 'COPYRIGHT': copyright!, - if (composer != null && composer!.isNotEmpty) 'COMPOSER': composer!, - }; - } - - Map _mapMetadataForTagEmbed(Map source) { - final mapped = {}; - - void put(String key, dynamic value) { - final normalized = value?.toString().trim(); - if (normalized == null || normalized.isEmpty) return; - mapped[key] = normalized; - } - - put('TITLE', source['title']); - put('ARTIST', source['artist']); - put('ALBUM', source['album']); - put('ALBUMARTIST', source['album_artist']); - put('DATE', source['date']); - put('ISRC', source['isrc']); - put('GENRE', source['genre']); - put('ORGANIZATION', source['label']); - put('COPYRIGHT', source['copyright']); - put('COMPOSER', source['composer']); - put('COMMENT', source['comment']); - put('LYRICS', source['lyrics']); - put('UNSYNCEDLYRICS', source['lyrics']); - - final trackNumber = source['track_number']; - final totalTracks = source['total_tracks']; - if (trackNumber != null && trackNumber.toString() != '0') { - final trackTag = - totalTracks != null && - totalTracks.toString().isNotEmpty && - totalTracks.toString() != '0' - ? '${trackNumber.toString()}/${totalTracks.toString()}' - : trackNumber; - put('TRACKNUMBER', trackTag); - } - final discNumber = source['disc_number']; - final totalDiscs = source['total_discs']; - if (discNumber != null && discNumber.toString() != '0') { - final discTag = - totalDiscs != null && - totalDiscs.toString().isNotEmpty && - totalDiscs.toString() != '0' - ? '${discNumber.toString()}/${totalDiscs.toString()}' - : discNumber; - put('DISCNUMBER', discTag); - } - - return mapped; - } - - String? _extractLossyBitrateLabel(String? quality) { - if (quality == null || quality.isEmpty) return null; - final match = RegExp( - r'(\d+)\s*k(?:bps)?', - caseSensitive: false, - ).firstMatch(quality); - if (match == null) return null; - return '${match.group(1)}kbps'; - } - - String _extractFileNameFromPathOrUri(String pathOrUri) { - if (pathOrUri.isEmpty) return ''; - try { - if (pathOrUri.startsWith('content://')) { - final uri = Uri.parse(pathOrUri); - if (uri.pathSegments.isNotEmpty) { - var last = Uri.decodeComponent(uri.pathSegments.last); - if (last.contains('/')) { - last = last.split('/').last; - } - if (last.contains(':')) { - last = last.split(':').last; - } - if (last.isNotEmpty) return last; - } - } - } catch (_) {} - - final normalized = pathOrUri.replaceAll('\\', '/'); - if (normalized.contains('/')) { - return normalized.split('/').last; - } - return normalized; - } - - Future _rescanReplayGain() async { - if (!_fileExists) return; - final messenger = ScaffoldMessenger.of(context); - messenger.clearSnackBars(); - messenger.showSnackBar( - SnackBar( - content: Text(context.l10n.trackReplayGainScanning), - duration: const Duration(seconds: 30), - ), - ); - bool ok = false; - try { - ok = await ReplayGainService.applyToFile(cleanFilePath); - } catch (e) { - _log.w('ReplayGain rescan failed: $e'); - } - if (!mounted) return; - messenger.hideCurrentSnackBar(); - messenger.showSnackBar( - SnackBar( - content: Text( - ok - ? context.l10n.trackReplayGainSuccess - : context.l10n.trackReplayGainFailed, - ), - ), - ); - } - - void _showConvertSheet(BuildContext context) { - final currentFormat = _currentFileFormat; - final isLosslessSource = isLosslessConversionSource(currentFormat); - - final formats = []; - if (currentFormat == 'FLAC') { - formats.addAll(['ALAC', 'WAV', 'AIFF', 'AAC', 'MP3', 'Opus']); - } else if (currentFormat == 'ALAC') { - formats.addAll(['FLAC', 'WAV', 'AIFF', 'AAC', 'MP3', 'Opus']); - } else if (currentFormat == 'M4A') { - formats.addAll(['ALAC', 'FLAC', 'WAV', 'AIFF', 'AAC', 'MP3', 'Opus']); - } else if (currentFormat == 'WAV') { - formats.addAll(['FLAC', 'ALAC', 'AIFF', 'AAC', 'MP3', 'Opus']); - } else if (currentFormat == 'AIFF') { - formats.addAll(['FLAC', 'ALAC', 'WAV', 'AAC', 'MP3', 'Opus']); - } else if (currentFormat == 'AAC') { - formats.addAll(['MP3', 'Opus']); - } else if (currentFormat == 'MP3') { - formats.addAll(['AAC', 'Opus']); - } else if (currentFormat == 'Opus') { - formats.addAll(['AAC', 'MP3']); - } else { - formats.addAll(['AAC', 'MP3', 'Opus']); - } - - String selectedFormat = formats.first; - String defaultBitrateForFormat(String format) { - if (format == 'Opus') return '128k'; - if (format == 'AAC') return '256k'; - return '320k'; - } - - String selectedBitrate = defaultBitrateForFormat(selectedFormat); - bool isLosslessTarget = isLosslessConversionTarget(selectedFormat); - int? selectedMaxBitDepth; - int? selectedMaxSampleRate; - String selectedDither = 'none'; - String selectedResampler = 'swr'; - final bitDepthOptions = availableLosslessBitDepthOptions(bitDepth); - final sampleRateOptions = availableLosslessSampleRateOptions(sampleRate); - - showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (sheetContext) { - return StatefulBuilder( - builder: (context, setSheetState) { - final colorScheme = Theme.of(context).colorScheme; - final labels = context.l10n.losslessConversionLabels; - final bitrates = ['128k', '192k', '256k', '320k']; - - Widget card({required Widget child}) { - return Container( - width: double.infinity, - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: settingsGroupColor(context), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), - ), - child: child, - ); - } - - Widget sectionLabel(String text) { - return Padding( - padding: const EdgeInsets.only(left: 2, bottom: 12), - child: Text( - text, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - letterSpacing: 0.1, - ), - ), - ); - } - - Widget choice({ - required String label, - required bool selected, - required VoidCallback onTap, - }) { - return Material( - color: selected - ? colorScheme.primaryContainer - : colorScheme.surface, - borderRadius: BorderRadius.circular(14), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(14), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric( - horizontal: 18, - vertical: 11, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: selected - ? Colors.transparent - : colorScheme.outlineVariant.withValues(alpha: 0.6), - ), - ), - child: Text( - label, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: selected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurface, - fontWeight: selected - ? FontWeight.w600 - : FontWeight.w500, - ), - ), - ), - ), - ); - } - - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.viewInsetsOf(context).bottom, - ), - child: DraggableScrollableSheet( - initialChildSize: 0.85, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) => SafeArea( - child: SingleChildScrollView( - controller: scrollController, - padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.4, - ), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: 18), - Text( - context.l10n.trackConvertTitle, - style: Theme.of(context).textTheme.titleLarge - ?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - Text( - currentFormat, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - const SizedBox(height: 20), - - card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - sectionLabel( - context.l10n.trackConvertTargetFormat, - ), - Wrap( - spacing: 8, - runSpacing: 8, - children: formats.map((format) { - return choice( - label: format, - selected: format == selectedFormat, - onTap: () { - setSheetState(() { - selectedFormat = format; - isLosslessTarget = - isLosslessConversionTarget(format); - if (!isLosslessTarget) { - selectedBitrate = - defaultBitrateForFormat(format); - } else { - selectedMaxBitDepth = null; - selectedMaxSampleRate = null; - selectedDither = 'none'; - selectedResampler = 'swr'; - } - }); - }, - ); - }).toList(), - ), - ], - ), - ), - - if (!isLosslessTarget) - card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - sectionLabel(context.l10n.trackConvertBitrate), - Wrap( - spacing: 8, - runSpacing: 8, - children: bitrates.map((br) { - return choice( - label: br, - selected: br == selectedBitrate, - onTap: () => setSheetState( - () => selectedBitrate = br, - ), - ); - }).toList(), - ), - ], - ), - ), - - if (isLosslessTarget && bitDepthOptions.isNotEmpty) - card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - sectionLabel( - context.l10n.audioAnalysisBitDepth, - ), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - choice( - label: losslessBitDepthLabel( - null, - originalLabel: labels.original, - ), - selected: selectedMaxBitDepth == null, - onTap: () => setSheetState(() { - selectedMaxBitDepth = null; - selectedDither = 'none'; - }), - ), - ...bitDepthOptions.map((depth) { - return choice( - label: losslessBitDepthLabel( - depth, - originalLabel: labels.original, - ), - selected: depth == selectedMaxBitDepth, - onTap: () => setSheetState( - () => selectedMaxBitDepth = depth, - ), - ); - }), - ], - ), - ], - ), - ), - - if (isLosslessTarget && sampleRateOptions.isNotEmpty) - card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - sectionLabel( - context.l10n.audioAnalysisSampleRate, - ), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - choice( - label: losslessSampleRateLabel( - null, - originalLabel: labels.original, - ), - selected: selectedMaxSampleRate == null, - onTap: () => setSheetState(() { - selectedMaxSampleRate = null; - selectedResampler = 'swr'; - }), - ), - ...sampleRateOptions.map((rate) { - return choice( - label: losslessSampleRateLabel( - rate, - originalLabel: labels.original, - ), - selected: rate == selectedMaxSampleRate, - onTap: () => setSheetState( - () => selectedMaxSampleRate = rate, - ), - ); - }), - ], - ), - ], - ), - ), - - if (isLosslessTarget && selectedMaxBitDepth != null) - card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - sectionLabel( - context.l10n.trackConvertDithering, - ), - Wrap( - spacing: 8, - runSpacing: 8, - children: losslessDitherOptions.map((mode) { - return choice( - label: context.l10n - .losslessDitherOptionLabel(mode), - selected: mode == selectedDither, - onTap: () => setSheetState( - () => selectedDither = mode, - ), - ); - }).toList(), - ), - ], - ), - ), - - if (isLosslessTarget && selectedMaxSampleRate != null) - card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - sectionLabel( - context.l10n.trackConvertResampler, - ), - Wrap( - spacing: 8, - runSpacing: 8, - children: losslessResamplerOptions.map(( - mode, - ) { - return choice( - label: context.l10n - .losslessResamplerOptionLabel(mode), - selected: mode == selectedResampler, - onTap: () => setSheetState( - () => selectedResampler = mode, - ), - ); - }).toList(), - ), - ], - ), - ), - - if (isLosslessTarget && isLosslessSource) - Container( - width: double.infinity, - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues( - alpha: 0.4, - ), - borderRadius: BorderRadius.circular(14), - ), - child: Row( - children: [ - Icon( - Icons.verified, - size: 18, - color: colorScheme.primary, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - selectedMaxBitDepth == null && - selectedMaxSampleRate == null - ? context.l10n.trackConvertLosslessHint - : context.l10n - .trackConvertLosslessOutputWithCap( - losslessQualityLabel( - LosslessConversionQuality( - maxBitDepth: - selectedMaxBitDepth, - maxSampleRate: - selectedMaxSampleRate, - ), - originalLabel: - labels.original, - originalQualityLabel: - labels.originalQuality, - ), - ), - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.primary), - ), - ), - ], - ), - ), - - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: () { - Navigator.pop(context); - _confirmAndConvert( - context: this.context, - sourceFormat: currentFormat, - targetFormat: selectedFormat, - bitrate: selectedBitrate, - losslessQuality: LosslessConversionQuality( - maxBitDepth: selectedMaxBitDepth, - maxSampleRate: selectedMaxSampleRate, - ), - losslessProcessing: - LosslessConversionProcessing( - dither: selectedDither, - resampler: selectedResampler, - ), - ); - }, - icon: const Icon(Icons.swap_horiz), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - label: Text( - isLosslessTarget - ? context.l10n - .trackConvertActionLabelLossless( - currentFormat, - selectedFormat, - losslessQualityLabel( - LosslessConversionQuality( - maxBitDepth: selectedMaxBitDepth, - maxSampleRate: - selectedMaxSampleRate, - ), - originalLabel: labels.original, - originalQualityLabel: - labels.originalQuality, - ), - ) - : context.l10n.trackConvertActionLabelLossy( - currentFormat, - selectedFormat, - selectedBitrate, - ), - ), - ), - ), - ], - ), - ), - ), - ), - ); - }, - ); - }, - ); - } - - void _showCueSplitSheet(BuildContext context) async { - var cuePath = cleanFilePath; - final unknownAlbum = context.l10n.unknownAlbum; - final unknownArtist = context.l10n.unknownArtist; - final trackSuffix = RegExp(r'#track\d+$'); - if (trackSuffix.hasMatch(cuePath)) { - cuePath = cuePath.replaceFirst(trackSuffix, ''); - } - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarLoadingCueSheet)), - ); - - try { - final cueInfo = await PlatformBridge.parseCueSheet(cuePath); - - if (!mounted) return; - _hideCurrentSnackBar(); - - if (cueInfo.containsKey('error')) { - _showSnackBarMessage(_l10nCueSplitNoAudioFile); - return; - } - - final album = cueInfo['album'] as String? ?? unknownAlbum; - final artist = cueInfo['artist'] as String? ?? unknownArtist; - final audioPath = cueInfo['audio_path'] as String? ?? ''; - final genre = cueInfo['genre'] as String? ?? ''; - final date = cueInfo['date'] as String? ?? ''; - final tracksRaw = cueInfo['tracks'] as List? ?? []; - - if (audioPath.isEmpty) { - _showSnackBarMessage(_l10nCueSplitNoAudioFile); - return; - } - - final tracks = tracksRaw - .map((t) => CueSplitTrackInfo.fromJson(t as Map)) - .toList(); - - if (tracks.isEmpty) { - _showSnackBarMessage(_l10nCueSplitFailed); - return; - } - - if (!mounted) return; - - showModalBottomSheet( - context: this.context, - useRootNavigator: true, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (sheetContext) { - final colorScheme = Theme.of(sheetContext).colorScheme; - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.4, - ), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: 16), - Text( - sheetContext.l10n.cueSplitTitle, - style: Theme.of(sheetContext).textTheme.titleLarge - ?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 12), - Text( - sheetContext.l10n.cueSplitAlbum(album), - style: Theme.of(sheetContext).textTheme.bodyMedium - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - const SizedBox(height: 4), - Text( - sheetContext.l10n.cueSplitArtist(artist), - style: Theme.of(sheetContext).textTheme.bodyMedium - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - const SizedBox(height: 4), - Text( - sheetContext.l10n.cueSplitTrackCount(tracks.length), - style: Theme.of(sheetContext).textTheme.bodyMedium - ?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 16), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 200), - child: ListView.builder( - shrinkWrap: true, - itemCount: tracks.length, - itemBuilder: (context, index) { - final track = tracks[index]; - final duration = track.endSec > 0 - ? track.endSec - track.startSec - : 0.0; - final durationStr = duration > 0 - ? '${(duration ~/ 60).toString().padLeft(2, '0')}:${(duration.toInt() % 60).toString().padLeft(2, '0')}' - : ''; - return ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: CircleAvatar( - radius: 14, - backgroundColor: colorScheme.primaryContainer, - child: Text( - '${track.number}', - style: TextStyle( - fontSize: 11, - color: colorScheme.onPrimaryContainer, - ), - ), - ), - title: Text( - track.title, - style: const TextStyle(fontSize: 13), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: track.artist.isNotEmpty - ? Text( - track.artist, - style: TextStyle( - fontSize: 11, - color: colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) - : null, - trailing: durationStr.isNotEmpty - ? Text( - durationStr, - style: TextStyle( - fontSize: 12, - color: colorScheme.onSurfaceVariant, - ), - ) - : null, - ); - }, - ), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: () { - Navigator.pop(sheetContext); - _confirmAndSplitCue( - context: this.context, - audioPath: audioPath, - album: album, - artist: artist, - genre: genre, - date: date, - tracks: tracks, - ); - }, - icon: const Icon(Icons.call_split), - label: Text(sheetContext.l10n.cueSplitButton), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - const SizedBox(height: 8), - ], - ), - ), - ); - }, - ); - } catch (e) { - if (!mounted) return; - _hideCurrentSnackBar(); - _showSnackBarMessage(_l10nCueSplitFailed); - _log.e('Failed to parse CUE sheet: $e'); - } - } - - void _confirmAndSplitCue({ - required BuildContext context, - required String audioPath, - required String album, - required String artist, - required String genre, - required String date, - required List tracks, - }) { - showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: Text(dialogContext.l10n.cueSplitConfirmTitle), - content: Text( - dialogContext.l10n.cueSplitConfirmMessage(album, tracks.length), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(dialogContext.l10n.dialogCancel), - ), - FilledButton( - onPressed: () { - Navigator.pop(dialogContext); - _performCueSplit( - audioPath: audioPath, - album: album, - artist: artist, - genre: genre, - date: date, - tracks: tracks, - ); - }, - child: Text(dialogContext.l10n.cueSplitButton), - ), - ], - ); - }, - ); - } - - Future _resolvePersistentCueSplitOutputDir() async { - final settings = ref.read(settingsProvider); - final queueState = ref.read(downloadQueueProvider); - final configuredOutputDir = queueState.outputDir.trim(); - if (settings.storageMode != 'saf' && - configuredOutputDir.isNotEmpty && - !isContentUri(configuredOutputDir)) { - final dir = Directory(configuredOutputDir); - await dir.create(recursive: true); - return dir; - } - - if (Platform.isAndroid) { - final externalDir = await getExternalStorageDirectory(); - if (externalDir != null) { - final musicDir = Directory( - '${externalDir.parent.parent.parent.parent.path}' - '${Platform.pathSeparator}Music' - '${Platform.pathSeparator}SpotiFLAC', - ); - await musicDir.create(recursive: true); - return musicDir; - } - } - - final docsDir = await getApplicationDocumentsDirectory(); - final fallbackDir = Directory( - '${docsDir.path}${Platform.pathSeparator}SpotiFLAC', - ); - await fallbackDir.create(recursive: true); - return fallbackDir; - } - - Future?> _exportCueSplitOutputsToSaf({ - required List outputPaths, - required String treeUri, - required String relativeDir, - }) async { - final exportedUris = []; - for (final path in outputPaths) { - final fileName = path.split(Platform.pathSeparator).last; - final safUri = await PlatformBridge.createSafFileFromPath( - treeUri: treeUri, - relativeDir: relativeDir, - fileName: fileName, - mimeType: audioMimeTypeForPath(path), - srcPath: path, - ); - if (safUri != null && safUri.isNotEmpty) { - exportedUris.add(safUri); - } - } - return exportedUris.isEmpty ? null : exportedUris; - } - - Future _performCueSplit({ - required String audioPath, - required String album, - required String artist, - required String genre, - required String date, - required List tracks, - }) async { - if (_isConverting) return; - setState(() => _isConverting = true); - - String? safTempAudioPath; - Directory? tempSplitDir; - try { - // For SAF content:// audio paths, copy to temp for FFmpeg processing - String workingAudioPath = audioPath; - final isSafSource = isContentUri(audioPath); - if (isSafSource) { - final tempPath = await PlatformBridge.copyContentUriToTemp(audioPath); - if (tempPath == null || tempPath.isEmpty) { - throw Exception('Failed to copy SAF audio file to temp'); - } - safTempAudioPath = tempPath; - workingAudioPath = tempPath; - } - - final String outputDir; - final treeUri = !_isLocalItem - ? (_downloadItem?.downloadTreeUri ?? '') - : ''; - final relativeDir = !_isLocalItem - ? (_downloadItem?.safRelativeDir ?? '') - : ''; - final writeBackToSaf = isSafSource && treeUri.isNotEmpty; - if (writeBackToSaf) { - final tempDir = await getTemporaryDirectory(); - tempSplitDir = Directory( - '${tempDir.path}${Platform.pathSeparator}' - 'cue_split_${DateTime.now().millisecondsSinceEpoch}', - ); - await tempSplitDir.create(recursive: true); - outputDir = tempSplitDir.path; - } else if (isSafSource) { - final persistentDir = await _resolvePersistentCueSplitOutputDir(); - outputDir = persistentDir.path; - } else { - outputDir = File(audioPath).parent.path; - } - - if (!mounted) return; - _showLongSnackBarMessage(_l10nCueSplitSplitting(1, tracks.length)); - - String? coverPath; - try { - final tempDir = await getTemporaryDirectory(); - final coverOutput = - '${tempDir.path}${Platform.pathSeparator}cue_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; - final coverResult = await PlatformBridge.extractCoverToFile( - workingAudioPath, - coverOutput, - ); - if (coverResult['error'] == null) { - coverPath = coverOutput; - } - } catch (_) {} - - final albumMetadata = { - 'artist': artist, - 'album': album, - 'genre': genre, - 'date': date, - }; - - final outputPaths = await FFmpegService.splitCueToTracks( - audioPath: workingAudioPath, - outputDir: outputDir, - tracks: tracks, - albumMetadata: albumMetadata, - coverPath: coverPath, - onProgress: (current, total) { - if (mounted) { - _hideCurrentSnackBar(); - _showLongSnackBarMessage(_l10nCueSplitSplitting(current, total)); - } - }, - ); - - var finalOutputPaths = outputPaths; - - if (coverPath != null && finalOutputPaths != null) { - for (final path in finalOutputPaths) { - if (path.toLowerCase().endsWith('.flac')) { - try { - // Only send the cover_path field — EditFlacFields uses - // field-presence semantics, so omitting artist/album_artist - // means those keys won't be rewritten. This preserves any - // existing split artist Vorbis Comments. - await PlatformBridge.editFileMetadata(path, { - 'cover_path': coverPath, - }); - } catch (e) { - _log.w('Failed to embed cover to split track: $e'); - } - } - } - } - - if (writeBackToSaf && finalOutputPaths != null) { - final exportedUris = await _exportCueSplitOutputsToSaf( - outputPaths: finalOutputPaths, - treeUri: treeUri, - relativeDir: relativeDir, - ); - finalOutputPaths = exportedUris; - } - - if (coverPath != null) { - try { - await File(coverPath).delete(); - } catch (_) {} - } - - if (mounted) { - _hideCurrentSnackBar(); - if (finalOutputPaths != null && finalOutputPaths.isNotEmpty) { - _showSnackBarMessage(_l10nCueSplitSuccess(finalOutputPaths.length)); - } else { - _showSnackBarMessage(_l10nCueSplitFailed); - } - } - } catch (e) { - _log.e('CUE split failed: $e'); - if (mounted) { - _hideCurrentSnackBar(); - _showSnackBarMessage(_l10nCueSplitFailed); - } - } finally { - if (safTempAudioPath != null) { - try { - await File(safTempAudioPath).delete(); - } catch (_) {} - } - if (tempSplitDir != null) { - try { - await tempSplitDir.delete(recursive: true); - } catch (_) {} - } - if (mounted) { - setState(() => _isConverting = false); - } - } - } - - void _confirmAndConvert({ - required BuildContext context, - required String sourceFormat, - required String targetFormat, - required String bitrate, - LosslessConversionQuality losslessQuality = - const LosslessConversionQuality(), - LosslessConversionProcessing losslessProcessing = - const LosslessConversionProcessing(), - }) { - final isLossless = isLosslessConversionTarget(targetFormat); - showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: Text(dialogContext.l10n.trackConvertConfirmTitle), - content: Text( - isLossless && losslessQuality.hasCaps - ? dialogContext.l10n.trackConvertConfirmMessageLosslessCapped( - sourceFormat, - targetFormat, - losslessQualityLabel( - losslessQuality, - originalLabel: - dialogContext.l10n.losslessConversionLabels.original, - originalQualityLabel: dialogContext - .l10n - .losslessConversionLabels - .originalQuality, - ), - ) - : isLossless - ? dialogContext.l10n.trackConvertConfirmMessageLossless( - sourceFormat, - targetFormat, - ) - : dialogContext.l10n.trackConvertConfirmMessage( - sourceFormat, - targetFormat, - bitrate, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(dialogContext.l10n.dialogCancel), - ), - FilledButton( - onPressed: () { - Navigator.pop(dialogContext); - _performConversion( - targetFormat: targetFormat, - bitrate: bitrate, - losslessQuality: losslessQuality, - losslessProcessing: losslessProcessing, - ); - }, - child: Text(dialogContext.l10n.trackConvertFormat), - ), - ], - ); - }, - ); - } - - Future _performConversion({ - required String targetFormat, - required String bitrate, - LosslessConversionQuality losslessQuality = - const LosslessConversionQuality(), - LosslessConversionProcessing losslessProcessing = - const LosslessConversionProcessing(), - }) async { - if (_isConverting) return; - setState(() => _isConverting = true); - final losslessLabels = context.l10n.losslessConversionLabels; - - try { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackConvertConverting)), - ); - - final settings = ref.read(settingsProvider); - final shouldEmbedLyrics = - settings.embedLyrics && settings.lyricsMode != 'external'; - final metadata = _buildFallbackMetadata(); - try { - final result = await PlatformBridge.readFileMetadata(cleanFilePath); - if (result['error'] == null) { - mergePlatformMetadataForTagEmbed(target: metadata, source: result); - } else { - _log.w('readFileMetadata returned error, using fallback metadata'); - } - } catch (e) { - _log.w('readFileMetadata threw, using fallback metadata: $e'); - } - await ensureLyricsMetadataForConversion( - metadata: metadata, - sourcePath: cleanFilePath, - shouldEmbedLyrics: shouldEmbedLyrics, - trackName: trackName, - artistName: artistName, - spotifyId: _spotifyId ?? '', - durationMs: (duration ?? 0) * 1000, - ); - - String? coverPath; - try { - final tempDir = await getTemporaryDirectory(); - final coverOutput = - '${tempDir.path}${Platform.pathSeparator}convert_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; - final coverResult = await PlatformBridge.extractCoverToFile( - cleanFilePath, - coverOutput, - ); - if (coverResult['error'] == null) { - coverPath = coverOutput; - } - } catch (_) {} - - String workingPath = cleanFilePath; - final isSaf = _isSafFile; - String? safTempPath; - - if (isSaf) { - safTempPath = await PlatformBridge.copyContentUriToTemp(cleanFilePath); - if (safTempPath == null) { - if (mounted) { - setState(() => _isConverting = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackConvertFailed)), - ); - } - return; - } - workingPath = safTempPath; - } - - final newPath = await FFmpegService.convertAudioFormat( - inputPath: workingPath, - targetFormat: targetFormat.toLowerCase(), - bitrate: bitrate, - metadata: metadata, - coverPath: coverPath, - artistTagMode: ref.read(settingsProvider).artistTagMode, - deleteOriginal: !isSaf, - sourceBitDepth: bitDepth, - losslessQuality: losslessQuality, - losslessProcessing: losslessProcessing, - ); - - if (coverPath != null) { - try { - await File(coverPath).delete(); - } catch (_) {} - } - - if (newPath == null) { - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - if (mounted) { - setState(() => _isConverting = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackConvertFailed)), - ); - } - return; - } - - final isLosslessOutput = isLosslessConversionTarget(targetFormat); - int? convertedBitDepth; - int? convertedSampleRate; - if (isLosslessOutput) { - try { - final convertedMetadata = await PlatformBridge.readFileMetadata( - newPath, - ); - if (convertedMetadata['error'] == null) { - convertedBitDepth = readPositiveAudioInt( - convertedMetadata['bit_depth'], - ); - convertedSampleRate = readPositiveAudioInt( - convertedMetadata['sample_rate'], - ); - } - } catch (_) {} - convertedBitDepth ??= losslessQuality.effectiveBitDepth(bitDepth); - convertedSampleRate ??= losslessQuality.effectiveSampleRate(sampleRate); - } - final newQuality = convertedAudioQualityLabel( - targetFormat: targetFormat, - bitrate: bitrate, - labels: losslessLabels, - losslessQuality: losslessQuality, - actualBitDepth: convertedBitDepth, - actualSampleRate: convertedSampleRate, - ); - - if (isSaf) { - String? treeUri; - String relativeDir = ''; - String oldFileName = ''; - if (_isLocalItem) { - final uri = Uri.parse(cleanFilePath); - final pathSegments = uri.pathSegments; - final treeIdx = pathSegments.indexOf('tree'); - final docIdx = pathSegments.indexOf('document'); - if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) { - final treeId = pathSegments[treeIdx + 1]; - treeUri = - 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; - } - if (docIdx >= 0 && docIdx + 1 < pathSegments.length) { - final docPath = Uri.decodeFull(pathSegments[docIdx + 1]); - final slashIdx = docPath.lastIndexOf('/'); - if (slashIdx >= 0) { - oldFileName = docPath.substring(slashIdx + 1); - final treeId = treeIdx >= 0 && treeIdx + 1 < pathSegments.length - ? Uri.decodeFull(pathSegments[treeIdx + 1]) - : ''; - if (treeId.isNotEmpty && docPath.startsWith(treeId)) { - final afterTree = docPath.substring(treeId.length); - final trimmed = afterTree.startsWith('/') - ? afterTree.substring(1) - : afterTree; - final lastSlash = trimmed.lastIndexOf('/'); - relativeDir = lastSlash >= 0 - ? trimmed.substring(0, lastSlash) - : ''; - } - } else { - oldFileName = docPath; - } - } - } else { - treeUri = _downloadItem?.downloadTreeUri; - relativeDir = _downloadItem?.safRelativeDir ?? ''; - oldFileName = - (_downloadItem?.safFileName != null && - _downloadItem!.safFileName!.isNotEmpty) - ? _downloadItem!.safFileName! - : _extractFileNameFromPathOrUri(cleanFilePath); - } - if (treeUri == null || treeUri.isEmpty) { - try { - await File(newPath).delete(); - } catch (_) {} - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - if (mounted) { - setState(() => _isConverting = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackConvertFailed)), - ); - } - return; - } - - final dotIdx = oldFileName.lastIndexOf('.'); - final baseName = dotIdx > 0 - ? oldFileName.substring(0, dotIdx) - : oldFileName; - final convTarget = convertTargetExtAndMime(targetFormat); - final newExt = convTarget.ext; - final mimeType = convTarget.mime; - final newFileName = '$baseName$newExt'; - - final safUri = await PlatformBridge.createSafFileFromPath( - treeUri: treeUri, - relativeDir: relativeDir, - fileName: newFileName, - mimeType: mimeType, - srcPath: newPath, - ); - - if (safUri == null || safUri.isEmpty) { - try { - await File(newPath).delete(); - } catch (_) {} - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - if (mounted) { - setState(() => _isConverting = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackConvertFailed)), - ); - } - return; - } - - if (!isSameContentUri(cleanFilePath, safUri)) { - final deletedOriginal = await PlatformBridge.safDelete( - cleanFilePath, - ).catchError((_) => false); - if (deletedOriginal != true) { - _log.w( - 'Converted SAF file created but failed deleting original URI', - ); - } - } - - if (!_isLocalItem) { - await HistoryDatabase.instance.updateFilePath( - _downloadItem!.id, - safUri, - newSafFileName: newFileName, - newQuality: newQuality, - newFormat: normalizedConvertedAudioFormat(targetFormat), - newBitrate: convertedAudioBitrateKbps( - targetFormat: targetFormat, - bitrate: bitrate, - ), - newBitDepth: convertedBitDepth, - newSampleRate: convertedSampleRate, - clearAudioSpecs: !isLosslessOutput, - ); - await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); - } else { - await LibraryDatabase.instance.replaceWithConvertedItem( - item: _localLibraryItem!, - newFilePath: safUri, - targetFormat: targetFormat, - bitrate: bitrate, - bitDepth: convertedBitDepth, - sampleRate: convertedSampleRate, - ); - await ref.read(localLibraryProvider.notifier).reloadFromStorage(); - } - - try { - await File(newPath).delete(); - } catch (_) {} - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - } else { - if (!_isLocalItem) { - await HistoryDatabase.instance.updateFilePath( - _downloadItem!.id, - newPath, - newQuality: newQuality, - newFormat: normalizedConvertedAudioFormat(targetFormat), - newBitrate: convertedAudioBitrateKbps( - targetFormat: targetFormat, - bitrate: bitrate, - ), - newBitDepth: convertedBitDepth, - newSampleRate: convertedSampleRate, - clearAudioSpecs: !isLosslessOutput, - ); - await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); - } else { - await LibraryDatabase.instance.replaceWithConvertedItem( - item: _localLibraryItem!, - newFilePath: newPath, - targetFormat: targetFormat, - bitrate: bitrate, - bitDepth: convertedBitDepth, - sampleRate: convertedSampleRate, - ); - await ref.read(localLibraryProvider.notifier).reloadFromStorage(); - } - } - - if (mounted) { - setState(() => _isConverting = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.trackConvertSuccess(targetFormat)), - ), - ); - Navigator.pop(context, true); - } - } catch (e) { - if (mounted) { - setState(() => _isConverting = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))), - ); - } - } - } - - void _showEditMetadataSheet( - BuildContext context, - WidgetRef ref, - ColorScheme colorScheme, - ) async { - Map? fileMetadata; - try { - final result = await PlatformBridge.readFileMetadata(cleanFilePath); - if (result['error'] == null) { - fileMetadata = result; - } - } catch (e) { - debugPrint('readFileMetadata failed, using item data: $e'); - } - - String val(String key, String? fallback) { - final v = fileMetadata?[key]?.toString(); - return (v != null && v.isNotEmpty) ? v : (fallback ?? ''); - } - - final initialValues = { - 'title': val('title', trackName), - 'artist': val('artist', artistName), - 'album': val('album', albumName), - 'album_artist': val('album_artist', albumArtist), - 'date': val('date', releaseDate), - 'track_number': (fileMetadata?['track_number'] ?? trackNumber ?? '') - .toString(), - 'total_tracks': (fileMetadata?['total_tracks'] ?? totalTracks ?? '') - .toString(), - 'disc_number': (fileMetadata?['disc_number'] ?? discNumber ?? '') - .toString(), - 'total_discs': (fileMetadata?['total_discs'] ?? totalDiscs ?? '') - .toString(), - 'genre': val('genre', genre), - 'isrc': val('isrc', isrc), - 'label': val('label', label), - 'copyright': val('copyright', copyright), - 'composer': val('composer', composer), - 'comment': fileMetadata?['comment']?.toString() ?? '', - 'lyrics': fileMetadata?['lyrics']?.toString() ?? '', - }; - - final initialDurationSeconds = - readPositiveInt(fileMetadata?['duration']) ?? duration ?? 0; - - if (!context.mounted) return; - - final saved = await showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - backgroundColor: colorScheme.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (sheetContext) => _EditMetadataSheet( - colorScheme: colorScheme, - initialValues: initialValues, - filePath: cleanFilePath, - sourceTrackId: _spotifyId, - durationMs: initialDurationSeconds > 0 - ? initialDurationSeconds * 1000 - : 0, - artistTagMode: ref.read(settingsProvider).artistTagMode, - ), - ); - - if (saved == true && mounted) { - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar(content: Text(this.context.l10n.snackbarMetadataSaved)), - ); - try { - final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath); - final refreshedLyrics = refreshed['lyrics']?.toString().trim() ?? ''; - setState(() { - _editedMetadata = refreshed; - _lyricsError = null; - _isInstrumental = false; - _embeddedLyricsChecked = true; - if (refreshedLyrics.isNotEmpty) { - _lyrics = _cleanLrcForDisplay(refreshedLyrics); - _rawLyrics = refreshedLyrics; - _lyricsSource = context.l10n.trackLyricsEmbeddedSource; - _lyricsEmbedded = true; - } else { - _lyrics = null; - _rawLyrics = null; - _lyricsSource = null; - _lyricsEmbedded = false; - } - }); - } catch (_) { - setState(() {}); - } - await _refreshEmbeddedCoverPreview(force: true); - _markMetadataChanged(); - await _syncDownloadHistoryMetadata(); - } - } - - void _confirmDelete( - BuildContext screenContext, - WidgetRef ref, - ColorScheme colorScheme, - ) { - showDialog( - context: screenContext, - useRootNavigator: true, - builder: (dialogContext) => AlertDialog( - title: Text(dialogContext.l10n.trackDeleteConfirmTitle), - content: Text(dialogContext.l10n.trackDeleteConfirmMessage), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(dialogContext.l10n.dialogCancel), - ), - TextButton( - onPressed: () async { - if (_isLocalItem) { - if (_isCueVirtualTrack && _localLibraryItem != null) { - await ref - .read(localLibraryProvider.notifier) - .removeItem(_localLibraryItem!.id); - } else { - try { - await deleteFile(cleanFilePath); - } catch (e) { - debugPrint('Failed to delete file: $e'); - } - if (_localLibraryItem != null) { - await ref - .read(localLibraryProvider.notifier) - .removeItem(_localLibraryItem!.id); - } - } - } else { - try { - await deleteFile(cleanFilePath); - } catch (e) { - debugPrint('Failed to delete file: $e'); - } - - ref - .read(downloadHistoryProvider.notifier) - .removeFromHistory(_downloadItem!.id); - } - - if (dialogContext.mounted) { - Navigator.pop(dialogContext); - } - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - final navigator = Navigator.of(context); - if (navigator.canPop()) { - navigator.pop(true); - } - }); - }, - child: Text( - dialogContext.l10n.dialogDelete, - style: TextStyle(color: colorScheme.error), - ), - ), - ], - ), - ); - } - - void _closeOptionsMenuAndRun(BuildContext sheetContext, VoidCallback action) { - Navigator.pop(sheetContext); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - action(); - }); - } - - Future _openFile(BuildContext context, String filePath) async { - if (isCueVirtualPath(filePath)) { - _showCueVirtualTrackSnackBar(context); - return; - } - try { - await ref - .read(playbackProvider.notifier) - .playLocalPath( - path: filePath, - title: trackName, - artist: artistName, - album: albumName, - coverUrl: _coverUrl ?? '', - ); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarCannotOpenFile(e.toString())), - ), - ); - } - } - } - - void _copyToClipboard(BuildContext context, String text) { - Clipboard.setData(ClipboardData(text: text)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.trackCopiedToClipboard), - duration: const Duration(seconds: 2), - ), - ); - } - - Future _shareFile(BuildContext context) async { - if (_isCueVirtualTrack) { - _showCueVirtualTrackSnackBar(context); - return; - } - - String sharePath = cleanFilePath; - if (!await fileExists(sharePath)) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarFileNotFound)), - ); - } - return; - } - - final shareTitle = '$trackName - $artistName'; - - // For SAF content URIs, use native share intent directly (zero-copy) - if (isContentUri(sharePath)) { - try { - await PlatformBridge.shareContentUri(sharePath, title: shareTitle); - } catch (_) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarCannotOpenFile('Failed to share file'), - ), - ), - ); - } - } - return; - } - - await SharePlus.instance.share( - ShareParams(files: [XFile(sharePath)], text: shareTitle), - ); - } - - String _formatFullDate(DateTime date) { - return '${date.day} ${_months[date.month - 1]} ${date.year}, ' - '${date.hour.toString().padLeft(2, '0')}:' - '${date.minute.toString().padLeft(2, '0')}'; - } - - String _formatFileSize(int bytes) { - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; - if (bytes < 1024 * 1024 * 1024) { - return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; - } - return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; - } - - IconData _getServiceIcon(String service) { - switch (service.toLowerCase()) { - case 'tidal': - return Icons.waves; - case 'qobuz': - return Icons.album; - case 'amazon': - return Icons.shopping_cart; - default: - return Icons.cloud_download; - } - } - - Color _getServiceColor(String service, ColorScheme colorScheme) { - switch (service.toLowerCase()) { - case 'tidal': - return const Color(0xFF0077B5); - case 'qobuz': - return const Color(0xFF0052CC); - case 'amazon': - return const Color(0xFFFF9900); - default: - return colorScheme.primary; - } - } } class _MetadataOption {