fix(player): scale mpv stream ring buffer for poorly interleaved mp4/mov
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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<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
|
||||
/// 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<String, String>? 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user