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
This commit is contained in:
edde746
2026-08-16 22:40:52 +02:00
parent d77836ef22
commit 49c117db83
2 changed files with 61 additions and 0 deletions
+14
View File
@@ -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
+47
View File
@@ -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<Object?>();
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();