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 = audioConversionTargetFormats .where( (target) => canConvertAudioFormat( sourceFormat: currentFormat, targetFormat: target, ), ) .toList(growable: false); final labels = context.l10n.losslessConversionLabels; showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, builder: (sheetContext) => BatchConvertSheet( formats: formats, title: context.l10n.trackConvertTitle, subtitle: currentFormat, sourceIsLossless: isLosslessSource, sourceBitDepth: bitDepth, sourceSampleRate: sampleRate, confirmLabelBuilder: (format, bitrate, isLosslessTarget, losslessQuality) { return isLosslessTarget ? context.l10n.trackConvertActionLabelLossless( currentFormat, format, losslessQualityLabel( losslessQuality, originalLabel: labels.original, originalQualityLabel: labels.originalQuality, ), ) : context.l10n.trackConvertActionLabelLossy( currentFormat, format, bitrate, ); }, onConvert: ( format, bitrate, losslessQuality, losslessProcessing, keepOriginal, ) { Navigator.pop(sheetContext); _confirmAndConvert( context: this.context, sourceFormat: currentFormat, targetFormat: format, bitrate: bitrate, losslessQuality: losslessQuality, losslessProcessing: losslessProcessing, keepOriginal: keepOriginal, ); }, ), ); } 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, 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: [ const AppSheetHandle(), 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(), bool keepOriginal = false, }) { final isLossless = isLosslessConversionTarget(targetFormat); showDialog( context: context, builder: (dialogContext) { return AlertDialog( title: Text(dialogContext.l10n.trackConvertConfirmTitle), content: Text( keepOriginal ? dialogContext.l10n.trackConvertConfirmKeepOriginal( sourceFormat, targetFormat, ) : 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, keepOriginal: keepOriginal, ); }, child: Text(dialogContext.l10n.trackConvertFormat), ), ], ); }, ); } Future _performConversion({ required String targetFormat, required String bitrate, LosslessConversionQuality losslessQuality = const LosslessConversionQuality(), LosslessConversionProcessing losslessProcessing = const LosslessConversionProcessing(), bool keepOriginal = false, }) async { if (_isConverting) return; _setState(() => _isConverting = true); final losslessLabels = context.l10n.losslessConversionLabels; String? coverPath; String? safTempPath; String? convertedSafTempPath; 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, ); 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; if (isSaf) { safTempPath = await ConversionLibraryService.copySafSourceToTemp( 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 && !keepOriginal, 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; } if (isSaf) convertedSafTempPath = newPath; final isLosslessOutput = isLosslessConversionTarget(targetFormat); int? convertedBitDepth; int? convertedSampleRate; if (isLosslessOutput) { try { final convertedMetadata = await PlatformBridge.readFileMetadata( newPath, ); if (convertedMetadata['error'] == null) { convertedBitDepth = readPositiveInt(convertedMetadata['bit_depth']); convertedSampleRate = readPositiveInt( 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 published = await ConversionLibraryService.publishSafConversion( treeUri: treeUri, relativeDir: relativeDir, originalFileName: oldFileName, targetFormat: targetFormat, sourcePath: newPath, keepOriginal: keepOriginal, ); final safUri = published?.uri; 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 (!keepOriginal && !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 ConversionLibraryService.persistHistoryConversion( source: _downloadItem!, newFilePath: safUri, newQuality: newQuality, targetFormat: targetFormat, bitrate: bitrate, bitDepth: convertedBitDepth, sampleRate: convertedSampleRate, keepOriginal: keepOriginal, newSafFileName: published!.fileName, ); await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); } else { await LibraryDatabase.instance.replaceWithConvertedItem( item: _localLibraryItem!, newFilePath: safUri, targetFormat: targetFormat, bitrate: bitrate, bitDepth: convertedBitDepth, sampleRate: convertedSampleRate, keepOriginal: keepOriginal, ); 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 ConversionLibraryService.persistHistoryConversion( source: _downloadItem!, newFilePath: newPath, newQuality: newQuality, targetFormat: targetFormat, bitrate: bitrate, bitDepth: convertedBitDepth, sampleRate: convertedSampleRate, keepOriginal: keepOriginal, ); await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); } else { await LibraryDatabase.instance.replaceWithConvertedItem( item: _localLibraryItem!, newFilePath: newPath, targetFormat: targetFormat, bitrate: bitrate, bitDepth: convertedBitDepth, sampleRate: convertedSampleRate, keepOriginal: keepOriginal, ); 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(context.friendlyError(e)), ), ), ); } } finally { for (final path in [ coverPath, safTempPath, convertedSafTempPath, ]) { if (path == null || path.isEmpty) continue; try { final file = File(path); if (await file.exists()) await file.delete(); } catch (error) { _log.w('Failed to clean conversion temp file $path: $error'); } } } } }