From 24b9d7ba80f4728fcf081558a07d6239a1a40bdc Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 29 Jul 2026 19:29:19 +0700 Subject: [PATCH] fix(player): preserve audio quality across track changes --- lib/providers/music_player_provider.dart | 8 +++ lib/screens/now_playing_screen.dart | 31 +++++++--- lib/services/music_player_service.dart | 71 +++++++++++++++++++++- test/music_player_media_metadata_test.dart | 48 +++++++++++++++ 4 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 test/music_player_media_metadata_test.dart diff --git a/lib/providers/music_player_provider.dart b/lib/providers/music_player_provider.dart index 593f4ca5..8d47abc6 100644 --- a/lib/providers/music_player_provider.dart +++ b/lib/providers/music_player_provider.dart @@ -147,6 +147,10 @@ PlayableMedia playableFromHistory(DownloadHistoryItem item) { duration: (item.duration != null && item.duration! > 0) ? Duration(seconds: item.duration!) : null, + bitDepth: item.bitDepth, + sampleRate: item.sampleRate, + bitrate: item.bitrate, + format: item.format, ); } @@ -168,5 +172,9 @@ PlayableMedia playableFromLocal(LocalLibraryItem item) { duration: (item.duration != null && item.duration! > 0) ? Duration(seconds: item.duration!) : null, + bitDepth: item.bitDepth, + sampleRate: item.sampleRate, + bitrate: item.bitrate, + format: item.format, ); } diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index fffbaf73..ce3f09af 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -7,8 +7,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/lyrics_parser.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; @@ -202,20 +204,33 @@ class _NowPlayingScreenState extends ConsumerState { final source = item.extras?['source']?.toString() ?? ''; if (source.isEmpty) return; final resolvedSource = item.extras?['resolvedSource']?.toString(); - unawaited(_loadMetadataFor(source, resolvedSource: resolvedSource)); + unawaited( + _loadMetadataFor( + source, + resolvedSource: resolvedSource, + fallbackMetadata: playbackAudioMetadataFromMediaItem(item), + ), + ); } - Future _loadMetadataFor(String source, {String? resolvedSource}) async { + Future _loadMetadataFor( + String source, { + String? resolvedSource, + Map fallbackMetadata = const {}, + }) async { final effectiveResolvedSource = resolvedSource?.trim(); if (source == _loadedSource && effectiveResolvedSource == _loadedResolvedSource) { + if (_metadata == null && fallbackMetadata.isNotEmpty) { + setState(() => _metadata = fallbackMetadata); + } return; } _loadedSource = source; _loadedResolvedSource = effectiveResolvedSource; setState(() { _loadingMeta = true; - _metadata = null; + _metadata = fallbackMetadata.isEmpty ? null : fallbackMetadata; _lyrics = ParsedLyrics.empty; }); try { @@ -237,7 +252,7 @@ class _NowPlayingScreenState extends ConsumerState { return; } setState(() { - _metadata = meta; + _metadata = mergePlaybackFileMetadata(fallbackMetadata, meta); _lyrics = LyricsParser.parse((meta['lyrics'] ?? '').toString()); _loadingMeta = false; }); @@ -249,7 +264,7 @@ class _NowPlayingScreenState extends ConsumerState { return; } setState(() { - _metadata = null; + _metadata = fallbackMetadata.isEmpty ? null : fallbackMetadata; _lyrics = ParsedLyrics.empty; _loadingMeta = false; }); @@ -267,10 +282,10 @@ class _NowPlayingScreenState extends ConsumerState { .toUpperCase(); if (format.isNotEmpty) parts.add(format); - final bitDepth = (meta['bit_depth'] as num?)?.toInt() ?? 0; + final bitDepth = readPositiveInt(meta['bit_depth']) ?? 0; if (bitDepth > 0) parts.add('$bitDepth-bit'); - final sampleRate = (meta['sample_rate'] as num?)?.toDouble() ?? 0; + final sampleRate = readPositiveInt(meta['sample_rate'])?.toDouble() ?? 0; if (sampleRate > 0) { final khz = sampleRate / 1000; final khzStr = khz == khz.roundToDouble() @@ -279,7 +294,7 @@ class _NowPlayingScreenState extends ConsumerState { parts.add('$khzStr kHz'); } - final bitrate = (meta['bitrate'] as num?)?.toInt() ?? 0; + final bitrate = readPositiveInt(meta['bitrate']) ?? 0; if (bitDepth == 0 && bitrate > 0) parts.add('$bitrate kbps'); if (parts.isEmpty) return null; diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index 9f6399ec..c5903b7d 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -8,6 +8,7 @@ import 'package:audio_session/audio_session.dart' import 'package:audioplayers/audioplayers.dart'; import 'package:spotiflac_android/services/app_state_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('MusicPlayer'); @@ -51,6 +52,10 @@ class PlayableMedia { final String album; final String? artUri; final Duration? duration; + final int? bitDepth; + final int? sampleRate; + final int? bitrate; + final String? format; const PlayableMedia({ required this.id, @@ -60,6 +65,10 @@ class PlayableMedia { this.album = '', this.artUri, this.duration, + this.bitDepth, + this.sampleRate, + this.bitrate, + this.format, }); bool get isContentUri => source.startsWith('content://'); @@ -72,6 +81,10 @@ class PlayableMedia { 'album': album, if (artUri != null && artUri!.isNotEmpty) 'artUri': artUri, if (duration != null) 'durationMs': duration!.inMilliseconds, + if (bitDepth != null && bitDepth! > 0) 'bitDepth': bitDepth, + if (sampleRate != null && sampleRate! > 0) 'sampleRate': sampleRate, + if (bitrate != null && bitrate! > 0) 'bitrate': bitrate, + if (format != null && format!.trim().isNotEmpty) 'format': format, }; static PlayableMedia? fromJson(Map json) { @@ -91,6 +104,10 @@ class PlayableMedia { duration: (durationMs != null && durationMs > 0) ? Duration(milliseconds: durationMs) : null, + bitDepth: readPositiveInt(json['bitDepth']), + sampleRate: readPositiveInt(json['sampleRate']), + bitrate: readPositiveInt(json['bitrate']), + format: json['format']?.toString(), ); } @@ -108,11 +125,57 @@ class PlayableMedia { 'source': source, if (resolvedSource != null && resolvedSource.isNotEmpty) 'resolvedSource': resolvedSource, + if (bitDepth != null && bitDepth! > 0) 'bit_depth': bitDepth, + if (sampleRate != null && sampleRate! > 0) 'sample_rate': sampleRate, + if (bitrate != null && bitrate! > 0) 'bitrate': bitrate, + if (format != null && format!.trim().isNotEmpty) + 'format': format!.trim(), }, ); } } +/// Technical audio metadata carried with a queue item. This is available +/// immediately when tracks change, before a fresh file probe completes. +Map playbackAudioMetadataFromMediaItem(MediaItem item) { + final extras = item.extras; + if (extras == null || extras.isEmpty) return const {}; + + final metadata = {}; + final bitDepth = readPositiveInt(extras['bit_depth']); + final sampleRate = readPositiveInt(extras['sample_rate']); + final bitrate = readPositiveInt(extras['bitrate']); + final format = extras['format']?.toString().trim(); + if (bitDepth != null) metadata['bit_depth'] = bitDepth; + if (sampleRate != null) metadata['sample_rate'] = sampleRate; + if (bitrate != null) metadata['bitrate'] = bitrate; + if (format != null && format.isNotEmpty) metadata['format'] = format; + return metadata; +} + +/// Combines the immediate queue metadata with the richer file probe while +/// retaining known quality fields when a decoder omits or reports them as 0. +Map mergePlaybackFileMetadata( + Map fallback, + Map probed, +) { + final merged = {...fallback, ...probed}; + for (final key in const ['bit_depth', 'sample_rate', 'bitrate']) { + if (readPositiveInt(probed[key]) == null && + readPositiveInt(fallback[key]) != null) { + merged[key] = fallback[key]; + } + } + final probedFormat = probed['format']?.toString().trim(); + final fallbackFormat = fallback['format']?.toString().trim(); + if ((probedFormat == null || probedFormat.isEmpty) && + fallbackFormat != null && + fallbackFormat.isNotEmpty) { + merged['format'] = fallbackFormat; + } + return merged; +} + class MusicPlayerHandler extends BaseAudioHandler with QueueHandler, SeekHandler { final AudioPlayer _player = AudioPlayer(playerId: 'music-player'); @@ -716,7 +779,13 @@ class MusicPlayerHandler extends BaseAudioHandler if (!_isCurrentPlayRequest(generation, media)) return; _activeResolvedPath = media.isContentUri ? resolved : null; await _cleanupPendingResolvedPaths(); - mediaItem.add(media.toMediaItem(resolvedSource: resolved)); + // Plain file paths were already published before loading. Re-publishing + // them with an identical resolved path made Now Playing clear and probe + // the same metadata twice on every Next. SAF needs this second event so + // the UI can inspect its temporary local copy. + if (media.isContentUri) { + mediaItem.add(media.toMediaItem(resolvedSource: resolved)); + } _broadcastPosition(Duration.zero, force: true); _broadcastState(playerState: PlayerState.playing); _persistSession(); diff --git a/test/music_player_media_metadata_test.dart b/test/music_player_media_metadata_test.dart new file mode 100644 index 00000000..10e6dbff --- /dev/null +++ b/test/music_player_media_metadata_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/music_player_service.dart'; + +void main() { + const media = PlayableMedia( + id: 'track-1', + source: '/music/track.flac', + title: 'Track', + artist: 'Artist', + bitDepth: 24, + sampleRate: 96000, + bitrate: 2860, + format: 'flac', + ); + + test('queue media exposes technical quality to Now Playing immediately', () { + final metadata = playbackAudioMetadataFromMediaItem(media.toMediaItem()); + + expect(metadata, { + 'bit_depth': 24, + 'sample_rate': 96000, + 'bitrate': 2860, + 'format': 'flac', + }); + }); + + test('persisted playback keeps technical quality across app restarts', () { + final restored = PlayableMedia.fromJson(media.toJson()); + + expect(restored, isNotNull); + expect(restored!.bitDepth, 24); + expect(restored.sampleRate, 96000); + expect(restored.bitrate, 2860); + expect(restored.format, 'flac'); + }); + + test('file probe cannot erase valid queue quality with empty values', () { + final merged = mergePlaybackFileMetadata( + playbackAudioMetadataFromMediaItem(media.toMediaItem()), + {'title': 'Track', 'bit_depth': 0, 'sample_rate': null, 'format': ''}, + ); + + expect(merged['bit_depth'], 24); + expect(merged['sample_rate'], 96000); + expect(merged['format'], 'flac'); + expect(merged['title'], 'Track'); + }); +}