From 829aeb156eae4508bd9d49111d85fc1c9ca3decf Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:16:15 +0700 Subject: [PATCH] fix(playback): reload audio sources when switching tracks --- lib/services/music_playback_deck.dart | 10 +++++++- test/music_player_automix_test.dart | 37 ++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/lib/services/music_playback_deck.dart b/lib/services/music_playback_deck.dart index 2ae7a732..960091a1 100644 --- a/lib/services/music_playback_deck.dart +++ b/lib/services/music_playback_deck.dart @@ -151,7 +151,7 @@ class MusicPlaybackDeck { _source = null; _routeLost = false; _nativeStarted = false; - await _ordinary.setSource(UrlSource(url)); + await _setOrdinarySource(UrlSource(url)); } Future setSource( @@ -245,6 +245,14 @@ class MusicPlaybackDeck { usbAudioStatus.value = const UsbAudioStatus(reason: 'dsd_unsupported'); throw UsbDsdUnavailable(); } + await _setOrdinarySource(source); + } + + Future _setOrdinarySource(Source source) async { + // stop mode retains the native source. Android can reuse the same + // /proc/self/fd number for a different SAF file, so URL equality must not + // reuse the previous MediaPlayer or its pending preparation callbacks. + await _ordinary.release(); await _ordinary.setSource(source); } diff --git a/test/music_player_automix_test.dart b/test/music_player_automix_test.dart index 3a11c956..9c9460c7 100644 --- a/test/music_player_automix_test.dart +++ b/test/music_player_automix_test.dart @@ -66,6 +66,8 @@ class _AudioNative { final live = {}; final playing = {}; final sources = {}; + final sourceContents = {}; + final preparedContents = {}; final sourceGates = >{}; final resumedSources = []; Completer? resumeGate; @@ -98,7 +100,11 @@ class _AudioNative { case 'setSourceUrl': final source = args['url']! as String; await sourceGates[source]?.future; - sources[id] = source; + // Android caches equal URLs, including recycled SAF descriptors. + if (sources[id] != source) { + sources[id] = source; + preparedContents[id] = sourceContents[source] ?? source; + } unawaited(event(id, 'audio.onPrepared', true)); if (emitsDuration) unawaited(event(id, 'audio.onDuration', 60000)); case 'seek': @@ -110,6 +116,10 @@ class _AudioNative { resumedSources.add(sources[id]!); case 'pause' || 'stop': playing.remove(id); + case 'release': + playing.remove(id); + sources.remove(id); + preparedContents.remove(id); case 'dispose': playing.remove(id); live.remove(id); @@ -937,6 +947,31 @@ void main() { }, ); + test('recycled SAF descriptor prepares the newly selected file', () async { + const descriptor = '/proc/self/fd/42'; + native.sourceContents[descriptor] = 'first file'; + await handler.setQueueAndPlay([ + const PlayableMedia( + id: 'first', + source: descriptor, + title: 'First', + artist: 'Artist', + ), + const PlayableMedia( + id: 'second', + source: descriptor, + title: 'Second', + artist: 'Artist', + ), + ]); + expect(native.preparedContents['music-player'], 'first file'); + native.sourceContents[descriptor] = 'second file'; + await handler.skipToNext(); + expect(handler.mediaItem.value?.title, 'Second'); + expect(native.preparedContents['music-player'], 'second file'); + expect(native.playing, contains('music-player')); + }); + test('replacing a queue during preparation drops the old request', () async { final gate = Completer(); native.sourceGates['/one.flac'] = gate;