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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user