mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-26 05:51:18 +02:00
refactor(metadata): split _TrackMetadataScreenState into part files
track_metadata_screen.dart was 5,470 lines, most of it one State class mixing five surfaces. Move four cohesive method clusters into private extensions on _TrackMetadataScreenState in new part files: header/info cards, lyrics + save/re-enrich flows, audio conversion + CUE split, and file actions. Same library, so private state access is unchanged; setState goes through a _setState forwarder as in queue_tab. The screen file is now 1,575 lines.
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
part of 'track_metadata_screen.dart';
|
||||
|
||||
extension _TrackMetadataFileActions on _TrackMetadataScreenState {
|
||||
void _showEditMetadataSheet(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ColorScheme colorScheme,
|
||||
) async {
|
||||
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');
|
||||
}
|
||||
|
||||
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(),
|
||||
'total_tracks': (fileMetadata?['total_tracks'] ?? totalTracks ?? '')
|
||||
.toString(),
|
||||
'disc_number': (fileMetadata?['disc_number'] ?? discNumber ?? '')
|
||||
.toString(),
|
||||
'total_discs': (fileMetadata?['total_discs'] ?? totalDiscs ?? '')
|
||||
.toString(),
|
||||
'genre': val('genre', genre),
|
||||
'isrc': val('isrc', isrc),
|
||||
'label': val('label', label),
|
||||
'copyright': val('copyright', copyright),
|
||||
'composer': val('composer', composer),
|
||||
'comment': fileMetadata?['comment']?.toString() ?? '',
|
||||
'lyrics': fileMetadata?['lyrics']?.toString() ?? '',
|
||||
};
|
||||
|
||||
final initialDurationSeconds =
|
||||
readPositiveInt(fileMetadata?['duration']) ?? duration ?? 0;
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
final saved = await showModalBottomSheet<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,
|
||||
sourceTrackId: _spotifyId,
|
||||
durationMs: initialDurationSeconds > 0
|
||||
? initialDurationSeconds * 1000
|
||||
: 0,
|
||||
artistTagMode: ref.read(settingsProvider).artistTagMode,
|
||||
),
|
||||
);
|
||||
|
||||
if (saved == true && mounted) {
|
||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||
SnackBar(content: Text(this.context.l10n.snackbarMetadataSaved)),
|
||||
);
|
||||
try {
|
||||
final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath);
|
||||
final refreshedLyrics = refreshed['lyrics']?.toString().trim() ?? '';
|
||||
_setState(() {
|
||||
_editedMetadata = refreshed;
|
||||
_lyricsError = null;
|
||||
_isInstrumental = false;
|
||||
_embeddedLyricsChecked = true;
|
||||
if (refreshedLyrics.isNotEmpty) {
|
||||
_lyrics = _cleanLrcForDisplay(refreshedLyrics);
|
||||
_rawLyrics = refreshedLyrics;
|
||||
_lyricsSource = context.l10n.trackLyricsEmbeddedSource;
|
||||
_lyricsEmbedded = true;
|
||||
} else {
|
||||
_lyrics = null;
|
||||
_rawLyrics = null;
|
||||
_lyricsSource = null;
|
||||
_lyricsEmbedded = false;
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
_setState(() {});
|
||||
}
|
||||
await _refreshEmbeddedCoverPreview(force: true);
|
||||
_markMetadataChanged();
|
||||
await _syncDownloadHistoryMetadata();
|
||||
}
|
||||
}
|
||||
|
||||
void _confirmDelete(
|
||||
BuildContext screenContext,
|
||||
WidgetRef ref,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
showDialog<void>(
|
||||
context: screenContext,
|
||||
useRootNavigator: true,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(dialogContext.l10n.trackDeleteConfirmTitle),
|
||||
content: Text(dialogContext.l10n.trackDeleteConfirmMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(dialogContext.l10n.dialogCancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
if (_isLocalItem) {
|
||||
if (_isCueVirtualTrack && _localLibraryItem != null) {
|
||||
await ref
|
||||
.read(localLibraryProvider.notifier)
|
||||
.removeItem(_localLibraryItem!.id);
|
||||
} else {
|
||||
try {
|
||||
await deleteFile(cleanFilePath);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to delete file: $e');
|
||||
}
|
||||
if (_localLibraryItem != null) {
|
||||
await ref
|
||||
.read(localLibraryProvider.notifier)
|
||||
.removeItem(_localLibraryItem!.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await deleteFile(cleanFilePath);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to delete file: $e');
|
||||
}
|
||||
|
||||
ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.removeFromHistory(_downloadItem!.id);
|
||||
}
|
||||
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final navigator = Navigator.of(context);
|
||||
if (navigator.canPop()) {
|
||||
navigator.pop(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
dialogContext.l10n.dialogDelete,
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _closeOptionsMenuAndRun(BuildContext sheetContext, VoidCallback action) {
|
||||
Navigator.pop(sheetContext);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
action();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openFile(BuildContext context, String filePath) async {
|
||||
if (isCueVirtualPath(filePath)) {
|
||||
_showCueVirtualTrackSnackBar(context);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.playLocalPath(
|
||||
path: filePath,
|
||||
title: trackName,
|
||||
artist: artistName,
|
||||
album: albumName,
|
||||
coverUrl: _coverUrl ?? '',
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _copyToClipboard(BuildContext context, String text) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.trackCopiedToClipboard),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _shareFile(BuildContext context) async {
|
||||
if (_isCueVirtualTrack) {
|
||||
_showCueVirtualTrackSnackBar(context);
|
||||
return;
|
||||
}
|
||||
|
||||
String sharePath = cleanFilePath;
|
||||
if (!await fileExists(sharePath)) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarFileNotFound)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final shareTitle = '$trackName - $artistName';
|
||||
|
||||
// For SAF content URIs, use native share intent directly (zero-copy)
|
||||
if (isContentUri(sharePath)) {
|
||||
try {
|
||||
await PlatformBridge.shareContentUri(sharePath, title: shareTitle);
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarCannotOpenFile('Failed to share file'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(files: [XFile(sharePath)], text: shareTitle),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatFullDate(DateTime date) {
|
||||
return '${date.day} ${_TrackMetadataScreenState._months[date.month - 1]} ${date.year}, '
|
||||
'${date.hour.toString().padLeft(2, '0')}:'
|
||||
'${date.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _formatFileSize(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||
}
|
||||
|
||||
IconData _getServiceIcon(String service) {
|
||||
switch (service.toLowerCase()) {
|
||||
case 'tidal':
|
||||
return Icons.waves;
|
||||
case 'qobuz':
|
||||
return Icons.album;
|
||||
case 'amazon':
|
||||
return Icons.shopping_cart;
|
||||
default:
|
||||
return Icons.cloud_download;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getServiceColor(String service, ColorScheme colorScheme) {
|
||||
switch (service.toLowerCase()) {
|
||||
case 'tidal':
|
||||
return const Color(0xFF0077B5);
|
||||
case 'qobuz':
|
||||
return const Color(0xFF0052CC);
|
||||
case 'amazon':
|
||||
return const Color(0xFFFF9900);
|
||||
default:
|
||||
return colorScheme.primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
part of 'track_metadata_screen.dart';
|
||||
|
||||
extension _TrackMetadataCards on _TrackMetadataScreenState {
|
||||
Widget _buildAnimatedTrackContent(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
final currentKey = ValueKey<String>('metadata_content_$_itemId');
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 240),
|
||||
reverseDuration: const Duration(milliseconds: 180),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
layoutBuilder: (currentChild, previousChildren) {
|
||||
return Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: <Widget>[...previousChildren, ?currentChild],
|
||||
);
|
||||
},
|
||||
transitionBuilder: (child, animation) {
|
||||
if (_metadataTransitionDirection == 0) {
|
||||
return child;
|
||||
}
|
||||
final isIncoming = child.key == currentKey;
|
||||
final direction = _metadataTransitionDirection.toDouble();
|
||||
final begin = Offset(
|
||||
isIncoming ? 0.18 * direction : -0.18 * direction,
|
||||
0,
|
||||
);
|
||||
final curved = CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
return ClipRect(
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: begin,
|
||||
end: Offset.zero,
|
||||
).animate(curved),
|
||||
child: FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
key: currentKey,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildMetadataCard(context, colorScheme, _fileSize),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildFileInfoCard(context, colorScheme, _fileExists, _fileSize),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildLyricsCard(context, colorScheme),
|
||||
|
||||
if (_fileExists) ...[
|
||||
const SizedBox(height: 16),
|
||||
AudioAnalysisCard(filePath: _filePath),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildActionButtons(context, ref, colorScheme, _fileExists),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderBackground(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
double expandedHeight,
|
||||
bool showContent,
|
||||
) {
|
||||
final cacheWidth = coverCacheWidthForViewport(context);
|
||||
final coverChild = _hasPath(_embeddedCoverPreviewPath)
|
||||
? Image.file(
|
||||
File(_embeddedCoverPreviewPath!),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheWidth,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
|
||||
)
|
||||
: _coverUrl != null
|
||||
? CachedCoverImage(
|
||||
imageUrl: _coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheWidth,
|
||||
placeholder: (_, _) => Container(color: colorScheme.surface),
|
||||
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
|
||||
)
|
||||
: _localCoverPath != null && _localCoverPath!.isNotEmpty
|
||||
? Image.file(
|
||||
File(_localCoverPath!),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheWidth,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 80,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Hero(
|
||||
tag: _coverHeroTag,
|
||||
child: Material(color: Colors.transparent, child: coverChild),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: expandedHeight * 0.65,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.85),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 40,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: showContent ? 1.0 : 0.0,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
trackName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.2,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
artistName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
albumName,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (_displayAudioQuality != null &&
|
||||
_displayAudioQuality!.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_displayAudioQuality!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (duration != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_formatDuration(duration!),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_service != 'local')
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_service[0].toUpperCase() + _service.substring(1),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.folder,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
context.l10n.librarySourceLocal,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_hasCheckedFile && !_fileExists)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_rounded,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
context.l10n.trackFileNotFound,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
RoundedRectangleBorder _sectionCardShape(ColorScheme colorScheme) {
|
||||
return RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetadataCard(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
int? fileSize,
|
||||
) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: settingsGroupColor(context),
|
||||
shape: _sectionCardShape(colorScheme),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
context.l10n.trackMetadata,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildMetadataGrid(context, colorScheme),
|
||||
|
||||
if (_spotifyId != null && _spotifyId!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final openService = _serviceForTrackId(
|
||||
_spotifyId!,
|
||||
fallbackService: _service.toLowerCase(),
|
||||
);
|
||||
String buttonLabel;
|
||||
if (openService == 'deezer') {
|
||||
buttonLabel = context.l10n.trackOpenInDeezer;
|
||||
} else if (openService == 'amazon') {
|
||||
buttonLabel = context.l10n.trackOpenInService(
|
||||
'Amazon Music',
|
||||
);
|
||||
} else if (openService == 'tidal') {
|
||||
buttonLabel = context.l10n.trackOpenInService('Tidal');
|
||||
} else if (openService == 'qobuz') {
|
||||
buttonLabel = context.l10n.trackOpenInService('Qobuz');
|
||||
} else {
|
||||
buttonLabel = context.l10n.trackOpenInSpotify;
|
||||
}
|
||||
return OutlinedButton.icon(
|
||||
onPressed: () => _openServiceUrl(context),
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
label: Text(buttonLabel),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openServiceUrl(BuildContext context) async {
|
||||
if (_spotifyId == null) return;
|
||||
|
||||
final openService = _serviceForTrackId(
|
||||
_spotifyId!,
|
||||
fallbackService: _service.toLowerCase(),
|
||||
);
|
||||
final rawId = _displayServiceTrackId(_spotifyId!);
|
||||
|
||||
String webUrl;
|
||||
Uri? appUri;
|
||||
String serviceName;
|
||||
|
||||
if (openService == 'deezer') {
|
||||
webUrl = 'https://www.deezer.com/track/$rawId';
|
||||
appUri = Uri.parse('deezer://www.deezer.com/track/$rawId');
|
||||
serviceName = 'Deezer';
|
||||
} else if (openService == 'amazon') {
|
||||
webUrl = 'https://music.amazon.com/search/$rawId';
|
||||
appUri = Uri.parse('amznm://search/$rawId');
|
||||
serviceName = 'Amazon Music';
|
||||
} else if (openService == 'tidal') {
|
||||
webUrl = 'https://listen.tidal.com/track/$rawId';
|
||||
appUri = Uri.parse('tidal://track/$rawId');
|
||||
serviceName = 'Tidal';
|
||||
} else if (openService == 'qobuz') {
|
||||
webUrl = 'https://play.qobuz.com/track/$rawId';
|
||||
appUri = Uri.parse('qobuz://track/$rawId');
|
||||
serviceName = 'Qobuz';
|
||||
} else {
|
||||
webUrl = 'https://open.spotify.com/track/$rawId';
|
||||
appUri = Uri.parse('spotify:track:$rawId');
|
||||
serviceName = 'Spotify';
|
||||
}
|
||||
|
||||
try {
|
||||
final launched = await launchUrl(
|
||||
appUri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
|
||||
if (!launched) {
|
||||
await launchUrl(
|
||||
Uri.parse(webUrl),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
try {
|
||||
await launchUrl(
|
||||
Uri.parse(webUrl),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
_copyToClipboard(context, webUrl);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarUrlCopied(serviceName)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
|
||||
final audioQualityStr = _displayAudioQuality;
|
||||
|
||||
final items = <_MetadataItem>[
|
||||
_MetadataItem(context.l10n.trackTrackName, trackName),
|
||||
_MetadataItem(context.l10n.trackArtist, artistName),
|
||||
if (albumArtist != null && albumArtist != artistName)
|
||||
_MetadataItem(context.l10n.trackAlbumArtist, albumArtist!),
|
||||
_MetadataItem(context.l10n.trackAlbum, albumName),
|
||||
if (trackNumber != null && trackNumber! > 0)
|
||||
_MetadataItem(context.l10n.trackTrackNumber, trackNumber.toString()),
|
||||
if (totalTracks != null && totalTracks! > 0)
|
||||
_MetadataItem(
|
||||
context.l10n.editMetadataFieldTrackTotal,
|
||||
totalTracks.toString(),
|
||||
),
|
||||
if (discNumber != null && discNumber! > 0)
|
||||
_MetadataItem(context.l10n.trackDiscNumber, discNumber.toString()),
|
||||
if (totalDiscs != null && totalDiscs! > 0)
|
||||
_MetadataItem(
|
||||
context.l10n.editMetadataFieldDiscTotal,
|
||||
totalDiscs.toString(),
|
||||
),
|
||||
if (duration != null)
|
||||
_MetadataItem(context.l10n.trackDuration, _formatDuration(duration!)),
|
||||
if (audioQualityStr != null)
|
||||
_MetadataItem(context.l10n.trackAudioQuality, audioQualityStr),
|
||||
if (releaseDate != null && releaseDate!.isNotEmpty)
|
||||
_MetadataItem(context.l10n.trackReleaseDate, releaseDate!),
|
||||
if (genre != null && genre!.isNotEmpty)
|
||||
_MetadataItem(context.l10n.trackGenre, genre!),
|
||||
if (label != null && label!.isNotEmpty)
|
||||
_MetadataItem(context.l10n.trackLabel, label!),
|
||||
if (copyright != null && copyright!.isNotEmpty)
|
||||
_MetadataItem(context.l10n.trackCopyright, copyright!),
|
||||
if (composer != null && composer!.isNotEmpty)
|
||||
_MetadataItem(context.l10n.editMetadataFieldComposer, composer!),
|
||||
if (isrc != null && isrc!.isNotEmpty) _MetadataItem('ISRC', isrc!),
|
||||
];
|
||||
|
||||
if (!_isLocalItem && _spotifyId != null && _spotifyId!.isNotEmpty) {
|
||||
final idService = _serviceForTrackId(
|
||||
_spotifyId!,
|
||||
fallbackService: _service.toLowerCase(),
|
||||
);
|
||||
final cleanId = _displayServiceTrackId(_spotifyId!);
|
||||
String idLabel;
|
||||
switch (idService) {
|
||||
case 'deezer':
|
||||
idLabel = 'Deezer ID';
|
||||
case 'amazon':
|
||||
idLabel = 'Amazon ASIN';
|
||||
case 'tidal':
|
||||
idLabel = 'Tidal ID';
|
||||
case 'qobuz':
|
||||
idLabel = 'Qobuz ID';
|
||||
default:
|
||||
idLabel = 'Spotify ID';
|
||||
}
|
||||
items.add(_MetadataItem(idLabel, cleanId));
|
||||
}
|
||||
|
||||
items.add(
|
||||
_MetadataItem(context.l10n.trackMetadataService, _service.toUpperCase()),
|
||||
);
|
||||
items.add(
|
||||
_MetadataItem(context.l10n.trackDownloaded, _formatFullDate(_addedAt)),
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: items.map((metadata) {
|
||||
final isCopyable =
|
||||
metadata.label == 'ISRC' ||
|
||||
metadata.label == 'Spotify ID' ||
|
||||
metadata.label == 'Deezer ID' ||
|
||||
metadata.label == 'Amazon ASIN' ||
|
||||
metadata.label == 'Tidal ID' ||
|
||||
metadata.label == 'Qobuz ID';
|
||||
return InkWell(
|
||||
onTap: isCopyable
|
||||
? () => _copyToClipboard(context, metadata.value)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
metadata.label,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
metadata.value,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isCopyable)
|
||||
Icon(
|
||||
Icons.copy,
|
||||
size: 14,
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(int seconds) {
|
||||
final minutes = seconds ~/ 60;
|
||||
final secs = seconds % 60;
|
||||
return '$minutes:${secs.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _formatLabelForRaw(String raw) {
|
||||
final normalized = raw.toLowerCase().replaceAll('-', '_');
|
||||
return switch (normalized) {
|
||||
'flac' => 'FLAC',
|
||||
'alac' => 'ALAC',
|
||||
'wav' || 'wave' => 'WAV',
|
||||
'aiff' || 'aif' || 'aifc' => 'AIFF',
|
||||
'eac3' || 'ec_3' => 'EAC3',
|
||||
'ac3' || 'ac_3' => 'AC3',
|
||||
'ac4' || 'ac_4' => 'AC4',
|
||||
'aac' || 'mp4a' => 'AAC',
|
||||
'm4a' || 'mp4' => 'M4A',
|
||||
'mp3' => 'MP3',
|
||||
'opus' => 'Opus',
|
||||
'ogg' => 'OGG',
|
||||
_ => raw.toUpperCase(),
|
||||
};
|
||||
}
|
||||
|
||||
String _displayFormatLabelForFile(String fileName) {
|
||||
final storedFormat = normalizeOptionalString(_storedAudioFormat);
|
||||
final raw =
|
||||
storedFormat ??
|
||||
(fileName.contains('.') ? fileName.split('.').last : 'Unknown');
|
||||
return _formatLabelForRaw(raw);
|
||||
}
|
||||
|
||||
bool _isBitrateFormatLabel(String label) {
|
||||
return const {
|
||||
'MP3',
|
||||
'OPUS',
|
||||
'OGG',
|
||||
'M4A',
|
||||
'AAC',
|
||||
'EAC3',
|
||||
'AC3',
|
||||
'AC4',
|
||||
}.contains(label.toUpperCase());
|
||||
}
|
||||
|
||||
Widget _buildFileInfoCard(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
bool fileExists,
|
||||
int? fileSize,
|
||||
) {
|
||||
final displayFilePath = _formatPathForDisplay(rawFilePath);
|
||||
final fileName = _extractFileNameFromPathOrUri(rawFilePath);
|
||||
final fileExtension = _displayFormatLabelForFile(fileName);
|
||||
final resolvedQuality = _displayAudioQuality;
|
||||
final lossyBitrateLabel = _extractLossyBitrateLabel(resolvedQuality);
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: settingsGroupColor(context),
|
||||
shape: _sectionCardShape(colorScheme),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.folder_outlined,
|
||||
size: 20,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
context.l10n.trackFileInfo,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
fileExtension,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (fileSize != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_formatFileSize(fileSize),
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isBitrateFormatLabel(fileExtension) &&
|
||||
lossyBitrateLabel != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
lossyBitrateLabel,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_audioBitrate != null &&
|
||||
_audioBitrate! > 0 &&
|
||||
_isBitrateFormatLabel(fileExtension))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'${_audioBitrate}kbps',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (bitDepth != null &&
|
||||
bitDepth! > 0 &&
|
||||
sampleRate != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
buildDisplayAudioQuality(
|
||||
bitDepth: bitDepth,
|
||||
sampleRate: sampleRate,
|
||||
) ??
|
||||
'',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getServiceColor(_service, colorScheme),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_getServiceIcon(_service),
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_service.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
InkWell(
|
||||
onTap: () => _copyToClipboard(context, cleanFilePath),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
displayFilePath,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.copy,
|
||||
size: 18,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user