From e063e6d5da3d7b17159846a5a2596fd5ced9ac9a Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:44:59 +0700 Subject: [PATCH] feat(metadata): display saved ReplayGain values and refresh after analysis --- lib/l10n/arb/app_en.arb | 16 ++++ lib/l10n/arb/app_id.arb | 16 ++++ lib/screens/track_metadata_cards.dart | 32 ++++++++ lib/screens/track_metadata_convert.dart | 11 ++- lib/screens/track_metadata_screen.dart | 18 ++++- rust_backend/crates/core/src/tags.rs | 41 ++++++++++ rust_backend/crates/core/src/tags/library.rs | 4 + test/platform_bridge_metadata_test.dart | 8 ++ test/track_metadata_replaygain_test.dart | 80 ++++++++++++++++++++ 9 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 test/track_metadata_replaygain_test.dart diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 9967b4ad..19a5411a 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1593,6 +1593,22 @@ "@trackAudioQuality": { "description": "Metadata label - audio quality" }, + "trackReplayGainTrackGain": "ReplayGain Track Gain", + "@trackReplayGainTrackGain": { + "description": "Metadata label - ReplayGain track gain" + }, + "trackReplayGainTrackPeak": "ReplayGain Track Peak", + "@trackReplayGainTrackPeak": { + "description": "Metadata label - ReplayGain track peak" + }, + "trackReplayGainAlbumGain": "ReplayGain Album Gain", + "@trackReplayGainAlbumGain": { + "description": "Metadata label - ReplayGain album gain" + }, + "trackReplayGainAlbumPeak": "ReplayGain Album Peak", + "@trackReplayGainAlbumPeak": { + "description": "Metadata label - ReplayGain album peak" + }, "libraryQualityLabelFileFormat": "File format", "@libraryQualityLabelFileFormat": { "description": "Library audio quality label mode that shows the detected file format" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 994aa226..b087c3c7 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -2169,6 +2169,22 @@ "@trackAudioQuality": { "description": "Metadata label - audio quality" }, + "trackReplayGainTrackGain": "ReplayGain Track Gain", + "@trackReplayGainTrackGain": { + "description": "Metadata label - ReplayGain track gain" + }, + "trackReplayGainTrackPeak": "ReplayGain Track Peak", + "@trackReplayGainTrackPeak": { + "description": "Metadata label - ReplayGain track peak" + }, + "trackReplayGainAlbumGain": "ReplayGain Album Gain", + "@trackReplayGainAlbumGain": { + "description": "Metadata label - ReplayGain album gain" + }, + "trackReplayGainAlbumPeak": "ReplayGain Album Peak", + "@trackReplayGainAlbumPeak": { + "description": "Metadata label - ReplayGain album peak" + }, "libraryQualityLabelFileFormat": "Format berkas", "libraryRemovedMissingFiles": "Removed {count} missing files from library", "@libraryRemovedMissingFiles": { diff --git a/lib/screens/track_metadata_cards.dart b/lib/screens/track_metadata_cards.dart index 8427cbd9..24fb10a6 100644 --- a/lib/screens/track_metadata_cards.dart +++ b/lib/screens/track_metadata_cards.dart @@ -456,6 +456,18 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) { final audioQualityStr = _displayAudioQuality; + final replayGainTrackGain = normalizeOptionalString( + _editedMetadata?['replaygain_track_gain']?.toString(), + ); + final replayGainTrackPeak = normalizeOptionalString( + _editedMetadata?['replaygain_track_peak']?.toString(), + ); + final replayGainAlbumGain = normalizeOptionalString( + _editedMetadata?['replaygain_album_gain']?.toString(), + ); + final replayGainAlbumPeak = normalizeOptionalString( + _editedMetadata?['replaygain_album_peak']?.toString(), + ); final items = <_MetadataItem>[ _MetadataItem(context.l10n.trackTrackName, trackName), @@ -481,6 +493,26 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { _MetadataItem(context.l10n.trackDuration, formatClock(duration!)), if (audioQualityStr != null) _MetadataItem(context.l10n.trackAudioQuality, audioQualityStr), + if (replayGainTrackGain != null) + _MetadataItem( + context.l10n.trackReplayGainTrackGain, + replayGainTrackGain, + ), + if (replayGainTrackPeak != null) + _MetadataItem( + context.l10n.trackReplayGainTrackPeak, + replayGainTrackPeak, + ), + if (replayGainAlbumGain != null) + _MetadataItem( + context.l10n.trackReplayGainAlbumGain, + replayGainAlbumGain, + ), + if (replayGainAlbumPeak != null) + _MetadataItem( + context.l10n.trackReplayGainAlbumPeak, + replayGainAlbumPeak, + ), if (_embeddedCoverDimensions case final dimensions?) _MetadataItem( context.l10n.trackCoverResolution, diff --git a/lib/screens/track_metadata_convert.dart b/lib/screens/track_metadata_convert.dart index 0b948887..15a60380 100644 --- a/lib/screens/track_metadata_convert.dart +++ b/lib/screens/track_metadata_convert.dart @@ -193,6 +193,8 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { Future _rescanReplayGain() async { if (!_fileExists) return; + final sourcePath = cleanFilePath; + final generation = _metadataLoadGeneration; final messenger = ScaffoldMessenger.of(context); messenger.clearSnackBars(); messenger.showSnackBar( @@ -203,11 +205,18 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { ); bool ok = false; try { - ok = await ReplayGainService.applyToFile(cleanFilePath); + ok = await ReplayGainService.applyToFile(sourcePath); } catch (e) { _log.w('ReplayGain rescan failed: $e'); } if (!mounted) return; + if (ok && + generation == _metadataLoadGeneration && + sourcePath == cleanFilePath) { + _hasLoadedResolvedAudioMetadata = false; + await _refreshResolvedAudioMetadataFromFile(); + } + if (!mounted) return; messenger.hideCurrentSnackBar(); messenger.showSnackBar( SnackBar( diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 1c65d21f..8a7fc2e9 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -125,6 +125,12 @@ class TrackMetadataScreen extends ConsumerStatefulWidget { class _TrackMetadataScreenState extends ConsumerState with CollapsingHeaderScrollMixin { static const int _maxCoverPreviewCacheEntries = 96; + static const _replayGainFields = [ + 'replaygain_track_gain', + 'replaygain_track_peak', + 'replaygain_album_gain', + 'replaygain_album_peak', + ]; static final Map _embeddedCoverPreviewCache = {}; @@ -299,6 +305,14 @@ class _TrackMetadataScreenState extends ConsumerState return; } + final replayGainMetadata = { + for (final key in _replayGainFields) + key: normalizeOptionalString(metadata[key]?.toString()), + }; + final replayGainChanged = replayGainMetadata.entries.any( + (entry) => entry.value != _editedMetadata?[entry.key], + ); + final resolvedBitDepth = readPositiveInt(metadata['bit_depth']); final resolvedSampleRate = readPositiveInt(metadata['sample_rate']); final resolvedFormat = detectedAudioFormatFromMetadata(metadata); @@ -437,7 +451,8 @@ class _TrackMetadataScreenState extends ConsumerState needsDuration || formatChanged); - if ((resolvedBitDepth != null || + if ((replayGainChanged || + resolvedBitDepth != null || resolvedSampleRate != null || resolvedFormat != null || fileHasTitle || @@ -465,6 +480,7 @@ class _TrackMetadataScreenState extends ConsumerState _resolvedAudioFormat = resolvedFormat; _editedMetadata = { ...?_editedMetadata, + ...replayGainMetadata, // ignore: use_null_aware_elements if (resolvedBitDepth != null) 'bit_depth': resolvedBitDepth, // ignore: use_null_aware_elements diff --git a/rust_backend/crates/core/src/tags.rs b/rust_backend/crates/core/src/tags.rs index 375853d6..c4aaf220 100644 --- a/rust_backend/crates/core/src/tags.rs +++ b/rust_backend/crates/core/src/tags.rs @@ -269,8 +269,49 @@ fn truthy(value: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use std::collections::BTreeMap; use std::io::Cursor; + #[test] + fn library_scan_preserves_replaygain_values_written_to_flac() { + let mut source = b"fLaC\x80\0\0\x22".to_vec(); + source.resize(42, 0); + source.extend_from_slice(&[0xff, 0xf8, 0, 0]); + let fields = BTreeMap::from([ + ("replaygain_track_gain".into(), "-6.20 dB".into()), + ("replaygain_track_peak".into(), "0.000000".into()), + ("replaygain_album_gain".into(), "0.00 dB".into()), + ("replaygain_album_peak".into(), "1.234567".into()), + ]); + let mut output = Vec::new(); + rewrite_audio_tags( + &mut Cursor::new(&source), + &mut output, + "flac", + &fields, + None, + &|| Ok(()), + ) + .unwrap(); + let mut reader = Cursor::new(&output); + let scan = read_library_metadata(&mut reader, "track.flac", "", "", 0, &|| Ok(())).unwrap(); + let full = read_file_metadata(&mut reader, "track.flac", "", &|| Ok(())).unwrap(); + let empty = read_library_metadata( + &mut Cursor::new(source), + "track.flac", + "", + "", + 0, + &|| Ok(()), + ) + .unwrap(); + for (key, value) in fields { + assert_eq!(scan[&key], value); + assert_eq!(scan[&key], full[&key]); + assert!(empty.get(&key).is_none()); + } + } + fn atom(kind: &[u8; 4], body: &[u8]) -> Vec { [ ((body.len() + 8) as u32).to_be_bytes().as_slice(), diff --git a/rust_backend/crates/core/src/tags/library.rs b/rust_backend/crates/core/src/tags/library.rs index 09abaa29..54291f76 100644 --- a/rust_backend/crates/core/src/tags/library.rs +++ b/rust_backend/crates/core/src/tags/library.rs @@ -261,6 +261,10 @@ fn apply_tags( ("album_type", "albumType"), ("explicit", "explicit"), ("upc", "upc"), + ("replay_gain_track_gain", "replaygain_track_gain"), + ("replay_gain_track_peak", "replaygain_track_peak"), + ("replay_gain_album_gain", "replaygain_album_gain"), + ("replay_gain_album_peak", "replaygain_album_peak"), ] { let value = &tags[source]; if !matches!(value, Value::Null) diff --git a/test/platform_bridge_metadata_test.dart b/test/platform_bridge_metadata_test.dart index 19596e31..242e39ee 100644 --- a/test/platform_bridge_metadata_test.dart +++ b/test/platform_bridge_metadata_test.dart @@ -43,6 +43,10 @@ void main() { 'sampleRate': 96000, 'bitrate': 1800, 'format': 'flac', + 'replaygain_track_gain': '-6.20 dB', + 'replaygain_track_peak': '0.000000', + 'replaygain_album_gain': '0.00 dB', + 'replaygain_album_peak': '1.234567', }); } fail('Unexpected full metadata read: ${call.method}'); @@ -58,6 +62,10 @@ void main() { expect(result['bit_depth'], 24); expect(result['sample_rate'], 96000); expect(result['audio_codec'], 'flac'); + expect(result['replaygain_track_gain'], '-6.20 dB'); + expect(result['replaygain_track_peak'], '0.000000'); + expect(result['replaygain_album_gain'], '0.00 dB'); + expect(result['replaygain_album_peak'], '1.234567'); }); test( diff --git a/test/track_metadata_replaygain_test.dart b/test/track_metadata_replaygain_test.dart new file mode 100644 index 00000000..4a1019d4 --- /dev/null +++ b/test/track_metadata_replaygain_test.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/screens/track_metadata_screen.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + for (final hasTags in [true, false]) { + testWidgets('metadata displays only saved ReplayGain (tags=$hasTags)', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final metadata = hasTags + ? { + 'replaygain_track_gain': ' -6.20 dB ', + 'replaygain_track_peak': '0.000000', + 'replaygain_album_gain': '0.00 dB', + 'replaygain_album_peak': '1.234567', + } + : {'replaygain_track_gain': ' ', 'replaygain_album_peak': null}; + messenger.setMockMethodCallHandler(channel, (call) async { + return switch (call.method) { + 'safStat' => jsonEncode({'exists': true, 'size': 100}), + 'readAudioMetadata' => jsonEncode(metadata), + 'getLyricsLRCWithSource' => jsonEncode({'lyrics': '', 'source': ''}), + 'getSafFileModTimes' => '{}', + _ => null, + }; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + final item = DownloadHistoryItem( + id: 'track-$hasTags', + trackName: 'Track', + artistName: 'Artist', + albumName: 'Album', + filePath: 'content://library/document/track-$hasTags.flac', + service: 'provider-a', + downloadedAt: DateTime(2026), + quality: '16-bit/44.1kHz', + bitDepth: 16, + sampleRate: 44100, + format: 'flac', + ); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: TrackMetadataScreen(item: item), + ), + ), + ); + await tester.pumpAndSettle(); + for (final text in [ + 'ReplayGain Track Gain', + 'ReplayGain Track Peak', + 'ReplayGain Album Gain', + 'ReplayGain Album Peak', + '-6.20 dB', + '0.000000', + '0.00 dB', + '1.234567', + ]) { + expect(find.text(text), hasTags ? findsOneWidget : findsNothing); + } + expect(tester.takeException(), isNull); + }); + } +}