SpotiFLAC-Mobile/lib/screens/track_metadata_screen.dart

3968 lines
130 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:file_picker/file_picker.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:share_plus/share_plus.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
final _log = AppLogger('TrackMetadata');
class _EmbeddedCoverPreviewCacheEntry {
final String previewPath;
final int? fileModTime;
const _EmbeddedCoverPreviewCacheEntry({
required this.previewPath,
this.fileModTime,
});
}
class TrackMetadataScreen extends ConsumerStatefulWidget {
final DownloadHistoryItem? item;
final LocalLibraryItem? localItem;
const TrackMetadataScreen({super.key, this.item, this.localItem})
: assert(
item != null || localItem != null,
'Either item or localItem must be provided',
);
@override
ConsumerState<TrackMetadataScreen> createState() =>
_TrackMetadataScreenState();
}
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
static const int _maxCoverPreviewCacheEntries = 96;
static final Map<String, _EmbeddedCoverPreviewCacheEntry>
_embeddedCoverPreviewCache = {};
bool _fileExists = false;
bool _hasCheckedFile = false;
int? _fileSize;
String? _lyrics; // Cleaned lyrics for display (no timestamps)
String? _rawLyrics; // Raw LRC with timestamps for embedding
bool _lyricsLoading = false;
String? _lyricsError;
String? _lyricsSource;
bool _showTitleInAppBar = false;
bool _lyricsEmbedded = false; // Track if lyrics are embedded in file
bool _isEmbedding = false; // Track embed operation in progress
bool _isInstrumental = false; // Track if detected as instrumental
bool _isConverting = false; // Track convert operation in progress
bool _hasMetadataChanges = false;
Map<String, dynamic>? _editedMetadata; // Overrides after metadata edit
String? _embeddedCoverPreviewPath;
final ScrollController _scrollController = ScrollController();
static final RegExp _lrcTimestampPattern = RegExp(
r'^\[\d{2}:\d{2}\.\d{2,3}\]',
);
static final RegExp _lrcMetadataPattern = RegExp(r'^\[[a-zA-Z]+:.*\]$');
static final RegExp _lrcInlineTimestampPattern = RegExp(
r'<\d{2}:\d{2}\.\d{2,3}>',
);
static final RegExp _lrcSpeakerPrefixPattern = RegExp(r'^(v1|v2):\s*');
static final RegExp _lrcBackgroundLinePattern = RegExp(r'^\[bg:(.*)\]$');
static final RegExp _invalidFileNameChars = RegExp(r'[<>:"/\\|?*\x00-\x1f]');
static final RegExp _multiUnderscore = RegExp(r'_+');
static final RegExp _leadingOrTrailingDots = RegExp(r'^\.+|\.+$');
static const List<String> _months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
String get _coverCacheKey => _itemId;
bool _isCacheTrackedPath(String? path) {
if (!_hasPath(path)) return false;
return _embeddedCoverPreviewCache.values.any(
(entry) => entry.previewPath == path,
);
}
bool _isVolatileSafTempPath(String path) {
if (path.isEmpty) return false;
return path.contains(
'${Platform.pathSeparator}cache${Platform.pathSeparator}saf_',
);
}
int? _readLocalFileModTimeMsSync(String path) {
if (path.isEmpty || isContentUri(path) || _isVolatileSafTempPath(path)) {
return null;
}
try {
return File(path).statSync().modified.millisecondsSinceEpoch;
} catch (_) {
return null;
}
}
void _cacheEmbeddedCoverPreview(
String cacheKey,
String sourcePath,
String previewPath,
) {
final fileModTime = _readLocalFileModTimeMsSync(sourcePath);
final existing = _embeddedCoverPreviewCache[cacheKey];
_embeddedCoverPreviewCache[cacheKey] = _EmbeddedCoverPreviewCacheEntry(
previewPath: previewPath,
fileModTime: fileModTime,
);
if (existing != null && existing.previewPath != previewPath) {
_cleanupTempFileAndParentSyncIfNotCached(existing.previewPath);
}
while (_embeddedCoverPreviewCache.length > _maxCoverPreviewCacheEntries) {
final oldestKey = _embeddedCoverPreviewCache.keys.first;
final removed = _embeddedCoverPreviewCache.remove(oldestKey);
if (removed != null) {
_cleanupTempFileAndParentSyncIfNotCached(removed.previewPath);
}
}
}
void _invalidateEmbeddedCoverPreviewCacheForPath(String cacheKey) {
if (cacheKey.isEmpty) return;
final removed = _embeddedCoverPreviewCache.remove(cacheKey);
if (removed != null) {
_cleanupTempFileAndParentSyncIfNotCached(removed.previewPath);
}
}
String? _getCachedEmbeddedCoverPreviewPathIfValid(
String cacheKey,
String sourcePath,
) {
if (cacheKey.isEmpty) return null;
final cached = _embeddedCoverPreviewCache[cacheKey];
if (cached == null) return null;
final previewFile = File(cached.previewPath);
if (!previewFile.existsSync()) {
_embeddedCoverPreviewCache.remove(cacheKey);
return null;
}
if (!isContentUri(sourcePath) && !_isVolatileSafTempPath(sourcePath)) {
final currentModTime = _readLocalFileModTimeMsSync(sourcePath);
if (currentModTime != null &&
cached.fileModTime != null &&
currentModTime != cached.fileModTime) {
_embeddedCoverPreviewCache.remove(cacheKey);
_cleanupTempFileAndParentSyncIfNotCached(cached.previewPath);
return null;
}
}
return cached.previewPath;
}
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_checkFile();
}
@override
void dispose() {
_cleanupTempFileAndParentSyncIfNotCached(_embeddedCoverPreviewPath);
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final expandedHeight = _calculateExpandedHeight(context);
final shouldShow =
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
double _calculateExpandedHeight(BuildContext context) {
final mediaSize = MediaQuery.of(context).size;
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
Future<void> _checkFile() async {
var filePath = _filePath;
if (filePath.startsWith('EXISTS:')) {
filePath = filePath.substring(7);
}
bool exists = false;
int? size;
try {
final stat = await fileStat(filePath);
if (stat != null) {
exists = true;
size = stat.size;
}
} catch (_) {}
if (mounted &&
(exists != _fileExists || size != _fileSize || !_hasCheckedFile)) {
setState(() {
_fileExists = exists;
_fileSize = size;
_hasCheckedFile = true;
});
}
if (mounted && exists && _lyrics == null && !_lyricsLoading) {
_fetchLyrics();
}
if (mounted && exists && !_hasPath(_embeddedCoverPreviewPath)) {
final cachedPath = _getCachedEmbeddedCoverPreviewPathIfValid(
_coverCacheKey,
cleanFilePath,
);
if (_hasPath(cachedPath)) {
setState(() => _embeddedCoverPreviewPath = cachedPath);
}
}
}
bool _hasPath(String? path) => path != null && path.trim().isNotEmpty;
Future<void> _cleanupTempFileAndParent(String? path) async {
if (!_hasPath(path)) return;
final file = File(path!);
try {
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
try {
final dir = file.parent;
if (await dir.exists()) {
await dir.delete(recursive: true);
}
} catch (_) {}
}
Future<void> _cleanupTempFileAndParentIfNotCached(String? path) async {
if (_isCacheTrackedPath(path)) return;
await _cleanupTempFileAndParent(path);
}
void _cleanupTempFileAndParentSync(String? path) {
if (!_hasPath(path)) return;
final file = File(path!);
try {
if (file.existsSync()) {
file.deleteSync();
}
} catch (_) {}
try {
final dir = file.parent;
if (dir.existsSync()) {
dir.deleteSync(recursive: true);
}
} catch (_) {}
}
void _cleanupTempFileAndParentSyncIfNotCached(String? path) {
if (_isCacheTrackedPath(path)) return;
_cleanupTempFileAndParentSync(path);
}
Future<void> _refreshEmbeddedCoverPreview({bool force = false}) async {
final cacheKey = _coverCacheKey;
final sourcePath = cleanFilePath;
if (!force) {
final cachedPath = _getCachedEmbeddedCoverPreviewPathIfValid(
cacheKey,
sourcePath,
);
if (_hasPath(cachedPath)) {
if (mounted && _embeddedCoverPreviewPath != cachedPath) {
setState(() => _embeddedCoverPreviewPath = cachedPath);
}
return;
}
}
String? newPreviewPath;
try {
if (!_fileExists) {
_invalidateEmbeddedCoverPreviewCacheForPath(cacheKey);
await _cleanupTempFileAndParentIfNotCached(_embeddedCoverPreviewPath);
if (mounted) {
setState(() => _embeddedCoverPreviewPath = null);
}
return;
}
if (force) {
_invalidateEmbeddedCoverPreviewCacheForPath(cacheKey);
}
final tempDir = await Directory.systemTemp.createTemp(
'track_cover_preview_',
);
final outputPath =
'${tempDir.path}${Platform.pathSeparator}cover_preview.jpg';
final result = await PlatformBridge.extractCoverToFile(
sourcePath,
outputPath,
);
if (result['error'] == null && await File(outputPath).exists()) {
newPreviewPath = outputPath;
_cacheEmbeddedCoverPreview(cacheKey, sourcePath, outputPath);
} else {
try {
await tempDir.delete(recursive: true);
} catch (_) {}
}
} catch (_) {}
final oldPreviewPath = _embeddedCoverPreviewPath;
if (!mounted) {
if (newPreviewPath != null) {
await _cleanupTempFileAndParentIfNotCached(newPreviewPath);
}
return;
}
setState(() => _embeddedCoverPreviewPath = newPreviewPath);
if (oldPreviewPath != null && oldPreviewPath != newPreviewPath) {
await _cleanupTempFileAndParentIfNotCached(oldPreviewPath);
}
}
bool get _isLocalItem => widget.localItem != null;
DownloadHistoryItem? get _downloadItem => widget.item;
LocalLibraryItem? get _localLibraryItem => widget.localItem;
String get _itemId =>
_isLocalItem ? _localLibraryItem!.id : _downloadItem!.id;
String get trackName =>
_editedMetadata?['title']?.toString() ??
(_isLocalItem ? _localLibraryItem!.trackName : _downloadItem!.trackName);
String get artistName =>
_editedMetadata?['artist']?.toString() ??
(_isLocalItem
? _localLibraryItem!.artistName
: _downloadItem!.artistName);
String get albumName =>
_editedMetadata?['album']?.toString() ??
(_isLocalItem ? _localLibraryItem!.albumName : _downloadItem!.albumName);
String? get albumArtist {
final edited = _editedMetadata?['album_artist']?.toString();
if (edited != null && edited.isNotEmpty) return edited;
return normalizeOptionalString(
_isLocalItem
? _localLibraryItem!.albumArtist
: _downloadItem!.albumArtist,
);
}
int? get trackNumber {
final edited = _editedMetadata?['track_number'];
if (edited != null) {
final v = int.tryParse(edited.toString());
if (v != null && v > 0) return v;
}
return _isLocalItem
? _localLibraryItem!.trackNumber
: _downloadItem!.trackNumber;
}
int? get discNumber {
final edited = _editedMetadata?['disc_number'];
if (edited != null) {
final v = int.tryParse(edited.toString());
if (v != null && v > 0) return v;
}
return _isLocalItem
? _localLibraryItem!.discNumber
: _downloadItem!.discNumber;
}
String? get releaseDate =>
_editedMetadata?['date']?.toString() ??
(_isLocalItem
? _localLibraryItem!.releaseDate
: _downloadItem!.releaseDate);
String? get isrc =>
_editedMetadata?['isrc']?.toString() ??
(_isLocalItem ? _localLibraryItem!.isrc : _downloadItem!.isrc);
String? get genre =>
_editedMetadata?['genre']?.toString() ??
(_isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre);
String? get label =>
_editedMetadata?['label']?.toString() ??
(_isLocalItem ? null : _downloadItem!.label);
String? get copyright =>
_editedMetadata?['copyright']?.toString() ??
(_isLocalItem ? null : _downloadItem!.copyright);
int? get duration =>
_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration;
int? get bitDepth =>
_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth;
int? get sampleRate =>
_isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate;
int? get _localBitrate => _isLocalItem ? _localLibraryItem!.bitrate : null;
String get _filePath =>
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
String? get _coverUrl => _isLocalItem ? null : _downloadItem!.coverUrl;
String? get _localCoverPath =>
_isLocalItem ? _localLibraryItem!.coverPath : null;
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;
String get _service => _isLocalItem ? 'local' : _downloadItem!.service;
DateTime get _addedAt {
if (_isLocalItem) {
// Use file modification time if available, otherwise fall back to scannedAt
final modTime = _localLibraryItem!.fileModTime;
if (modTime != null && modTime > 0) {
return DateTime.fromMillisecondsSinceEpoch(modTime);
}
return _localLibraryItem!.scannedAt;
}
return _downloadItem!.downloadedAt;
}
String? get _quality => _isLocalItem ? null : _downloadItem!.quality;
String get cleanFilePath {
final path = _filePath;
return path.startsWith('EXISTS:') ? path.substring(7) : path;
}
String _formatPathForDisplay(String pathOrUri) {
if (pathOrUri.isEmpty || !pathOrUri.startsWith('content://')) {
return pathOrUri;
}
try {
final uri = Uri.parse(pathOrUri);
final segments = uri.pathSegments;
String? documentId;
final documentIndex = segments.indexOf('document');
if (documentIndex != -1 && documentIndex + 1 < segments.length) {
documentId = Uri.decodeComponent(segments[documentIndex + 1]);
}
if (documentId == null || documentId.isEmpty) {
final treeIndex = segments.indexOf('tree');
if (treeIndex != -1 && treeIndex + 1 < segments.length) {
documentId = Uri.decodeComponent(segments[treeIndex + 1]);
}
}
if (documentId == null || documentId.isEmpty) return pathOrUri;
final separatorIndex = documentId.indexOf(':');
if (separatorIndex <= 0) return documentId;
final volumeId = documentId.substring(0, separatorIndex);
final relativePath = documentId
.substring(separatorIndex + 1)
.replaceAll('\\', '/');
if (volumeId.toLowerCase() == 'primary') {
if (relativePath.isEmpty) return '/storage/emulated/0';
return '/storage/emulated/0/$relativePath';
}
if (relativePath.isEmpty) return volumeId;
return 'SD Card/$relativePath';
} catch (_) {
return pathOrUri;
}
}
void _markMetadataChanged() {
_hasMetadataChanges = true;
}
void _popWithMetadataResult() {
Navigator.pop(context, _hasMetadataChanges ? true : null);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final expandedHeight = _calculateExpandedHeight(context);
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
trackName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: _buildHeaderBackground(
context,
colorScheme,
expandedHeight,
showContent,
),
stretchModes: const [StretchMode.zoomBackground],
);
},
),
leading: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
onPressed: _popWithMetadataResult,
),
actions: [
IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.more_vert, color: Colors.white),
),
onPressed: () => _showOptionsMenu(context, ref, colorScheme),
),
],
),
SliverToBoxAdapter(
child: Padding(
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),
const SizedBox(height: 24),
_buildActionButtons(context, ref, colorScheme, _fileExists),
const SizedBox(height: 32),
],
),
),
),
],
),
);
}
Widget _buildHeaderBackground(
BuildContext context,
ColorScheme colorScheme,
double expandedHeight,
bool showContent,
) {
return Stack(
fit: StackFit.expand,
children: [
if (_hasPath(_embeddedCoverPreviewPath))
Image.file(
File(_embeddedCoverPreviewPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
)
else if (_coverUrl != null)
CachedNetworkImage(
imageUrl: _coverUrl!,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
)
else if (_localCoverPath != null && _localCoverPath!.isNotEmpty)
Image.file(
File(_localCoverPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
)
else
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
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 (_quality != null && _quality!.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(
_quality!,
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),
const Text(
'Local',
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,
),
),
],
),
),
],
),
],
),
),
),
],
);
}
Widget _buildMetadataCard(
BuildContext context,
ColorScheme colorScheme,
int? fileSize,
) {
return Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
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 isDeezer = _spotifyId!.contains('deezer');
return OutlinedButton.icon(
onPressed: () => _openServiceUrl(context),
icon: const Icon(Icons.open_in_new, size: 18),
label: Text(
isDeezer
? context.l10n.trackOpenInDeezer
: context.l10n.trackOpenInSpotify,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
},
),
],
],
),
),
);
}
Future<void> _openServiceUrl(BuildContext context) async {
if (_spotifyId == null) return;
final isDeezer = _spotifyId!.contains('deezer');
final rawId = _spotifyId!.replaceAll('deezer:', '');
final webUrl = isDeezer
? 'https://www.deezer.com/track/$rawId'
: 'https://open.spotify.com/track/$rawId';
final appUri = isDeezer
? Uri.parse('deezer://www.deezer.com/track/$rawId')
: Uri.parse('spotify:track:$rawId');
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(isDeezer ? 'Deezer' : 'Spotify'),
),
),
);
}
}
}
}
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
// Determine audio quality string - prefer stored quality from download
String? audioQualityStr;
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
final fileExt = fileName.contains('.')
? fileName.split('.').last.toUpperCase()
: '';
// Use stored quality from download history if available
if (_quality != null && _quality!.isNotEmpty) {
audioQualityStr = _quality;
} else if (_isLocalItem && _localBitrate != null && _localBitrate! > 0) {
// Lossy local file with bitrate info
final fmt = _localLibraryItem!.format?.toUpperCase() ?? fileExt;
audioQualityStr = '$fmt ${_localBitrate}kbps';
} else if (bitDepth != null && bitDepth! > 0 && sampleRate != null) {
// Lossless file with actual bit depth (FLAC, ALAC)
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
} else {
// Fallback based on file extension for legacy items
if (fileExt == 'MP3') {
audioQualityStr = 'MP3';
} else if (fileExt == 'OPUS' || fileExt == 'OGG') {
audioQualityStr = 'Opus';
} else if (fileExt == 'M4A' || fileExt == 'AAC') {
audioQualityStr = 'AAC';
}
}
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 (discNumber != null && discNumber! > 0)
_MetadataItem(context.l10n.trackDiscNumber, discNumber.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 (isrc != null && isrc!.isNotEmpty) _MetadataItem('ISRC', isrc!),
];
if (!_isLocalItem && _spotifyId != null && _spotifyId!.isNotEmpty) {
final isDeezer = _spotifyId!.contains('deezer');
final cleanId = _spotifyId!.replaceAll('deezer:', '');
items.add(_MetadataItem(isDeezer ? 'Deezer ID' : 'Spotify ID', 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';
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')}';
}
Widget _buildFileInfoCard(
BuildContext context,
ColorScheme colorScheme,
bool fileExists,
int? fileSize,
) {
final displayFilePath = _formatPathForDisplay(cleanFilePath);
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
final fileExtension = fileName.contains('.')
? fileName.split('.').last.toUpperCase()
: 'Unknown';
final lossyBitrateLabel = _extractLossyBitrateLabel(_quality);
return Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
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 ((fileExtension == 'MP3' ||
fileExtension == 'OPUS' ||
fileExtension == 'OGG') &&
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 (_isLocalItem &&
_localBitrate != null &&
_localBitrate! > 0 &&
(fileExtension == 'MP3' ||
fileExtension == 'OPUS' ||
fileExtension == 'OGG'))
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${_localBitrate}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(
'$bitDepth-bit/${(sampleRate! / 1000).toStringAsFixed(1)}kHz',
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: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
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(
'Source: ${_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: _fetchLyrics,
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,
),
),
),
),
// Show "Embed Lyrics" button if lyrics are from online (not already embedded)
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
Center(
child: FilledButton.tonalIcon(
onPressed: _fetchLyrics,
icon: const Icon(Icons.download),
label: Text(context.l10n.trackLoadLyrics),
),
),
],
),
),
);
}
Future<void> _fetchLyrics() async {
if (_lyricsLoading) return;
setState(() {
_lyricsLoading = true;
_lyricsError = null;
_isInstrumental = false;
_lyricsSource = null;
});
try {
// Convert duration from seconds to milliseconds
final durationMs = (duration ?? 0) * 1000;
// First, check if lyrics are embedded in the file
if (_fileExists) {
final embeddedResult =
await PlatformBridge.getLyricsLRCWithSource(
'',
trackName,
artistName,
filePath: cleanFilePath,
durationMs: 0,
).timeout(
const Duration(seconds: 5),
onTimeout: () => <String, dynamic>{'lyrics': '', 'source': ''},
);
final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? '';
final embeddedSource = embeddedResult['source']?.toString() ?? '';
if (embeddedLyrics.isNotEmpty) {
// Lyrics found in file
if (mounted) {
final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics);
setState(() {
_lyrics = cleanLyrics;
_rawLyrics = embeddedLyrics;
_lyricsSource = embeddedSource.isNotEmpty
? embeddedSource
: 'Embedded';
_lyricsEmbedded = true;
_lyricsLoading = false;
});
}
return;
}
}
// No embedded lyrics, fetch from online
final result = await PlatformBridge.getLyricsLRCWithSource(
_spotifyId ?? '',
trackName,
artistName,
filePath: null, // Don't check file again
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) {
// Check for instrumental marker
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; // Keep raw LRC with timestamps for embedding
_lyricsSource = source.isNotEmpty ? source : null;
_lyricsEmbedded = false; // Lyrics from online, not embedded
_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<void> _embedLyrics() async {
if (_isEmbedding || _rawLyrics == null || !_fileExists) return;
setState(() => _isEmbedding = true);
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');
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 = 'Failed to write back to storage';
}
} else {
success = true;
}
} else {
error = result['error']?.toString() ?? 'Failed to embed lyrics';
}
} else if (isMp3 || isOpus) {
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 (_) {}
String? ffmpegResult;
if (isMp3) {
ffmpegResult = await FFmpegService.embedMetadataToMp3(
mp3Path: workingPath,
coverPath: coverPath,
metadata: metadata,
);
} else {
ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: workingPath,
coverPath: coverPath,
metadata: metadata,
);
}
if (ffmpegResult == null) {
error = 'Failed to embed lyrics';
} else if (_isSafFile) {
final ok = await PlatformBridge.writeTempToSaf(
ffmpegResult,
cleanFilePath,
);
success = ok;
if (!ok) {
error = 'Failed to write back to storage';
}
} else {
success = true;
}
} else {
error = 'Unsupported audio format';
}
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 ?? 'Failed to embed lyrics')),
);
}
}
} catch (e) {
if (mounted) {
setState(() => _isEmbedding = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
}
} 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<void> _saveCoverArt() async {
try {
final baseName = _buildSaveBaseName();
if (_isSafFile) {
// SAF file: save to temp, then copy to SAF tree
final tempDir = await Directory.systemTemp.createTemp('cover_');
final tempOutput =
'${tempDir.path}${Platform.pathSeparator}$baseName.jpg';
Map<String, dynamic> result;
if (_coverUrl != null && _coverUrl!.isNotEmpty) {
result = await PlatformBridge.downloadCoverToFile(
_coverUrl!,
tempOutput,
maxQuality: true,
);
} else if (_fileExists) {
result = await PlatformBridge.extractCoverToFile(
cleanFilePath,
tempOutput,
);
} 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;
}
// Write temp file to SAF tree
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 {
// No SAF tree info, keep in temp
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;
}
// Regular file path
final dir = _getFileDirectory();
final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg';
Map<String, dynamic> result;
if (_coverUrl != null && _coverUrl!.isNotEmpty) {
result = await PlatformBridge.downloadCoverToFile(
_coverUrl!,
outputPath,
maxQuality: true,
);
} else if (_fileExists) {
result = await PlatformBridge.extractCoverToFile(
cleanFilePath,
outputPath,
);
} 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<void> _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) {
// SAF file: save to temp, then copy to SAF tree
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,
);
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;
}
// Write temp file to SAF tree
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: 'text/plain',
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;
}
// Regular file path
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,
);
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<void> _reEnrichMetadata() async {
if (!_fileExists) return;
try {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichSearching)),
);
final durationMs = (duration ?? 0) * 1000;
final request = <String, dynamic>{
'file_path': cleanFilePath,
'cover_url': _coverUrl ?? '',
'max_quality': true,
'embed_lyrics': true,
'spotify_id': _spotifyId ?? '',
'track_name': trackName,
'artist_name': artistName,
'album_name': albumName,
'album_artist': albumArtist ?? artistName,
'track_number': trackNumber ?? 0,
'disc_number': discNumber ?? 0,
'release_date': releaseDate ?? '',
'isrc': isrc ?? '',
'genre': genre ?? '',
'label': label ?? '',
'copyright': copyright ?? '',
'duration_ms': durationMs,
'search_online': true,
};
final result = await PlatformBridge.reEnrichFile(request);
final method = result['method'] as String?;
// Update local UI state with enriched metadata from online search
final enriched = result['enriched_metadata'] as Map<String, dynamic>?;
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,
'disc_number': enriched['disc_number'] ?? discNumber,
'isrc': enriched['isrc'] ?? isrc,
'genre': enriched['genre'] ?? genre,
'label': enriched['label'] ?? label,
'copyright': enriched['copyright'] ?? copyright,
};
});
}
if (method == 'native') {
// FLAC - handled natively by Go (SAF write-back handled in Kotlin)
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<String, dynamic>?)?.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('.opus') || lower.endsWith('.ogg')) {
ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget,
coverPath: effectiveCoverPath,
metadata: metadata,
);
}
// For SAF files, copy processed temp file back
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(
'Failed to write back to storage',
),
),
),
);
// Cleanup temp files
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;
}
}
// Cleanup temp files
if (tempPath != null && tempPath.isNotEmpty) {
try {
await File(tempPath).delete();
} catch (_) {}
}
if (ffmpegResult != null) {
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)),
);
}
// Cleanup temp cover from Go backend
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<void> _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,
discNumber: discNumber,
releaseDate: normalizedOrNull(releaseDate),
genre: normalizedOrNull(genre),
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 = <String>[];
for (final line in lines) {
var cleaned = line.trim();
// Skip metadata tags
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() ?? '';
}
// Remove line timestamp, inline word-by-word timestamps, and speaker prefix.
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,
ColorScheme colorScheme,
bool fileExists,
) {
return Row(
children: [
Expanded(
flex: 2,
child: FilledButton.icon(
onPressed: fileExists
? () => _openFile(context, cleanFilePath)
: null,
icon: const Icon(Icons.play_arrow),
label: Text(context.l10n.trackMetadataPlay),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: () => _confirmDelete(context, ref, colorScheme),
icon: Icon(Icons.delete_outline, color: colorScheme.error),
label: Text(
context.l10n.trackMetadataDelete,
style: TextStyle(color: colorScheme.error),
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
side: BorderSide(color: colorScheme.error.withValues(alpha: 0.5)),
),
),
),
],
);
}
void _showOptionsMenu(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
) {
showModalBottomSheet(
context: context,
useRootNavigator: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
isScrollControlled: true,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.7,
),
builder: (context) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.copy),
title: Text(context.l10n.trackCopyFilePath),
onTap: () {
Navigator.pop(context);
_copyToClipboard(context, cleanFilePath);
},
),
if (_fileExists)
ListTile(
leading: const Icon(Icons.edit_outlined),
title: Text(context.l10n.trackEditMetadata),
onTap: () {
Navigator.pop(context);
_showEditMetadataSheet(context, ref, colorScheme);
},
),
if (!_isLocalItem && (_coverUrl != null || _fileExists))
ListTile(
leading: const Icon(Icons.image_outlined),
title: Text(context.l10n.trackSaveCoverArt),
subtitle: Text(context.l10n.trackSaveCoverArtSubtitle),
onTap: () {
Navigator.pop(context);
_saveCoverArt();
},
),
if (!_isLocalItem)
ListTile(
leading: const Icon(Icons.lyrics_outlined),
title: Text(context.l10n.trackSaveLyrics),
subtitle: Text(context.l10n.trackSaveLyricsSubtitle),
onTap: () {
Navigator.pop(context);
_saveLyrics();
},
),
if (_fileExists)
ListTile(
leading: const Icon(Icons.travel_explore),
title: Text(context.l10n.trackReEnrich),
subtitle: Text(context.l10n.trackReEnrichOnlineSubtitle),
onTap: () {
Navigator.pop(context);
_reEnrichMetadata();
},
),
if (_fileExists && _isConvertibleFormat)
ListTile(
leading: const Icon(Icons.swap_horiz),
title: Text(context.l10n.trackConvertFormat),
subtitle: Text(context.l10n.trackConvertFormatSubtitle),
onTap: () {
Navigator.pop(context);
_showConvertSheet(context);
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.share),
title: Text(context.l10n.trackMetadataShare),
onTap: () {
Navigator.pop(context);
_shareFile(context);
},
),
ListTile(
leading: Icon(Icons.delete, color: colorScheme.error),
title: Text(
context.l10n.trackRemoveFromDevice,
style: TextStyle(color: colorScheme.error),
),
onTap: () {
Navigator.pop(context);
_confirmDelete(context, ref, colorScheme);
},
),
const SizedBox(height: 16),
],
),
),
),
);
}
/// Whether the current file format supports conversion
bool get _isConvertibleFormat {
final lower = cleanFilePath.toLowerCase();
return lower.endsWith('.flac') ||
lower.endsWith('.mp3') ||
lower.endsWith('.opus') ||
lower.endsWith('.ogg');
}
String get _currentFileFormat {
final lower = cleanFilePath.toLowerCase();
if (lower.endsWith('.flac')) return 'FLAC';
if (lower.endsWith('.mp3')) return 'MP3';
if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'Opus';
return 'Unknown';
}
Map<String, String> _buildFallbackMetadata() {
return {
'TITLE': trackName,
'ARTIST': artistName,
'ALBUM': albumName,
if (albumArtist != null && albumArtist!.isNotEmpty)
'ALBUMARTIST': albumArtist!,
if (trackNumber != null) 'TRACKNUMBER': trackNumber.toString(),
if (discNumber != null) 'DISCNUMBER': discNumber.toString(),
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!,
};
}
Map<String, String> _mapMetadataForTagEmbed(Map<String, dynamic> source) {
final mapped = <String, String>{};
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']);
final trackNumber = source['track_number'];
if (trackNumber != null && trackNumber.toString() != '0') {
put('TRACKNUMBER', trackNumber);
}
final discNumber = source['disc_number'];
if (discNumber != null && discNumber.toString() != '0') {
put('DISCNUMBER', discNumber);
}
return mapped;
}
String _buildConvertedQualityLabel(String targetFormat, String bitrate) {
final normalizedBitrate = bitrate.trim().toLowerCase();
return '${targetFormat.toUpperCase()} $normalizedBitrate';
}
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;
}
void _showConvertSheet(BuildContext context) {
final currentFormat = _currentFileFormat;
// Available target formats (exclude current)
final formats = <String>[
'MP3',
'Opus',
].where((f) => f != currentFormat).toList();
if (currentFormat == 'FLAC') {
// FLAC can convert to both
}
String selectedFormat = formats.first;
String selectedBitrate = selectedFormat == 'Opus' ? '128k' : '320k';
showModalBottomSheet(
context: context,
useRootNavigator: 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 bitrates = ['128k', '192k', '256k', '320k'];
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(
context.l10n.trackConvertTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 20),
Text(
context.l10n.trackConvertTargetFormat,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Row(
children: formats.map((format) {
final isSelected = format == selectedFormat;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() {
selectedFormat = format;
// Reset bitrate to default for format
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
});
}
},
),
);
}).toList(),
),
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
return ChoiceChip(
label: Text(br),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
}
},
);
}).toList(),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () {
Navigator.pop(context);
_confirmAndConvert(
context: this.context,
sourceFormat: currentFormat,
targetFormat: selectedFormat,
bitrate: selectedBitrate,
);
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'$currentFormat -> $selectedFormat @ $selectedBitrate',
),
),
),
const SizedBox(height: 8),
],
),
),
);
},
);
},
);
}
void _confirmAndConvert({
required BuildContext context,
required String sourceFormat,
required String targetFormat,
required String bitrate,
}) {
showDialog(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(dialogContext.l10n.trackConvertConfirmTitle),
content: Text(
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,
);
},
child: Text(dialogContext.l10n.trackConvertFormat),
),
],
);
},
);
}
Future<void> _performConversion({
required String targetFormat,
required String bitrate,
}) async {
if (_isConverting) return;
setState(() => _isConverting = true);
try {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackConvertConverting)),
);
final metadata = _buildFallbackMetadata();
try {
final result = await PlatformBridge.readFileMetadata(cleanFilePath);
if (result['error'] == null) {
result.forEach((key, value) {
if (key == 'error' || value == null) return;
final normalizedValue = value.toString().trim();
if (normalizedValue.isEmpty) return;
metadata[key.toUpperCase()] = normalizedValue;
});
} else {
_log.w('readFileMetadata returned error, using fallback metadata');
}
} catch (e) {
_log.w('readFileMetadata threw, using fallback metadata: $e');
}
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) {
// Copy SAF file to temp for processing
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,
deleteOriginal: !isSaf, // Don't delete temp copy for SAF, we handle it
);
// Cleanup cover temp
if (coverPath != null) {
try {
await File(coverPath).delete();
} catch (_) {}
}
if (newPath == null) {
// Cleanup SAF temp if needed
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 newQuality = _buildConvertedQualityLabel(targetFormat, bitrate);
if (isSaf) {
final treeUri = _downloadItem?.downloadTreeUri;
final relativeDir = _downloadItem?.safRelativeDir ?? '';
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 oldFileName =
(_downloadItem?.safFileName != null &&
_downloadItem!.safFileName!.isNotEmpty)
? _downloadItem!.safFileName!
: _extractFileNameFromPathOrUri(cleanFilePath);
final dotIdx = oldFileName.lastIndexOf('.');
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
final newExt = targetFormat.toLowerCase() == 'opus' ? '.opus' : '.mp3';
final newFileName = '$baseName$newExt';
final mimeType = targetFormat.toLowerCase() == 'opus'
? 'audio/opus'
: 'audio/mpeg';
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;
}
final deletedOriginal = await PlatformBridge.safDelete(
cleanFilePath,
).catchError((_) => false);
if (deletedOriginal != true) {
_log.w('Converted SAF file created but failed deleting original URI');
}
// Update history with new SAF info
if (!_isLocalItem) {
await HistoryDatabase.instance.updateFilePath(
_downloadItem!.id,
safUri,
newSafFileName: newFileName,
newQuality: newQuality,
clearAudioSpecs: true,
);
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
}
// Cleanup temp files
try {
await File(newPath).delete();
} catch (_) {}
if (safTempPath != null) {
try {
await File(safTempPath).delete();
} catch (_) {}
}
} else {
// Regular file: update history with new path
if (!_isLocalItem) {
await HistoryDatabase.instance.updateFilePath(
_downloadItem!.id,
newPath,
newQuality: newQuality,
clearAudioSpecs: true,
);
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
}
}
if (mounted) {
setState(() => _isConverting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.trackConvertSuccess(targetFormat)),
),
);
// Pop and let the caller refresh
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 {
// Read current metadata from file, fall back to item data on failure
Map<String, dynamic>? fileMetadata;
try {
final result = await PlatformBridge.readFileMetadata(cleanFilePath);
if (result['error'] == null) {
fileMetadata = result;
}
} catch (e) {
debugPrint('readFileMetadata failed, using item data: $e');
}
// Build initial values map — prefer file metadata, fall back to item data
String val(String key, String? fallback) {
final v = fileMetadata?[key]?.toString();
return (v != null && v.isNotEmpty) ? v : (fallback ?? '');
}
final initialValues = <String, String>{
'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(),
'disc_number': (fileMetadata?['disc_number'] ?? discNumber ?? '')
.toString(),
'genre': val('genre', genre),
'isrc': val('isrc', isrc),
'label': val('label', label),
'copyright': val('copyright', copyright),
'composer': fileMetadata?['composer']?.toString() ?? '',
'comment': fileMetadata?['comment']?.toString() ?? '',
};
if (!context.mounted) return;
final saved = await showModalBottomSheet<bool>(
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,
),
);
if (saved == true && mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Metadata saved successfully')),
);
// Re-read metadata from file to refresh the display
try {
final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath);
setState(() => _editedMetadata = refreshed);
} catch (_) {
setState(() {});
}
await _refreshEmbeddedCoverPreview(force: true);
_markMetadataChanged();
await _syncDownloadHistoryMetadata();
}
}
void _confirmDelete(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
) {
showDialog(
context: context,
useRootNavigator: false,
builder: (context) => AlertDialog(
title: Text(context.l10n.trackDeleteConfirmTitle),
content: Text(context.l10n.trackDeleteConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () async {
if (_isLocalItem) {
// For local items, just delete the file
try {
await deleteFile(cleanFilePath);
} catch (e) {
debugPrint('Failed to delete file: $e');
}
// Also remove from local library database
// 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 (context.mounted) {
Navigator.pop(context);
Navigator.pop(context);
}
},
child: Text(
context.l10n.dialogDelete,
style: TextStyle(color: colorScheme.error),
),
),
],
),
);
}
Future<void> _openFile(BuildContext context, String filePath) async {
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<void> _shareFile(BuildContext context) async {
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 _EditMetadataSheet extends StatefulWidget {
final ColorScheme colorScheme;
final Map<String, String> initialValues;
final String filePath;
const _EditMetadataSheet({
required this.colorScheme,
required this.initialValues,
required this.filePath,
});
@override
State<_EditMetadataSheet> createState() => _EditMetadataSheetState();
}
class _EditMetadataSheetState extends State<_EditMetadataSheet> {
bool _saving = false;
bool _showAdvanced = false;
String? _selectedCoverPath;
String? _selectedCoverTempDir;
String? _selectedCoverName;
String? _currentCoverPath;
String? _currentCoverTempDir;
bool _loadingCurrentCover = false;
late final TextEditingController _titleCtrl;
late final TextEditingController _artistCtrl;
late final TextEditingController _albumCtrl;
late final TextEditingController _albumArtistCtrl;
late final TextEditingController _dateCtrl;
late final TextEditingController _trackNumCtrl;
late final TextEditingController _discNumCtrl;
late final TextEditingController _genreCtrl;
late final TextEditingController _isrcCtrl;
late final TextEditingController _labelCtrl;
late final TextEditingController _copyrightCtrl;
late final TextEditingController _composerCtrl;
late final TextEditingController _commentCtrl;
bool _hasValue(String? value) => value != null && value.trim().isNotEmpty;
String _resolveImageExtension(String? ext, Uint8List? bytes) {
final normalized = (ext ?? '').toLowerCase();
if (normalized == 'png' ||
normalized == 'jpg' ||
normalized == 'jpeg' ||
normalized == 'webp') {
return normalized == 'jpeg' ? 'jpg' : normalized;
}
if (bytes != null && bytes.length >= 8) {
if (bytes[0] == 0x89 &&
bytes[1] == 0x50 &&
bytes[2] == 0x4E &&
bytes[3] == 0x47) {
return 'png';
}
if (bytes[0] == 0xFF && bytes[1] == 0xD8) {
return 'jpg';
}
if (bytes.length >= 12 &&
bytes[0] == 0x52 &&
bytes[1] == 0x49 &&
bytes[2] == 0x46 &&
bytes[3] == 0x46 &&
bytes[8] == 0x57 &&
bytes[9] == 0x45 &&
bytes[10] == 0x42 &&
bytes[11] == 0x50) {
return 'webp';
}
}
return 'jpg';
}
Future<void> _cleanupSelectedCoverTemp() async {
final dirPath = _selectedCoverTempDir;
_selectedCoverPath = null;
_selectedCoverTempDir = null;
_selectedCoverName = null;
if (dirPath == null || dirPath.isEmpty) return;
try {
final dir = Directory(dirPath);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
} catch (_) {}
}
void _cleanupSelectedCoverTempSync() {
final dirPath = _selectedCoverTempDir;
_selectedCoverPath = null;
_selectedCoverTempDir = null;
_selectedCoverName = null;
if (dirPath == null || dirPath.isEmpty) return;
try {
final dir = Directory(dirPath);
if (dir.existsSync()) {
dir.deleteSync(recursive: true);
}
} catch (_) {}
}
void _cleanupCurrentCoverTempSync() {
final dirPath = _currentCoverTempDir;
_currentCoverPath = null;
_currentCoverTempDir = null;
if (dirPath == null || dirPath.isEmpty) return;
try {
final dir = Directory(dirPath);
if (dir.existsSync()) {
dir.deleteSync(recursive: true);
}
} catch (_) {}
}
Future<void> _loadCurrentCoverPreview() async {
if (_loadingCurrentCover) return;
setState(() => _loadingCurrentCover = true);
String? newCoverPath;
String? newCoverDir;
try {
final tempDir = await Directory.systemTemp.createTemp(
'edit_existing_cover_',
);
final coverOutput =
'${tempDir.path}${Platform.pathSeparator}existing_cover.jpg';
final coverResult = await PlatformBridge.extractCoverToFile(
widget.filePath,
coverOutput,
);
if (coverResult['error'] == null && await File(coverOutput).exists()) {
newCoverPath = coverOutput;
newCoverDir = tempDir.path;
} else {
try {
await tempDir.delete(recursive: true);
} catch (_) {}
}
} catch (_) {}
if (!mounted) {
if (newCoverDir != null) {
try {
final dir = Directory(newCoverDir);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
} catch (_) {}
}
return;
}
final oldDir = _currentCoverTempDir;
setState(() {
_currentCoverPath = newCoverPath;
_currentCoverTempDir = newCoverDir;
_loadingCurrentCover = false;
});
if (oldDir != null && oldDir.isNotEmpty && oldDir != newCoverDir) {
try {
final dir = Directory(oldDir);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
} catch (_) {}
}
}
Future<void> _pickCoverImage() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: false,
withData: true,
);
if (result == null || result.files.isEmpty) return;
final picked = result.files.first;
final bytes = picked.bytes;
final sourcePath = picked.path;
final extension = _resolveImageExtension(picked.extension, bytes);
final tempDir = await Directory.systemTemp.createTemp('edit_cover_');
final tempPath =
'${tempDir.path}${Platform.pathSeparator}cover.$extension';
if (bytes != null && bytes.isNotEmpty) {
await File(tempPath).writeAsBytes(bytes, flush: true);
} else if (sourcePath != null && sourcePath.isNotEmpty) {
final sourceFile = File(sourcePath);
if (!await sourceFile.exists()) {
throw Exception('Selected image is not accessible');
}
await sourceFile.copy(tempPath);
} else {
throw Exception('Unable to read selected image');
}
await _cleanupSelectedCoverTemp();
if (!mounted) {
try {
await tempDir.delete(recursive: true);
} catch (_) {}
return;
}
setState(() {
_selectedCoverPath = tempPath;
_selectedCoverTempDir = tempDir.path;
_selectedCoverName = picked.name;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to pick cover: $e')));
}
}
@override
void initState() {
super.initState();
final v = widget.initialValues;
_titleCtrl = TextEditingController(text: v['title'] ?? '');
_artistCtrl = TextEditingController(text: v['artist'] ?? '');
_albumCtrl = TextEditingController(text: v['album'] ?? '');
_albumArtistCtrl = TextEditingController(text: v['album_artist'] ?? '');
_dateCtrl = TextEditingController(text: v['date'] ?? '');
_trackNumCtrl = TextEditingController(text: v['track_number'] ?? '');
_discNumCtrl = TextEditingController(text: v['disc_number'] ?? '');
_genreCtrl = TextEditingController(text: v['genre'] ?? '');
_isrcCtrl = TextEditingController(text: v['isrc'] ?? '');
_labelCtrl = TextEditingController(text: v['label'] ?? '');
_copyrightCtrl = TextEditingController(text: v['copyright'] ?? '');
_composerCtrl = TextEditingController(text: v['composer'] ?? '');
_commentCtrl = TextEditingController(text: v['comment'] ?? '');
_loadCurrentCoverPreview();
}
@override
void dispose() {
_cleanupSelectedCoverTempSync();
_cleanupCurrentCoverTempSync();
_titleCtrl.dispose();
_artistCtrl.dispose();
_albumCtrl.dispose();
_albumArtistCtrl.dispose();
_dateCtrl.dispose();
_trackNumCtrl.dispose();
_discNumCtrl.dispose();
_genreCtrl.dispose();
_isrcCtrl.dispose();
_labelCtrl.dispose();
_copyrightCtrl.dispose();
_composerCtrl.dispose();
_commentCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() => _saving = true);
final metadata = <String, String>{
'title': _titleCtrl.text,
'artist': _artistCtrl.text,
'album': _albumCtrl.text,
'album_artist': _albumArtistCtrl.text,
'date': _dateCtrl.text,
'track_number': _trackNumCtrl.text,
'disc_number': _discNumCtrl.text,
'genre': _genreCtrl.text,
'isrc': _isrcCtrl.text,
'label': _labelCtrl.text,
'copyright': _copyrightCtrl.text,
'composer': _composerCtrl.text,
'comment': _commentCtrl.text,
'cover_path': _selectedCoverPath ?? '',
};
try {
final result = await PlatformBridge.editFileMetadata(
widget.filePath,
metadata,
);
if (result['error'] != null) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('${result['error']}')));
}
setState(() => _saving = false);
return;
}
final method = result['method'] as String?;
if (method == 'ffmpeg') {
// MP3/Opus: use FFmpeg to write metadata
// 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 ?? widget.filePath;
final lower = widget.filePath.toLowerCase();
final isMp3 = lower.endsWith('.mp3');
final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg');
final vorbisMap = <String, String>{};
if (metadata['title']?.isNotEmpty == true) {
vorbisMap['TITLE'] = metadata['title']!;
}
if (metadata['artist']?.isNotEmpty == true) {
vorbisMap['ARTIST'] = metadata['artist']!;
}
if (metadata['album']?.isNotEmpty == true) {
vorbisMap['ALBUM'] = metadata['album']!;
}
if (metadata['album_artist']?.isNotEmpty == true) {
vorbisMap['ALBUMARTIST'] = metadata['album_artist']!;
}
if (metadata['date']?.isNotEmpty == true) {
vorbisMap['DATE'] = metadata['date']!;
}
if (metadata['track_number']?.isNotEmpty == true &&
metadata['track_number'] != '0') {
vorbisMap['TRACKNUMBER'] = metadata['track_number']!;
}
if (metadata['disc_number']?.isNotEmpty == true &&
metadata['disc_number'] != '0') {
vorbisMap['DISCNUMBER'] = metadata['disc_number']!;
}
if (metadata['genre']?.isNotEmpty == true) {
vorbisMap['GENRE'] = metadata['genre']!;
}
if (metadata['isrc']?.isNotEmpty == true) {
vorbisMap['ISRC'] = metadata['isrc']!;
}
if (metadata['label']?.isNotEmpty == true) {
vorbisMap['ORGANIZATION'] = metadata['label']!;
}
if (metadata['copyright']?.isNotEmpty == true) {
vorbisMap['COPYRIGHT'] = metadata['copyright']!;
}
if (metadata['composer']?.isNotEmpty == true) {
vorbisMap['COMPOSER'] = metadata['composer']!;
}
if (metadata['comment']?.isNotEmpty == true) {
vorbisMap['COMMENT'] = metadata['comment']!;
}
String? existingCoverPath = _selectedCoverPath ?? _currentCoverPath;
String? extractedCoverPath;
if (existingCoverPath == null || existingCoverPath.isEmpty) {
// Preserve current embedded cover when user does not pick a new one.
try {
final tempDir = await Directory.systemTemp.createTemp('cover_');
final coverOutput =
'${tempDir.path}${Platform.pathSeparator}cover.jpg';
final coverResult = await PlatformBridge.extractCoverToFile(
ffmpegTarget,
coverOutput,
);
if (coverResult['error'] == null) {
existingCoverPath = coverOutput;
extractedCoverPath = coverOutput;
} else {
try {
await tempDir.delete(recursive: true);
} catch (_) {}
}
} catch (_) {
// No cover to preserve, continue without
}
}
String? ffmpegResult;
if (isMp3) {
ffmpegResult = await FFmpegService.embedMetadataToMp3(
mp3Path: ffmpegTarget,
coverPath: existingCoverPath,
metadata: vorbisMap,
);
} else if (isOpus) {
ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget,
coverPath: existingCoverPath,
metadata: vorbisMap,
);
}
// Cleanup extracted temp cover (manual selected cover is cleaned on dispose)
if (extractedCoverPath != null && extractedCoverPath.isNotEmpty) {
final extractedFile = File(extractedCoverPath);
try {
await extractedFile.delete();
} catch (_) {}
try {
final dir = extractedFile.parent;
if (await dir.exists()) {
await dir.delete(recursive: true);
}
} catch (_) {}
}
if (ffmpegResult == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to save metadata via FFmpeg'),
),
);
}
setState(() => _saving = false);
return;
}
// For SAF files, copy the processed temp file back
if (tempPath != null && safUri != null) {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
if (!ok && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to write metadata back to storage'),
),
);
setState(() => _saving = false);
return;
}
}
}
if (mounted) {
Navigator.pop(context, true);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to save metadata: $e')));
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final cs = widget.colorScheme;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) => Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
Expanded(
child: Text(
'Edit Metadata',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (_saving)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
FilledButton(onPressed: _save, child: const Text('Save')),
],
),
),
const SizedBox(height: 12),
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 24),
children: [
const SizedBox(height: 6),
_buildCoverEditor(cs),
_field('Title', _titleCtrl),
_field('Artist', _artistCtrl),
_field('Album', _albumCtrl),
_field('Album Artist', _albumArtistCtrl),
_field('Date', _dateCtrl, hint: 'YYYY-MM-DD or YYYY'),
Row(
children: [
Expanded(
child: _field(
'Track #',
_trackNumCtrl,
keyboard: TextInputType.number,
),
),
const SizedBox(width: 12),
Expanded(
child: _field(
'Disc #',
_discNumCtrl,
keyboard: TextInputType.number,
),
),
],
),
_field('Genre', _genreCtrl),
_field('ISRC', _isrcCtrl),
// Advanced fields toggle
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: InkWell(
onTap: () =>
setState(() => _showAdvanced = !_showAdvanced),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(
_showAdvanced
? Icons.expand_less
: Icons.expand_more,
size: 20,
color: cs.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
'Advanced',
style: Theme.of(context).textTheme.labelLarge
?.copyWith(color: cs.onSurfaceVariant),
),
],
),
),
),
),
if (_showAdvanced) ...[
_field('Label', _labelCtrl),
_field('Copyright', _copyrightCtrl),
_field('Composer', _composerCtrl),
_field('Comment', _commentCtrl, maxLines: 3),
],
const SizedBox(height: 24),
],
),
),
],
),
),
);
}
Widget _buildCoverEditor(ColorScheme cs) {
final hasSelectedCover = _hasValue(_selectedCoverPath);
final hasCurrentCover = _hasValue(_currentCoverPath);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Cover Art',
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(color: cs.onSurface),
),
const SizedBox(height: 6),
if (_loadingCurrentCover)
const LinearProgressIndicator(minHeight: 2)
else if (!hasCurrentCover)
Text(
'No embedded album art found',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _saving ? null : _pickCoverImage,
icon: const Icon(Icons.image_outlined),
label: Text(
hasSelectedCover ? 'Replace Cover' : 'Pick Cover',
),
),
),
if (hasSelectedCover) ...[
const SizedBox(width: 8),
IconButton(
tooltip: 'Clear selected cover',
onPressed: _saving
? null
: () async {
await _cleanupSelectedCoverTemp();
if (!mounted) return;
setState(() {});
},
icon: const Icon(Icons.close),
),
],
],
),
if (hasCurrentCover || hasSelectedCover) ...[
const SizedBox(height: 12),
Row(
children: [
if (hasCurrentCover)
Expanded(
child: _buildCoverPreviewTile(
cs: cs,
path: _currentCoverPath!,
label: 'Current cover',
),
),
if (hasCurrentCover && hasSelectedCover)
const SizedBox(width: 12),
if (hasSelectedCover)
Expanded(
child: _buildCoverPreviewTile(
cs: cs,
path: _selectedCoverPath!,
label: _selectedCoverName ?? 'Selected cover',
),
),
],
),
if (hasSelectedCover) ...[
const SizedBox(height: 8),
Text(
'The selected cover will replace the current embedded cover when you tap Save.',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
],
],
],
),
),
);
}
Widget _buildCoverPreviewTile({
required ColorScheme cs,
required String path,
required String label,
}) {
return Column(
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: cs.shadow.withValues(alpha: 0.15),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
File(path),
height: 160,
width: 160,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(
width: 160,
height: 160,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.broken_image,
color: cs.onSurfaceVariant,
size: 32,
),
),
),
),
),
const SizedBox(height: 8),
Text(
label,
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(color: cs.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
Widget _field(
String label,
TextEditingController controller, {
String? hint,
TextInputType? keyboard,
int maxLines = 1,
}) {
final cs = widget.colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
keyboardType: keyboard,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
hintText: hint,
filled: true,
fillColor: cs.surfaceContainerHighest.withValues(alpha: 0.5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: cs.outlineVariant.withValues(alpha: 0.5),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: cs.outlineVariant.withValues(alpha: 0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: cs.primary, width: 2),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
),
);
}
}
class _MetadataItem {
final String label;
final String value;
_MetadataItem(this.label, this.value);
}