From 90662e2173f0f26b7cc5b8619a01228d10faf644 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:21:17 +0200 Subject: [PATCH] fix(player): scale mpv stream ring buffer for poorly interleaved mp4/mov --- .../parts/episode_navigation.dart | 4 ++ .../video_player/parts/playback_open.dart | 63 +++++++++++++++- .../video_player/parts/playback_start.dart | 13 +--- lib/screens/video_player/parts/seeking.dart | 2 + lib/screens/video_player_screen.dart | 1 + .../playback_initialization_types.dart | 6 ++ lib/utils/stream_buffer_sizing.dart | 71 +++++++++++++++++++ test/utils/stream_buffer_sizing_test.dart | 70 ++++++++++++++++++ 8 files changed, 217 insertions(+), 13 deletions(-) create mode 100644 lib/utils/stream_buffer_sizing.dart create mode 100644 test/utils/stream_buffer_sizing_test.dart diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index dd0b137f..9e51b99d 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -387,6 +387,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { settingsService: settingsService, videoUrl: result.videoUrl!, 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, headers: result.usesLocalMedia ? null : streamHeaders, play: !frameRatePlan.holdPlaybackStart && !wtOwnsStart && (attachesSubsAtOpen || !hasExternalSubs), diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart index 98ab163c..7c71d70f 100644 --- a/lib/screens/video_player/parts/playback_open.dart +++ b/lib/screens/video_player/parts/playback_open.dart @@ -407,8 +407,59 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { _queueScrubPreviewLoad(metadata: metadata, mediaInfo: mediaInfo, mediaClient: mediaClient); } - /// Open [videoUrl] on [player]: force-seekable hint → open → native - /// subtitle style. + /// Per-open network stream tunings: ffmpeg auto-reconnect plus an enlarged + /// 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 _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 /// stop without touching the player further. [onOpened] fires immediately @@ -422,6 +473,8 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { required SettingsService settingsService, required String videoUrl, required bool isTranscoding, + required bool isLocalMedia, + required MediaVersion? selectedVersion, required _PlaybackOpenTiming timing, Map? headers, required bool play, @@ -429,6 +482,12 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { bool Function()? shouldContinue, void Function()? onOpened, }) async { + await _applyNetworkStreamTuning( + player: player, + isNetworkVod: !isLocalMedia && !widget.isLive, + isTranscoding: isTranscoding, + selectedVersion: selectedVersion, + ); // Transcode streams can be seekable even when MPV cannot prove it // from response headers. Reset non-transcodes so live/direct/offline // streams keep native seekability detection. diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 797802a1..a26f9059 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -208,17 +208,6 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { ); 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( player: currentPlayer, settingsService: settingsService, @@ -243,6 +232,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { settingsService: settingsService, videoUrl: result.videoUrl!, isTranscoding: result.isTranscoding, + isLocalMedia: _isOfflinePlayback, + selectedVersion: result.selectedVersion, timing: openTiming, headers: streamHeaders, play: shouldAutoPlay, diff --git a/lib/screens/video_player/parts/seeking.dart b/lib/screens/video_player/parts/seeking.dart index 86a681c3..24827820 100644 --- a/lib/screens/video_player/parts/seeking.dart +++ b/lib/screens/video_player/parts/seeking.dart @@ -96,6 +96,8 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState { settingsService: SettingsService.instance, videoUrl: result.videoUrl!, isTranscoding: result.isTranscoding, + isLocalMedia: result.usesLocalMedia, + selectedVersion: result.selectedVersion, timing: _playbackOpenTiming( backend: replacementMetadata.backend, isTranscoding: result.isTranscoding, diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 9ff4c9a4..971a3a7c 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -74,6 +74,7 @@ import '../utils/orientation_helper.dart'; import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; import '../utils/snackbar_helper.dart'; +import '../utils/stream_buffer_sizing.dart'; import '../utils/video_player_navigation.dart'; import 'video_player/completion_latch.dart'; import 'video_player/frame_rate_matcher.dart'; diff --git a/lib/services/playback_initialization_types.dart b/lib/services/playback_initialization_types.dart index d48615d1..43e53d08 100644 --- a/lib/services/playback_initialization_types.dart +++ b/lib/services/playback_initialization_types.dart @@ -84,6 +84,12 @@ class PlaybackInitializationResult { /// source detail, not a statement about whether server reporting is possible. 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({ required this.availableVersions, this.videoUrl, diff --git a/lib/utils/stream_buffer_sizing.dart b/lib/utils/stream_buffer_sizing.dart new file mode 100644 index 00000000..6f5a29b0 --- /dev/null +++ b/lib/utils/stream_buffer_sizing.dart @@ -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)); +} diff --git a/test/utils/stream_buffer_sizing_test.dart b/test/utils/stream_buffer_sizing_test.dart new file mode 100644 index 00000000..893a5b4e --- /dev/null +++ b/test/utils/stream_buffer_sizing_test.dart @@ -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); + }); + }); +}