mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 00:58:37 +02:00
fix(player): preserve audio quality across track changes
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<NowPlayingScreen> {
|
||||
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<void> _loadMetadataFor(String source, {String? resolvedSource}) async {
|
||||
Future<void> _loadMetadataFor(
|
||||
String source, {
|
||||
String? resolvedSource,
|
||||
Map<String, dynamic> 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<NowPlayingScreen> {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_metadata = meta;
|
||||
_metadata = mergePlaybackFileMetadata(fallbackMetadata, meta);
|
||||
_lyrics = LyricsParser.parse((meta['lyrics'] ?? '').toString());
|
||||
_loadingMeta = false;
|
||||
});
|
||||
@@ -249,7 +264,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_metadata = null;
|
||||
_metadata = fallbackMetadata.isEmpty ? null : fallbackMetadata;
|
||||
_lyrics = ParsedLyrics.empty;
|
||||
_loadingMeta = false;
|
||||
});
|
||||
@@ -267,10 +282,10 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
.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<NowPlayingScreen> {
|
||||
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;
|
||||
|
||||
@@ -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<String, dynamic> 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<String, dynamic> playbackAudioMetadataFromMediaItem(MediaItem item) {
|
||||
final extras = item.extras;
|
||||
if (extras == null || extras.isEmpty) return const {};
|
||||
|
||||
final metadata = <String, dynamic>{};
|
||||
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<String, dynamic> mergePlaybackFileMetadata(
|
||||
Map<String, dynamic> fallback,
|
||||
Map<String, dynamic> probed,
|
||||
) {
|
||||
final merged = <String, dynamic>{...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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user