2026-06-12 22:21:17 +02:00
|
|
|
|
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
|
2026-06-13 20:51:14 +02:00
|
|
|
|
/// redials the connection on every seek, collapsing throughput.
|
|
|
|
|
|
const quickTimeFamilyContainers = {'mp4', 'mov', 'm4v', '3gp', '3g2'};
|
|
|
|
|
|
|
|
|
|
|
|
/// Matroska/WebM is normally well-interleaved, but UHD direct-play remuxes can
|
|
|
|
|
|
/// still overrun mpv's tiny 128 KiB default stream ring during normal demuxer
|
|
|
|
|
|
/// read bursts. Only opt in when the server reports a high bitrate so common
|
|
|
|
|
|
/// MKV playback keeps the default memory profile.
|
|
|
|
|
|
const matroskaFamilyContainers = {'mkv', 'matroska', 'webm'};
|
|
|
|
|
|
const _highBitrateMatroskaThresholdKbps = 40 * 1000;
|
2026-06-12 22:21:17 +02:00
|
|
|
|
|
|
|
|
|
|
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());
|
2026-06-13 20:51:14 +02:00
|
|
|
|
final isQuickTimeFamily = tokens.any(quickTimeFamilyContainers.contains);
|
|
|
|
|
|
final isMatroskaFamily = tokens.any(matroskaFamilyContainers.contains);
|
|
|
|
|
|
if (!isQuickTimeFamily && !isMatroskaFamily) return null;
|
2026-06-12 22:21:17 +02:00
|
|
|
|
|
|
|
|
|
|
final cap = max(minStreamRingBytes, min(maxBytes, maxStreamRingBytes));
|
2026-06-13 20:51:14 +02:00
|
|
|
|
if (bitrateKbps == null || bitrateKbps <= 0) {
|
|
|
|
|
|
return isQuickTimeFamily ? min(unknownBitrateStreamRingBytes, cap) : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!isQuickTimeFamily && bitrateKbps < _highBitrateMatroskaThresholdKbps) return null;
|
2026-06-12 22:21:17 +02:00
|
|
|
|
|
|
|
|
|
|
final bytesPerSecond = bitrateKbps * 1000 ~/ 8;
|
|
|
|
|
|
final ring = nextPowerOfTwo(bytesPerSecond * _streamRingContentSeconds);
|
|
|
|
|
|
return max(minStreamRingBytes, min(ring, cap));
|
|
|
|
|
|
}
|