mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-28 12:22:12 +02:00
feat(lyrics,replaygain): add LyricsPlus provider and ReplayGain batch scanning
LyricsPlus (KPOE): word-by-word synced lyrics with multi-server failover, converted to enhanced LRC. ReplayGain: standalone EBU R128 (re)scan writing REPLAYGAIN_TRACK_* tags via native writers or FFmpeg, with batch action in queue/album screens and SAF support.
This commit is contained in:
@@ -7,6 +7,7 @@ import 'package:share_plus/share_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/replaygain_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/history_database.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
@@ -1412,6 +1413,80 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runBatchReplayGain(List<DownloadHistoryItem> tracks) async {
|
||||
final tracksById = {for (final t in tracks) t.id: t};
|
||||
final selected = <DownloadHistoryItem>[];
|
||||
for (final id in _selectedIds) {
|
||||
final item = tracksById[id];
|
||||
if (item == null) continue;
|
||||
selected.add(item);
|
||||
}
|
||||
|
||||
if (selected.isEmpty) return;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.replayGainBatchConfirmTitle),
|
||||
content: Text(
|
||||
ctx.l10n.replayGainBatchConfirmMessage(selected.length),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(ctx.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(ctx.l10n.replayGainBatchConfirmTitle),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
var cancelled = false;
|
||||
int successCount = 0;
|
||||
final total = selected.length;
|
||||
|
||||
BatchProgressDialog.show(
|
||||
context: context,
|
||||
title: context.l10n.replayGainBatchAnalyzing,
|
||||
total: total,
|
||||
icon: Icons.graphic_eq,
|
||||
onCancel: () {
|
||||
cancelled = true;
|
||||
BatchProgressDialog.dismiss(context);
|
||||
},
|
||||
);
|
||||
|
||||
for (int i = 0; i < total; i++) {
|
||||
if (!mounted || cancelled) break;
|
||||
final item = selected[i];
|
||||
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
|
||||
try {
|
||||
final ok = await ReplayGainService.applyToFile(item.filePath);
|
||||
if (ok) successCount++;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
_exitSelectionMode();
|
||||
|
||||
if (!mounted) return;
|
||||
if (!cancelled) {
|
||||
BatchProgressDialog.dismiss(context);
|
||||
}
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.replayGainBatchSuccess(successCount, total),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectionBottomBar(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
@@ -1508,10 +1583,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _DownloadedAlbumSelectionActionButton(
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const spacing = 8.0;
|
||||
final itemWidth = (constraints.maxWidth - spacing) / 2;
|
||||
final actions = <Widget>[
|
||||
_DownloadedAlbumSelectionActionButton(
|
||||
icon: Icons.share_outlined,
|
||||
label: context.l10n.selectionShareCount(selectedCount),
|
||||
onPressed: selectedCount > 0
|
||||
@@ -1519,10 +1596,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
: null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _DownloadedAlbumSelectionActionButton(
|
||||
_DownloadedAlbumSelectionActionButton(
|
||||
icon: Icons.swap_horiz,
|
||||
label: context.l10n.selectionConvertCount(selectedCount),
|
||||
onPressed: selectedCount > 0
|
||||
@@ -1530,8 +1604,27 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
: null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
],
|
||||
_DownloadedAlbumSelectionActionButton(
|
||||
icon: Icons.graphic_eq,
|
||||
label: context.l10n.selectionReplayGainCount(
|
||||
selectedCount,
|
||||
),
|
||||
onPressed: selectedCount > 0
|
||||
? () => _runBatchReplayGain(tracks)
|
||||
: null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
];
|
||||
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: spacing,
|
||||
children: [
|
||||
for (final action in actions)
|
||||
SizedBox(width: itemWidth, child: action),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/replaygain_service.dart';
|
||||
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
|
||||
@@ -1681,6 +1682,80 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runBatchReplayGain(List<LocalLibraryItem> tracks) async {
|
||||
final tracksById = {for (final t in tracks) t.id: t};
|
||||
final selected = <LocalLibraryItem>[];
|
||||
for (final id in _selectedIds) {
|
||||
final item = tracksById[id];
|
||||
if (item == null) continue;
|
||||
selected.add(item);
|
||||
}
|
||||
|
||||
if (selected.isEmpty) return;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.replayGainBatchConfirmTitle),
|
||||
content: Text(
|
||||
ctx.l10n.replayGainBatchConfirmMessage(selected.length),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(ctx.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(ctx.l10n.replayGainBatchConfirmTitle),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
var cancelled = false;
|
||||
int successCount = 0;
|
||||
final total = selected.length;
|
||||
|
||||
BatchProgressDialog.show(
|
||||
context: context,
|
||||
title: context.l10n.replayGainBatchAnalyzing,
|
||||
total: total,
|
||||
icon: Icons.graphic_eq,
|
||||
onCancel: () {
|
||||
cancelled = true;
|
||||
BatchProgressDialog.dismiss(context);
|
||||
},
|
||||
);
|
||||
|
||||
for (int i = 0; i < total; i++) {
|
||||
if (!mounted || cancelled) break;
|
||||
final item = selected[i];
|
||||
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
|
||||
try {
|
||||
final ok = await ReplayGainService.applyToFile(item.filePath);
|
||||
if (ok) successCount++;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
_exitSelectionMode();
|
||||
|
||||
if (!mounted) return;
|
||||
if (!cancelled) {
|
||||
BatchProgressDialog.dismiss(context);
|
||||
}
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.replayGainBatchSuccess(successCount, total),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectionBottomBar(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
@@ -1778,22 +1853,26 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
if (flacEligibleCount > 0) ...[
|
||||
Expanded(
|
||||
child: _LocalAlbumSelectionActionButton(
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const spacing = 8.0;
|
||||
final itemWidth = (constraints.maxWidth - spacing) / 2;
|
||||
final actions = <Widget>[];
|
||||
|
||||
if (flacEligibleCount > 0) {
|
||||
actions.add(
|
||||
_LocalAlbumSelectionActionButton(
|
||||
icon: Icons.download_for_offline_outlined,
|
||||
label:
|
||||
'${context.l10n.queueFlacAction} ($flacEligibleCount)',
|
||||
onPressed: () => _queueSelectedAsFlac(tracks),
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: _LocalAlbumSelectionActionButton(
|
||||
);
|
||||
}
|
||||
|
||||
actions.add(
|
||||
_LocalAlbumSelectionActionButton(
|
||||
icon: Icons.auto_fix_high_outlined,
|
||||
label: '${context.l10n.trackReEnrich} ($selectedCount)',
|
||||
onPressed: selectedCount > 0
|
||||
@@ -1801,10 +1880,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
: null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _LocalAlbumSelectionActionButton(
|
||||
);
|
||||
|
||||
actions.add(
|
||||
_LocalAlbumSelectionActionButton(
|
||||
icon: Icons.swap_horiz,
|
||||
label: context.l10n.selectionConvertCount(selectedCount),
|
||||
onPressed: selectedCount > 0
|
||||
@@ -1812,8 +1891,30 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
: null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
actions.add(
|
||||
_LocalAlbumSelectionActionButton(
|
||||
icon: Icons.graphic_eq,
|
||||
label: context.l10n.selectionReplayGainCount(
|
||||
selectedCount,
|
||||
),
|
||||
onPressed: selectedCount > 0
|
||||
? () => _runBatchReplayGain(tracks)
|
||||
: null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
);
|
||||
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: spacing,
|
||||
children: [
|
||||
for (final action in actions)
|
||||
SizedBox(width: itemWidth, child: action),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
+132
-16
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/replaygain_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
@@ -5421,6 +5422,95 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch-scan loudness and write ReplayGain tags to the selected tracks.
|
||||
Future<void> _runBatchReplayGain(
|
||||
List<UnifiedLibraryItem> allItems,
|
||||
) async {
|
||||
final itemsById = {for (final item in allItems) item.id: item};
|
||||
final selectedItems = <UnifiedLibraryItem>[];
|
||||
for (final id in _selectedIds) {
|
||||
final item = itemsById[id];
|
||||
if (item == null) continue;
|
||||
selectedItems.add(item);
|
||||
}
|
||||
|
||||
if (selectedItems.isEmpty) return;
|
||||
|
||||
_hideSelectionOverlay();
|
||||
_hidePlaylistSelectionOverlay();
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.replayGainBatchConfirmTitle),
|
||||
content: Text(
|
||||
ctx.l10n.replayGainBatchConfirmMessage(selectedItems.length),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(ctx.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(ctx.l10n.replayGainBatchConfirmTitle),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
if (confirmed != true) {
|
||||
if (_isSelectionMode) {
|
||||
_syncSelectionOverlay(
|
||||
items: allItems,
|
||||
bottomPadding: MediaQuery.of(context).padding.bottom,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var cancelled = false;
|
||||
int successCount = 0;
|
||||
final total = selectedItems.length;
|
||||
|
||||
BatchProgressDialog.show(
|
||||
context: context,
|
||||
title: context.l10n.replayGainBatchAnalyzing,
|
||||
total: total,
|
||||
icon: Icons.graphic_eq,
|
||||
onCancel: () {
|
||||
cancelled = true;
|
||||
BatchProgressDialog.dismiss(context);
|
||||
},
|
||||
);
|
||||
|
||||
for (int i = 0; i < total; i++) {
|
||||
if (!mounted || cancelled) break;
|
||||
final item = selectedItems[i];
|
||||
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
|
||||
try {
|
||||
final ok = await ReplayGainService.applyToFile(item.filePath);
|
||||
if (ok) successCount++;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
_exitSelectionMode();
|
||||
|
||||
if (!mounted) return;
|
||||
if (!cancelled) {
|
||||
BatchProgressDialog.dismiss(context);
|
||||
}
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.replayGainBatchSuccess(successCount, total),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectionBottomBar(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
@@ -5524,11 +5614,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
if (localOnlySelection && flacEligibleCount > 0) ...[
|
||||
Expanded(
|
||||
child: _SelectionActionButton(
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const spacing = 8.0;
|
||||
final itemWidth = (constraints.maxWidth - spacing) / 2;
|
||||
final actions = <Widget>[];
|
||||
|
||||
if (localOnlySelection && flacEligibleCount > 0) {
|
||||
actions.add(
|
||||
_SelectionActionButton(
|
||||
icon: Icons.download_for_offline_outlined,
|
||||
label:
|
||||
'${context.l10n.queueFlacAction} ($flacEligibleCount)',
|
||||
@@ -5536,11 +5630,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_queueSelectedLocalAsFlac(unifiedItems),
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: _SelectionActionButton(
|
||||
);
|
||||
}
|
||||
|
||||
actions.add(
|
||||
_SelectionActionButton(
|
||||
icon: localOnlySelection
|
||||
? Icons.auto_fix_high_outlined
|
||||
: Icons.share_outlined,
|
||||
@@ -5554,10 +5648,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
: null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _SelectionActionButton(
|
||||
);
|
||||
|
||||
actions.add(
|
||||
_SelectionActionButton(
|
||||
icon: Icons.swap_horiz,
|
||||
label: context.l10n.selectionConvertCount(selectedCount),
|
||||
onPressed: selectedCount > 0
|
||||
@@ -5565,8 +5659,30 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
: null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
actions.add(
|
||||
_SelectionActionButton(
|
||||
icon: Icons.graphic_eq,
|
||||
label: context.l10n.selectionReplayGainCount(
|
||||
selectedCount,
|
||||
),
|
||||
onPressed: selectedCount > 0
|
||||
? () => _runBatchReplayGain(unifiedItems)
|
||||
: null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
);
|
||||
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: spacing,
|
||||
children: [
|
||||
for (final action in actions)
|
||||
SizedBox(width: itemWidth, child: action),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -26,6 +26,7 @@ class _LyricsProviderPriorityPageState
|
||||
'youtube',
|
||||
'kugou',
|
||||
'genius',
|
||||
'lyricsplus',
|
||||
];
|
||||
|
||||
late List<String> _enabledProviders;
|
||||
@@ -245,6 +246,12 @@ class _LyricsProviderPriorityPageState
|
||||
description: context.l10n.lyricsProviderExtensionDesc,
|
||||
icon: Icons.auto_awesome_outlined,
|
||||
);
|
||||
case 'lyricsplus':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'LyricsPlus',
|
||||
description: context.l10n.lyricsProviderLyricsPlusDesc,
|
||||
icon: Icons.lyrics_outlined,
|
||||
);
|
||||
default:
|
||||
return _LyricsProviderInfo(
|
||||
name: id,
|
||||
|
||||
@@ -221,6 +221,7 @@ class LyricsSettingsPage extends ConsumerWidget {
|
||||
'youtube': 'YouTube',
|
||||
'kugou': 'Kugou',
|
||||
'genius': 'Genius',
|
||||
'lyricsplus': 'LyricsPlus',
|
||||
};
|
||||
|
||||
String _getLyricsProvidersSubtitle(
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/replaygain_service.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
@@ -3259,136 +3260,196 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
showModalBottomSheet<void>(
|
||||
context: screenContext,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
isScrollControlled: true,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(screenContext).size.height * 0.7,
|
||||
),
|
||||
builder: (sheetContext) => 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(sheetContext.l10n.trackCopyFilePath),
|
||||
onTap: () {
|
||||
_closeOptionsMenuAndRun(
|
||||
sheetContext,
|
||||
() => _copyToClipboard(screenContext, cleanFilePath),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_fileExists)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit_outlined),
|
||||
title: Text(sheetContext.l10n.trackEditMetadata),
|
||||
onTap: () {
|
||||
_closeOptionsMenuAndRun(
|
||||
sheetContext,
|
||||
() => _showEditMetadataSheet(
|
||||
screenContext,
|
||||
ref,
|
||||
colorScheme,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!_isLocalItem && (_coverUrl != null || _fileExists))
|
||||
ListTile(
|
||||
leading: const Icon(Icons.image_outlined),
|
||||
title: Text(sheetContext.l10n.trackSaveCoverArt),
|
||||
subtitle: Text(sheetContext.l10n.trackSaveCoverArtSubtitle),
|
||||
onTap: () {
|
||||
_closeOptionsMenuAndRun(sheetContext, _saveCoverArt);
|
||||
},
|
||||
),
|
||||
if (!_isLocalItem)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.lyrics_outlined),
|
||||
title: Text(sheetContext.l10n.trackSaveLyrics),
|
||||
subtitle: Text(sheetContext.l10n.trackSaveLyricsSubtitle),
|
||||
onTap: () {
|
||||
_closeOptionsMenuAndRun(sheetContext, _saveLyrics);
|
||||
},
|
||||
),
|
||||
if (_fileExists)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.travel_explore),
|
||||
title: Text(sheetContext.l10n.trackReEnrich),
|
||||
subtitle: Text(sheetContext.l10n.trackReEnrichOnlineSubtitle),
|
||||
onTap: () {
|
||||
_closeOptionsMenuAndRun(sheetContext, _reEnrichMetadata);
|
||||
},
|
||||
),
|
||||
if (_fileExists && _isConvertibleFormat)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.swap_horiz),
|
||||
title: Text(sheetContext.l10n.trackConvertFormat),
|
||||
subtitle: Text(sheetContext.l10n.trackConvertFormatSubtitle),
|
||||
onTap: () {
|
||||
_closeOptionsMenuAndRun(
|
||||
sheetContext,
|
||||
() => _showConvertSheet(screenContext),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_fileExists && _isCueFile)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.call_split),
|
||||
title: Text(sheetContext.l10n.cueSplitTitle),
|
||||
subtitle: Text(sheetContext.l10n.cueSplitSubtitle),
|
||||
onTap: () {
|
||||
_closeOptionsMenuAndRun(
|
||||
sheetContext,
|
||||
() => _showCueSplitSheet(screenContext),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.share),
|
||||
title: Text(sheetContext.l10n.trackMetadataShare),
|
||||
onTap: () {
|
||||
_closeOptionsMenuAndRun(
|
||||
sheetContext,
|
||||
() => _shareFile(screenContext),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.delete, color: colorScheme.error),
|
||||
title: Text(
|
||||
sheetContext.l10n.trackRemoveFromDevice,
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
onTap: () {
|
||||
_closeOptionsMenuAndRun(
|
||||
sheetContext,
|
||||
() => _confirmDelete(screenContext, ref, colorScheme),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
builder: (sheetContext) {
|
||||
final l10n = sheetContext.l10n;
|
||||
|
||||
final options = <_MetadataOption>[
|
||||
_MetadataOption(
|
||||
icon: Icons.copy_outlined,
|
||||
label: l10n.trackCopyFilePath,
|
||||
onTap: () => _copyToClipboard(screenContext, cleanFilePath),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_fileExists)
|
||||
_MetadataOption(
|
||||
icon: Icons.edit_outlined,
|
||||
label: l10n.trackEditMetadata,
|
||||
onTap: () =>
|
||||
_showEditMetadataSheet(screenContext, ref, colorScheme),
|
||||
),
|
||||
if (!_isLocalItem && (_coverUrl != null || _fileExists))
|
||||
_MetadataOption(
|
||||
icon: Icons.image_outlined,
|
||||
label: l10n.trackSaveCoverArt,
|
||||
onTap: _saveCoverArt,
|
||||
),
|
||||
if (!_isLocalItem)
|
||||
_MetadataOption(
|
||||
icon: Icons.lyrics_outlined,
|
||||
label: l10n.trackSaveLyrics,
|
||||
onTap: _saveLyrics,
|
||||
),
|
||||
if (_fileExists)
|
||||
_MetadataOption(
|
||||
icon: Icons.travel_explore,
|
||||
label: l10n.trackReEnrich,
|
||||
onTap: _reEnrichMetadata,
|
||||
),
|
||||
if (_fileExists && _isConvertibleFormat)
|
||||
_MetadataOption(
|
||||
icon: Icons.swap_horiz,
|
||||
label: l10n.trackConvertFormat,
|
||||
onTap: () => _showConvertSheet(screenContext),
|
||||
),
|
||||
if (_fileExists && !_isCueFile)
|
||||
_MetadataOption(
|
||||
icon: Icons.graphic_eq,
|
||||
label: l10n.trackReplayGain,
|
||||
onTap: () => _rescanReplayGain(),
|
||||
),
|
||||
if (_fileExists && _isCueFile)
|
||||
_MetadataOption(
|
||||
icon: Icons.call_split,
|
||||
label: l10n.cueSplitTitle,
|
||||
onTap: () => _showCueSplitSheet(screenContext),
|
||||
),
|
||||
_MetadataOption(
|
||||
icon: Icons.share_outlined,
|
||||
label: l10n.trackMetadataShare,
|
||||
onTap: () => _shareFile(screenContext),
|
||||
),
|
||||
_MetadataOption(
|
||||
icon: Icons.delete_outline,
|
||||
label: l10n.trackRemoveFromDevice,
|
||||
destructive: true,
|
||||
onTap: () => _confirmDelete(screenContext, ref, colorScheme),
|
||||
),
|
||||
];
|
||||
|
||||
return SafeArea(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(sheetContext).size.height * 0.85,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: _buildOptionsHeaderCover(colorScheme),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
trackName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(sheetContext)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(sheetContext)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
for (final option in options)
|
||||
_MetadataOptionTile(
|
||||
option: option,
|
||||
colorScheme: colorScheme,
|
||||
onTap: () =>
|
||||
_closeOptionsMenuAndRun(sheetContext, option.onTap),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOptionsHeaderCover(ColorScheme colorScheme) {
|
||||
const size = 56.0;
|
||||
const cacheWidth = 112;
|
||||
|
||||
Widget placeholder() => Container(
|
||||
width: size,
|
||||
height: size,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
);
|
||||
|
||||
if (_coverUrl != null) {
|
||||
return CachedCoverImage(
|
||||
imageUrl: _coverUrl!,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheWidth,
|
||||
errorWidget: (_, _, _) => placeholder(),
|
||||
);
|
||||
}
|
||||
|
||||
if (_localCoverPath != null && _localCoverPath!.isNotEmpty) {
|
||||
return Image.file(
|
||||
File(_localCoverPath!),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheWidth,
|
||||
errorBuilder: (_, _, _) => placeholder(),
|
||||
);
|
||||
}
|
||||
|
||||
return placeholder();
|
||||
}
|
||||
|
||||
/// Whether the current file format supports conversion
|
||||
bool get _isConvertibleFormat {
|
||||
final lower = cleanFilePath.toLowerCase();
|
||||
@@ -3572,6 +3633,35 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
Future<void> _rescanReplayGain() async {
|
||||
if (!_fileExists) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.clearSnackBars();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.trackReplayGainScanning),
|
||||
duration: const Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
bool ok = false;
|
||||
try {
|
||||
ok = await ReplayGainService.applyToFile(cleanFilePath);
|
||||
} catch (e) {
|
||||
_log.w('ReplayGain rescan failed: $e');
|
||||
}
|
||||
if (!mounted) return;
|
||||
messenger.hideCurrentSnackBar();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
ok
|
||||
? context.l10n.trackReplayGainSuccess
|
||||
: context.l10n.trackReplayGainFailed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showConvertSheet(BuildContext context) {
|
||||
final currentFormat = _currentFileFormat;
|
||||
final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
|
||||
@@ -4859,3 +4949,57 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataOption {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool destructive;
|
||||
|
||||
const _MetadataOption({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.destructive = false,
|
||||
});
|
||||
}
|
||||
|
||||
class _MetadataOptionTile extends StatelessWidget {
|
||||
final _MetadataOption option;
|
||||
final ColorScheme colorScheme;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _MetadataOptionTile({
|
||||
required this.option,
|
||||
required this.colorScheme,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final boxColor = option.destructive
|
||||
? colorScheme.errorContainer
|
||||
: colorScheme.primaryContainer;
|
||||
final iconColor = option.destructive
|
||||
? colorScheme.onErrorContainer
|
||||
: colorScheme.onPrimaryContainer;
|
||||
final titleColor = option.destructive ? colorScheme.error : null;
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: boxColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(option.icon, color: iconColor, size: 20),
|
||||
),
|
||||
title: Text(
|
||||
option.label,
|
||||
style: TextStyle(fontWeight: FontWeight.w500, color: titleColor),
|
||||
),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user