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,
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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