From 49c117db8343709397df2d4cda19a02582c8d641 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:04:57 +0200 Subject: [PATCH] fix(music): open the gapless next track ahead of the boundary Gapless playback still gapped between tracks on network streams: mpv only opened the armed next track's stream at the moment the current one ended, so any server whose connect+probe outlasts the audio output's buffered tail (~0.5s) - remote Plex over TLS, a transcode session starting up - produced an audible dropout at every transition. Enable mpv's prefetch-playlist when arming a network track so the open happens while the current track still plays. Measured on a Pixel 7 the boundary goes from 60-233ms of inline network work to 9-16ms with no network activity at all; a failed or superseded prefetch falls back to the old boundary open. Local fdclose:// arms keep prefetch off: an early open would consume the fd while playlist-pos still reads 0, breaking _clearArmedNext's "provably never opened" close proof. close #1869 --- lib/mpv/player/player_native.dart | 14 +++++++++ test/mpv/player_gapless_test.dart | 47 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 97e974e75..31b2f8045 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -423,6 +423,20 @@ class PlayerNative extends PlayerBase { await _clearArmedNext(); if (media == null) return; + // Let mpv open the armed entry while the current track still plays + // (prefetch starts once the current demuxer is fully read) so the + // network open never sits on the gapless boundary: with a boundary + // open, any server whose connect+probe outlasts the AO's buffered + // tail (~0.5s) produces an audible gap on every transition (#1869). + // A failed or superseded prefetch is discarded by mpv and the entry + // reopens normally at the boundary, so this can only remove latency. + // Off for local arms: a prefetch would consume an fdclose:// fd while + // playlist-pos still reads 0, breaking _clearArmedNext's "provably + // never opened" proof (double close) — and local opens are instant + // anyway. Set before the fd claim so a property failure cannot leak it. + final networkArm = media.uri.startsWith('http://') || media.uri.startsWith('https://'); + await setProperty('prefetch-playlist', networkArm ? 'yes' : 'no'); + final (loadUri, fd) = await _toPlayableUri(media.uri, strict: true); // Per-entry options are the 4th loadfile argument on mpv >= 0.38 diff --git a/test/mpv/player_gapless_test.dart b/test/mpv/player_gapless_test.dart index dcd4549c6..c1bf1e554 100644 --- a/test/mpv/player_gapless_test.dart +++ b/test/mpv/player_gapless_test.dart @@ -122,6 +122,53 @@ void main() { }); }); + group('setNext prefetch-playlist policy', () { + // The armed entry's stream must be opened while the current track still + // plays (mpv prefetch) so the network round-trip never sits on the + // gapless boundary (#1869) — but never for fd-backed local entries, + // where an early open would consume the fd while playlist-pos still + // reads 0 and break the "provably never opened" close proof. + (int, String?) prefetchWrite(_AudioCoreMock core) { + final index = core.calls.lastIndexWhere( + (c) => c.method == 'setProperty' && _AudioCoreMock._args(c)['name'] == 'prefetch-playlist', + ); + if (index == -1) return (-1, null); + return (index, _AudioCoreMock._args(core.calls[index])['value'] as String?); + } + + int appendIndex(_AudioCoreMock core) => core.calls.lastIndexWhere((c) { + if (c.method != 'command') return false; + final args = (_AudioCoreMock._args(c)['args'] as List).cast(); + return args.length >= 3 && args[0] == 'loadfile' && args[2] == 'append'; + }); + + test('a network arm enables prefetch before the entry joins the playlist', () async { + final core = _AudioCoreMock(); + await run(core, (player, transitions) async { + await openFirst(player); + await player.setNext(Media('https://example.test/t2.flac')); + + final (writeIndex, value) = prefetchWrite(core); + expect(value, 'yes'); + expect(writeIndex, lessThan(appendIndex(core)), reason: 'the option must be live before the append'); + }); + }); + + test('an fd-backed arm disables prefetch before the entry joins the playlist', () async { + final core = _AudioCoreMock(); + await run(core, (player, transitions) async { + await openFirst(player); + await player.setNext(Media('https://example.test/t2.flac')); + await player.setNext(Media('content://downloads/t3')); + + final (writeIndex, value) = prefetchWrite(core); + expect(value, 'no', reason: 'a prefetch would consume the fd and invite a double close'); + expect(writeIndex, lessThan(appendIndex(core))); + expect(core.commands('loadfile').last, ['loadfile', 'fdclose://7', 'append']); + }); + }); + }); + group('armed fd ownership', () { test('clearing an unconsumed arm closes the fd (pos 0 before and after)', () async { final core = _AudioCoreMock();