fix(player): scale mpv stream ring buffer for poorly interleaved mp4/mov

This commit is contained in:
edde746
2026-06-12 22:21:17 +02:00
parent 24980aff95
commit 90662e2173
8 changed files with 217 additions and 13 deletions
@@ -387,6 +387,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
settingsService: settingsService, settingsService: settingsService,
videoUrl: result.videoUrl!, videoUrl: result.videoUrl!,
isTranscoding: result.isTranscoding, isTranscoding: result.isTranscoding,
// Not _isOfflinePlayback: the replacement session commits later, in
// onOpened, so the getter still describes the previous item here.
isLocalMedia: _offlineLibraryMode || result.usesLocalMedia,
selectedVersion: result.selectedVersion,
timing: openTiming, timing: openTiming,
headers: result.usesLocalMedia ? null : streamHeaders, headers: result.usesLocalMedia ? null : streamHeaders,
play: !frameRatePlan.holdPlaybackStart && !wtOwnsStart && (attachesSubsAtOpen || !hasExternalSubs), play: !frameRatePlan.holdPlaybackStart && !wtOwnsStart && (attachesSubsAtOpen || !hasExternalSubs),
@@ -407,8 +407,59 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
_queueScrubPreviewLoad(metadata: metadata, mediaInfo: mediaInfo, mediaClient: mediaClient); _queueScrubPreviewLoad(metadata: metadata, mediaInfo: mediaInfo, mediaClient: mediaClient);
} }
/// Open [videoUrl] on [player]: force-seekable hint → open → native /// Per-open network stream tunings: ffmpeg auto-reconnect plus an enlarged
/// subtitle style. /// mpv stream ring buffer for poorly interleaved MP4/MOV direct play (the
/// ring absorbs the demuxer's audio↔video byte ping-pong so HTTP reads stay
/// linear instead of dropping the connection on every byte seek — see
/// [networkStreamRingBytes]). Both properties are always written, set or
/// reset, so a reused player never carries one item's tuning into the next
/// open. On Android with ExoPlayer active they are stashed natively and
/// replayed on the exo→mpv fallback, so keep them unconditional.
Future<void> _applyNetworkStreamTuning({
required Player player,
required bool isNetworkVod,
required bool isTranscoding,
required MediaVersion? selectedVersion,
}) async {
if (isNetworkVod) {
// Covers network drops up to 10 min; applies to transcode streams too.
await player.setProperty(
'stream-lavf-o',
'reconnect=1,reconnect_on_network_error=1,reconnect_streamed=1,reconnect_delay_max=600',
);
} else {
await player.setProperty('stream-lavf-o', '');
}
int? ringBytes;
if (isNetworkVod && !isTranscoding) {
// Transcode (HLS) playback only uses the mpv stream layer for the
// playlist file; segment fetches happen inside ffmpeg's hls demuxer.
final maxBytes = Platform.isAndroid
? androidStreamRingCapBytes(await PlayerAndroid.getHeapSize())
: maxStreamRingBytes;
ringBytes = networkStreamRingBytes(
container: selectedVersion?.container,
bitrateKbps: selectedVersion?.bitrate,
maxBytes: maxBytes,
);
}
if (ringBytes != null) {
appLogger.i(
'Stream ring buffer: ${ringBytes ~/ (1024 * 1024)}MiB '
'(container=${selectedVersion?.container}, bitrate=${selectedVersion?.bitrate}kbps)',
);
} else {
appLogger.d(
'Stream ring buffer: default '
'(networkVod=$isNetworkVod, transcoding=$isTranscoding, container=${selectedVersion?.container})',
);
}
await player.setProperty('stream-buffer-size', '${ringBytes ?? mpvDefaultStreamBufferBytes}');
}
/// Open [videoUrl] on [player]: stream tuning + force-seekable hint →
/// open → native subtitle style.
/// ///
/// [shouldContinue] is re-checked between the awaits so stale generations /// [shouldContinue] is re-checked between the awaits so stale generations
/// stop without touching the player further. [onOpened] fires immediately /// stop without touching the player further. [onOpened] fires immediately
@@ -422,6 +473,8 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
required SettingsService settingsService, required SettingsService settingsService,
required String videoUrl, required String videoUrl,
required bool isTranscoding, required bool isTranscoding,
required bool isLocalMedia,
required MediaVersion? selectedVersion,
required _PlaybackOpenTiming timing, required _PlaybackOpenTiming timing,
Map<String, String>? headers, Map<String, String>? headers,
required bool play, required bool play,
@@ -429,6 +482,12 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
bool Function()? shouldContinue, bool Function()? shouldContinue,
void Function()? onOpened, void Function()? onOpened,
}) async { }) async {
await _applyNetworkStreamTuning(
player: player,
isNetworkVod: !isLocalMedia && !widget.isLive,
isTranscoding: isTranscoding,
selectedVersion: selectedVersion,
);
// Transcode streams can be seekable even when MPV cannot prove it // Transcode streams can be seekable even when MPV cannot prove it
// from response headers. Reset non-transcodes so live/direct/offline // from response headers. Reset non-transcodes so live/direct/offline
// streams keep native seekability detection. // streams keep native seekability detection.
@@ -208,17 +208,6 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
); );
if (!mounted || player != currentPlayer) return; if (!mounted || player != currentPlayer) return;
// Enable FFmpeg auto-reconnect for VOD streams (covers network drops
// up to 10 min). Forwarded to the Kotlin layer on Android so MPV
// inherits it on the ExoPlayer→MPV fallback path (see
// _onBackendSwitched), so keep it unconditional.
if (!_isOfflinePlayback && !widget.isLive) {
await currentPlayer.setProperty(
'stream-lavf-o',
'reconnect=1,reconnect_on_network_error=1,reconnect_streamed=1,reconnect_delay_max=600',
);
}
await _primeDisplayCriteria( await _primeDisplayCriteria(
player: currentPlayer, player: currentPlayer,
settingsService: settingsService, settingsService: settingsService,
@@ -243,6 +232,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
settingsService: settingsService, settingsService: settingsService,
videoUrl: result.videoUrl!, videoUrl: result.videoUrl!,
isTranscoding: result.isTranscoding, isTranscoding: result.isTranscoding,
isLocalMedia: _isOfflinePlayback,
selectedVersion: result.selectedVersion,
timing: openTiming, timing: openTiming,
headers: streamHeaders, headers: streamHeaders,
play: shouldAutoPlay, play: shouldAutoPlay,
@@ -96,6 +96,8 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
settingsService: SettingsService.instance, settingsService: SettingsService.instance,
videoUrl: result.videoUrl!, videoUrl: result.videoUrl!,
isTranscoding: result.isTranscoding, isTranscoding: result.isTranscoding,
isLocalMedia: result.usesLocalMedia,
selectedVersion: result.selectedVersion,
timing: _playbackOpenTiming( timing: _playbackOpenTiming(
backend: replacementMetadata.backend, backend: replacementMetadata.backend,
isTranscoding: result.isTranscoding, isTranscoding: result.isTranscoding,
+1
View File
@@ -74,6 +74,7 @@ import '../utils/orientation_helper.dart';
import '../utils/platform_detector.dart'; import '../utils/platform_detector.dart';
import '../utils/provider_extensions.dart'; import '../utils/provider_extensions.dart';
import '../utils/snackbar_helper.dart'; import '../utils/snackbar_helper.dart';
import '../utils/stream_buffer_sizing.dart';
import '../utils/video_player_navigation.dart'; import '../utils/video_player_navigation.dart';
import 'video_player/completion_latch.dart'; import 'video_player/completion_latch.dart';
import 'video_player/frame_rate_matcher.dart'; import 'video_player/frame_rate_matcher.dart';
@@ -84,6 +84,12 @@ class PlaybackInitializationResult {
/// source detail, not a statement about whether server reporting is possible. /// source detail, not a statement about whether server reporting is possible.
bool get usesLocalMedia => isOffline; bool get usesLocalMedia => isOffline;
/// The [MediaVersion] selected by [selectedMediaIndex], or null when no
/// version metadata is available (e.g. cached offline flows).
MediaVersion? get selectedVersion => selectedMediaIndex >= 0 && selectedMediaIndex < availableVersions.length
? availableVersions[selectedMediaIndex]
: null;
PlaybackInitializationResult({ PlaybackInitializationResult({
required this.availableVersions, required this.availableVersions,
this.videoUrl, this.videoUrl,
+71
View File
@@ -0,0 +1,71 @@
import 'dart:math';
/// mpv's built-in `stream-buffer-size` default (128 KiB). Written back for
/// opens that don't qualify for an enlarged ring so a reused player instance
/// never carries one item's tuning into the next.
const mpvDefaultStreamBufferBytes = 128 * 1024;
/// QuickTime/MP4 muxer family. Apple capture muxers (iPhone recordings and
/// friends) can store audio packets seconds away from coeval video packets —
/// in bytes, seconds × video byterate — which makes ffmpeg's DTS-ordered
/// reads ping-pong across the file. Over HTTP every ping-pong that escapes
/// mpv's stream ring buffer is a byte seek, and ffmpeg's http layer drops and
/// redials the connection on every seek, collapsing throughput. MKV is
/// interleaved by spec and excluded.
const poorlyInterleavedContainers = {'mp4', 'mov', 'm4v', '3gp', '3g2'};
const minStreamRingBytes = 16 * 1024 * 1024;
const maxStreamRingBytes = 128 * 1024 * 1024;
/// Ring for QuickTime-family content whose bitrate the backend didn't report.
const unknownBitrateStreamRingBytes = 64 * 1024 * 1024;
/// Seconds of content bytes the ring should hold. mpv guarantees only half
/// the ring as seek-back history, so 6s of bytes ⇒ ≥3s of guaranteed
/// interleave-skew coverage (observed iPhone skew ~2.9s) before the
/// power-of-two round-up adds headroom.
const _streamRingContentSeconds = 6;
int nextPowerOfTwo(int value) {
var result = 1;
while (result < value) {
result <<= 1;
}
return result;
}
/// Heap-tier cap for the stream ring on Android, mirroring the demuxer
/// auto-scaling tiers in video_player_screen.dart — mpv is the
/// ExoPlayer-fallback engine there, which is exactly where low-RAM TV boxes
/// land.
int androidStreamRingCapBytes(int heapMB) {
if (heapMB <= 0) return unknownBitrateStreamRingBytes;
if (heapMB <= 256) return 32 * 1024 * 1024;
if (heapMB <= 512) return 64 * 1024 * 1024;
return maxStreamRingBytes;
}
/// mpv `stream-buffer-size` for a network direct play of [container] at
/// [bitrateKbps] total bitrate, or null when mpv's 128 KiB default suffices.
///
/// The ring absorbs the demuxer's audio↔video byte alternation entirely in
/// RAM so the underlying HTTP reads stay linear. The ring is fully allocated
/// (power-of-two rounded) per stream, hence the container gate and bitrate
/// scaling instead of a flat global value.
int? networkStreamRingBytes({
required String? container,
required int? bitrateKbps,
int maxBytes = maxStreamRingBytes,
}) {
if (container == null) return null;
// Jellyfin may report ffmpeg demuxer alias lists ('mov,mp4,m4a,3gp,3g2,mj2').
final tokens = container.toLowerCase().split(',').map((token) => token.trim());
if (!tokens.any(poorlyInterleavedContainers.contains)) return null;
final cap = max(minStreamRingBytes, min(maxBytes, maxStreamRingBytes));
if (bitrateKbps == null || bitrateKbps <= 0) return min(unknownBitrateStreamRingBytes, cap);
final bytesPerSecond = bitrateKbps * 1000 ~/ 8;
final ring = nextPowerOfTwo(bytesPerSecond * _streamRingContentSeconds);
return max(minStreamRingBytes, min(ring, cap));
}
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/stream_buffer_sizing.dart';
void main() {
group('nextPowerOfTwo', () {
test('rounds up to the next power of two', () {
expect(nextPowerOfTwo(1), 1);
expect(nextPowerOfTwo(2), 2);
expect(nextPowerOfTwo(3), 4);
expect(nextPowerOfTwo(52 * 1024 * 1024), 64 * 1024 * 1024);
expect(nextPowerOfTwo(64 * 1024 * 1024), 64 * 1024 * 1024);
expect(nextPowerOfTwo(64 * 1024 * 1024 + 1), 128 * 1024 * 1024);
});
});
group('networkStreamRingBytes', () {
test('sizes the iPhone 4K60 HDR10 repro file to 64MiB', () {
// 69140 kbps total → 8.6 MB/s × 6s ≈ 52MB → pow2 64MiB; the guaranteed
// seek-back half (32MiB) covers the measured ~24MB interleave gap.
expect(networkStreamRingBytes(container: 'mov', bitrateKbps: 69140), 64 * 1024 * 1024);
});
test('returns null for well-interleaved or unknown containers', () {
expect(networkStreamRingBytes(container: 'mkv', bitrateKbps: 69140), isNull);
expect(networkStreamRingBytes(container: 'avi', bitrateKbps: 69140), isNull);
expect(networkStreamRingBytes(container: null, bitrateKbps: 69140), isNull);
expect(networkStreamRingBytes(container: '', bitrateKbps: 69140), isNull);
});
test('matches QuickTime-family containers case-insensitively', () {
expect(networkStreamRingBytes(container: 'MOV', bitrateKbps: 69140), 64 * 1024 * 1024);
expect(networkStreamRingBytes(container: 'mp4', bitrateKbps: 69140), 64 * 1024 * 1024);
});
test('matches ffmpeg demuxer alias lists from Jellyfin', () {
expect(networkStreamRingBytes(container: 'mov,mp4,m4a,3gp,3g2,mj2', bitrateKbps: 69140), 64 * 1024 * 1024);
expect(networkStreamRingBytes(container: 'matroska,webm', bitrateKbps: 69140), isNull);
});
test('falls back to 64MiB when bitrate is unknown', () {
expect(networkStreamRingBytes(container: 'mov', bitrateKbps: null), 64 * 1024 * 1024);
expect(networkStreamRingBytes(container: 'mov', bitrateKbps: 0), 64 * 1024 * 1024);
});
test('clamps to the 16MiB floor for low bitrates', () {
expect(networkStreamRingBytes(container: 'mp4', bitrateKbps: 1000), 16 * 1024 * 1024);
});
test('clamps to the 128MiB ceiling for very high bitrates', () {
expect(networkStreamRingBytes(container: 'mov', bitrateKbps: 400000), 128 * 1024 * 1024);
});
test('respects a lower Android heap cap, including the unknown-bitrate path', () {
expect(
networkStreamRingBytes(container: 'mov', bitrateKbps: 69140, maxBytes: 32 * 1024 * 1024),
32 * 1024 * 1024,
);
expect(networkStreamRingBytes(container: 'mov', bitrateKbps: null, maxBytes: 32 * 1024 * 1024), 32 * 1024 * 1024);
});
});
group('androidStreamRingCapBytes', () {
test('tiers by device heap with a conservative unknown fallback', () {
expect(androidStreamRingCapBytes(0), 64 * 1024 * 1024);
expect(androidStreamRingCapBytes(256), 32 * 1024 * 1024);
expect(androidStreamRingCapBytes(512), 64 * 1024 * 1024);
expect(androidStreamRingCapBytes(1024), 128 * 1024 * 1024);
});
});
}