feat(metadata): display saved ReplayGain values and refresh after analysis

This commit is contained in:
zarzet
2026-09-15 21:44:59 +07:00
parent 0ad17f0caa
commit e063e6d5da
9 changed files with 224 additions and 2 deletions
+16
View File
@@ -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"
+16
View File
@@ -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": {
+32
View File
@@ -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,
+10 -1
View File
@@ -193,6 +193,8 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState {
Future<void> _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(
+17 -1
View File
@@ -125,6 +125,12 @@ class TrackMetadataScreen extends ConsumerStatefulWidget {
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
with CollapsingHeaderScrollMixin<TrackMetadataScreen> {
static const int _maxCoverPreviewCacheEntries = 96;
static const _replayGainFields = [
'replaygain_track_gain',
'replaygain_track_peak',
'replaygain_album_gain',
'replaygain_album_peak',
];
static final Map<String, _EmbeddedCoverPreviewCacheEntry>
_embeddedCoverPreviewCache = {};
@@ -299,6 +305,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
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<TrackMetadataScreen>
needsDuration ||
formatChanged);
if ((resolvedBitDepth != null ||
if ((replayGainChanged ||
resolvedBitDepth != null ||
resolvedSampleRate != null ||
resolvedFormat != null ||
fileHasTitle ||
@@ -465,6 +480,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
_resolvedAudioFormat = resolvedFormat;
_editedMetadata = {
...?_editedMetadata,
...replayGainMetadata,
// ignore: use_null_aware_elements
if (resolvedBitDepth != null) 'bit_depth': resolvedBitDepth,
// ignore: use_null_aware_elements
+41
View File
@@ -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<u8> {
[
((body.len() + 8) as u32).to_be_bytes().as_slice(),
@@ -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)
+8
View File
@@ -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(
+80
View File
@@ -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);
});
}
}