feat(player): add mpv cache and buffer configuration

This commit is contained in:
Fredrik Burmester
2026-01-26 20:46:42 +01:00
parent 21f2ceefc3
commit 1cbb46f0ca
10 changed files with 299 additions and 9 deletions

View File

@@ -20,6 +20,7 @@ import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import {
AudioTranscodeMode,
type MpvCacheMode,
TVTypographyScale,
useSettings,
} from "@/utils/atoms/settings";
@@ -47,6 +48,7 @@ export default function SettingsTV() {
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
const currentTypographyScale =
settings.tvTypographyScale || TVTypographyScale.Default;
const currentCacheMode = settings.mpvCacheEnabled ?? "auto";
// Audio transcoding options
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
@@ -138,6 +140,28 @@ export default function SettingsTV() {
[currentAlignY],
);
// Cache mode options
const cacheModeOptions: TVOptionItem<MpvCacheMode>[] = useMemo(
() => [
{
label: t("home.settings.buffer.cache_auto"),
value: "auto",
selected: currentCacheMode === "auto",
},
{
label: t("home.settings.buffer.cache_yes"),
value: "yes",
selected: currentCacheMode === "yes",
},
{
label: t("home.settings.buffer.cache_no"),
value: "no",
selected: currentCacheMode === "no",
},
],
[t, currentCacheMode],
);
// Typography scale options
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
() => [
@@ -191,6 +215,11 @@ export default function SettingsTV() {
return option?.label || t("home.settings.appearance.display_size_default");
}, [typographyScaleOptions, t]);
const cacheModeLabel = useMemo(() => {
const option = cacheModeOptions.find((o) => o.selected);
return option?.label || t("home.settings.buffer.cache_auto");
}, [cacheModeOptions, t]);
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
<View style={{ flex: 1 }}>
@@ -382,6 +411,77 @@ export default function SettingsTV() {
"Get your free API key at opensubtitles.com/en/consumers"}
</Text>
{/* Buffer Settings Section */}
<TVSectionHeader title={t("home.settings.buffer.title")} />
<TVSettingsOptionButton
label={t("home.settings.buffer.cache_mode")}
value={cacheModeLabel}
onPress={() =>
showOptions({
title: t("home.settings.buffer.cache_mode"),
options: cacheModeOptions,
onSelect: (value) => updateSettings({ mpvCacheEnabled: value }),
})
}
/>
<TVSettingsStepper
label={t("home.settings.buffer.buffer_duration")}
value={settings.mpvCacheSeconds ?? 10}
onDecrease={() => {
const newValue = Math.max(
5,
(settings.mpvCacheSeconds ?? 10) - 5,
);
updateSettings({ mpvCacheSeconds: newValue });
}}
onIncrease={() => {
const newValue = Math.min(
120,
(settings.mpvCacheSeconds ?? 10) + 5,
);
updateSettings({ mpvCacheSeconds: newValue });
}}
formatValue={(v) => `${v}s`}
/>
<TVSettingsStepper
label={t("home.settings.buffer.max_cache_size")}
value={settings.mpvDemuxerMaxBytes ?? 150}
onDecrease={() => {
const newValue = Math.max(
50,
(settings.mpvDemuxerMaxBytes ?? 150) - 25,
);
updateSettings({ mpvDemuxerMaxBytes: newValue });
}}
onIncrease={() => {
const newValue = Math.min(
500,
(settings.mpvDemuxerMaxBytes ?? 150) + 25,
);
updateSettings({ mpvDemuxerMaxBytes: newValue });
}}
formatValue={(v) => `${v} MB`}
/>
<TVSettingsStepper
label={t("home.settings.buffer.max_backward_cache")}
value={settings.mpvDemuxerMaxBackBytes ?? 50}
onDecrease={() => {
const newValue = Math.max(
25,
(settings.mpvDemuxerMaxBackBytes ?? 50) - 25,
);
updateSettings({ mpvDemuxerMaxBackBytes: newValue });
}}
onIncrease={() => {
const newValue = Math.min(
200,
(settings.mpvDemuxerMaxBackBytes ?? 50) + 25,
);
updateSettings({ mpvDemuxerMaxBackBytes: newValue });
}}
formatValue={(v) => `${v} MB`}
/>
{/* Appearance Section */}
<TVSectionHeader title={t("home.settings.appearance.title")} />
<TVSettingsOptionButton

View File

@@ -3,6 +3,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { GestureControls } from "@/components/settings/GestureControls";
import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles";
import { MpvBufferSettings } from "@/components/settings/MpvBufferSettings";
import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings";
import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
@@ -26,6 +27,7 @@ export default function PlaybackControlsPage() {
<MediaToggles className='mb-4' />
<GestureControls className='mb-4' />
<PlaybackControlsSettings />
<MpvBufferSettings />
</MediaProvider>
</View>
{!Platform.isTV && <ChromecastSettings />}

View File

@@ -587,6 +587,13 @@ export default function page() {
autoplay: true,
initialSubtitleId,
initialAudioId,
// Pass cache/buffer settings from user preferences
cacheConfig: {
enabled: settings.mpvCacheEnabled,
cacheSeconds: settings.mpvCacheSeconds,
maxBytes: settings.mpvDemuxerMaxBytes,
maxBackBytes: settings.mpvDemuxerMaxBackBytes,
},
};
// Add external subtitles only for online playback
@@ -612,6 +619,10 @@ export default function page() {
subtitleIndex,
audioIndex,
offline,
settings.mpvCacheEnabled,
settings.mpvCacheSeconds,
settings.mpvDemuxerMaxBytes,
settings.mpvDemuxerMaxBackBytes,
]);
const volumeUpCb = useCallback(async () => {

View File

@@ -0,0 +1,100 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Stepper } from "@/components/inputs/Stepper";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [
{ key: "home.settings.buffer.cache_auto", value: "auto" },
{ key: "home.settings.buffer.cache_yes", value: "yes" },
{ key: "home.settings.buffer.cache_no", value: "no" },
];
export const MpvBufferSettings: React.FC = () => {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation();
const cacheModeOptions = useMemo(
() => [
{
options: CACHE_MODE_OPTIONS.map((option) => ({
type: "radio" as const,
label: t(option.key),
value: option.value,
selected: option.value === (settings?.mpvCacheEnabled ?? "auto"),
onPress: () => updateSettings({ mpvCacheEnabled: option.value }),
})),
},
],
[settings?.mpvCacheEnabled, t, updateSettings],
);
const currentCacheModeLabel = useMemo(() => {
const option = CACHE_MODE_OPTIONS.find(
(o) => o.value === (settings?.mpvCacheEnabled ?? "auto"),
);
return option ? t(option.key) : t("home.settings.buffer.cache_auto");
}, [settings?.mpvCacheEnabled, t]);
if (!settings) return null;
return (
<ListGroup title={t("home.settings.buffer.title")} className='mb-4'>
<ListItem title={t("home.settings.buffer.cache_mode")}>
<PlatformDropdown
groups={cacheModeOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{currentCacheModeLabel}
</Text>
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
</View>
}
title={t("home.settings.buffer.cache_mode")}
/>
</ListItem>
<ListItem title={t("home.settings.buffer.buffer_duration")}>
<Stepper
value={settings.mpvCacheSeconds ?? 10}
step={5}
min={5}
max={120}
onUpdate={(value) => updateSettings({ mpvCacheSeconds: value })}
appendValue='s'
/>
</ListItem>
<ListItem title={t("home.settings.buffer.max_cache_size")}>
<Stepper
value={settings.mpvDemuxerMaxBytes ?? 150}
step={25}
min={50}
max={500}
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBytes: value })}
appendValue=' MB'
/>
</ListItem>
<ListItem title={t("home.settings.buffer.max_backward_cache")}>
<Stepper
value={settings.mpvDemuxerMaxBackBytes ?? 50}
step={25}
min={25}
max={200}
onUpdate={(value) =>
updateSettings({ mpvDemuxerMaxBackBytes: value })
}
appendValue=' MB'
/>
</ListItem>
</ListGroup>
);
};

View File

@@ -300,7 +300,11 @@ final class MPVLayerRenderer {
startPosition: Double? = nil,
externalSubtitles: [String]? = nil,
initialSubtitleId: Int? = nil,
initialAudioId: Int? = nil
initialAudioId: Int? = nil,
cacheEnabled: String? = nil,
cacheSeconds: Int? = nil,
demuxerMaxBytes: Int? = nil,
demuxerMaxBackBytes: Int? = nil
) {
currentPreset = preset
currentURL = url
@@ -323,6 +327,21 @@ final class MPVLayerRenderer {
// Stop previous playback before loading new file
self.command(handle, ["stop"])
self.updateHTTPHeaders(headers)
// Apply cache/buffer settings
if let cacheMode = cacheEnabled {
self.setProperty(name: "cache", value: cacheMode)
}
if let cacheSecs = cacheSeconds {
self.setProperty(name: "cache-secs", value: String(cacheSecs))
}
if let maxBytes = demuxerMaxBytes {
self.setProperty(name: "demuxer-max-bytes", value: "\(maxBytes)MiB")
}
if let maxBackBytes = demuxerMaxBackBytes {
self.setProperty(name: "demuxer-max-back-bytes", value: "\(maxBackBytes)MiB")
}
// Set start position
if let startPos = startPosition, startPos > 0 {
self.setProperty(name: "start", value: String(format: "%.2f", startPos))

View File

@@ -29,7 +29,10 @@ public class MpvPlayerModule: Module {
guard let source = source,
let urlString = source["url"] as? String,
let videoURL = URL(string: urlString) else { return }
// Parse cache config if provided
let cacheConfig = source["cacheConfig"] as? [String: Any]
let config = VideoLoadConfig(
url: videoURL,
headers: source["headers"] as? [String: String],
@@ -37,9 +40,13 @@ public class MpvPlayerModule: Module {
startPosition: source["startPosition"] as? Double,
autoplay: (source["autoplay"] as? Bool) ?? true,
initialSubtitleId: source["initialSubtitleId"] as? Int,
initialAudioId: source["initialAudioId"] as? Int
initialAudioId: source["initialAudioId"] as? Int,
cacheEnabled: cacheConfig?["enabled"] as? String,
cacheSeconds: cacheConfig?["cacheSeconds"] as? Int,
demuxerMaxBytes: cacheConfig?["maxBytes"] as? Int,
demuxerMaxBackBytes: cacheConfig?["maxBackBytes"] as? Int
)
view.loadVideo(config: config)
}

View File

@@ -15,7 +15,12 @@ struct VideoLoadConfig {
var initialSubtitleId: Int?
/// MPV audio track ID to select on start (1-based, nil to use default)
var initialAudioId: Int?
/// Cache/buffer settings
var cacheEnabled: String? // "auto", "yes", or "no"
var cacheSeconds: Int? // Seconds of video to buffer
var demuxerMaxBytes: Int? // Max cache size in MB
var demuxerMaxBackBytes: Int? // Max backward cache size in MB
init(
url: URL,
headers: [String: String]? = nil,
@@ -23,7 +28,11 @@ struct VideoLoadConfig {
startPosition: Double? = nil,
autoplay: Bool = true,
initialSubtitleId: Int? = nil,
initialAudioId: Int? = nil
initialAudioId: Int? = nil,
cacheEnabled: String? = nil,
cacheSeconds: Int? = nil,
demuxerMaxBytes: Int? = nil,
demuxerMaxBackBytes: Int? = nil
) {
self.url = url
self.headers = headers
@@ -32,6 +41,10 @@ struct VideoLoadConfig {
self.autoplay = autoplay
self.initialSubtitleId = initialSubtitleId
self.initialAudioId = initialAudioId
self.cacheEnabled = cacheEnabled
self.cacheSeconds = cacheSeconds
self.demuxerMaxBytes = demuxerMaxBytes
self.demuxerMaxBackBytes = demuxerMaxBackBytes
}
}
@@ -151,13 +164,17 @@ class MpvPlayerView: ExpoView {
startPosition: config.startPosition,
externalSubtitles: config.externalSubtitles,
initialSubtitleId: config.initialSubtitleId,
initialAudioId: config.initialAudioId
initialAudioId: config.initialAudioId,
cacheEnabled: config.cacheEnabled,
cacheSeconds: config.cacheSeconds,
demuxerMaxBytes: config.demuxerMaxBytes,
demuxerMaxBackBytes: config.demuxerMaxBackBytes
)
if config.autoplay {
play()
}
onLoad(["url": config.url.absoluteString])
}

View File

@@ -43,6 +43,17 @@ export type VideoSource = {
initialSubtitleId?: number;
/** MPV audio track ID to select on start (1-based) */
initialAudioId?: number;
/** MPV cache/buffer configuration */
cacheConfig?: {
/** Whether caching is enabled: "auto" (default), "yes", or "no" */
enabled?: "auto" | "yes" | "no";
/** Seconds of video to buffer (default: 10, range: 5-120) */
cacheSeconds?: number;
/** Maximum cache size in MB (default: 150, range: 50-500) */
maxBytes?: number;
/** Maximum backward cache size in MB (default: 50, range: 25-200) */
maxBackBytes?: number;
};
};
export type MpvPlayerViewProps = {

View File

@@ -185,6 +185,16 @@
"rewind_length": "Rewind Length",
"seconds_unit": "s"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",
"cache_auto": "Auto",
"cache_yes": "Enabled",
"cache_no": "Disabled",
"buffer_duration": "Buffer Duration",
"max_cache_size": "Max Cache Size",
"max_backward_cache": "Max Backward Cache"
},
"gesture_controls": {
"gesture_controls_title": "Gesture Controls",
"horizontal_swipe_skip": "Horizontal Swipe to Skip",

View File

@@ -154,6 +154,9 @@ export enum AudioTranscodeMode {
AllowAll = "passthrough", // Direct play all audio formats
}
// MPV cache mode - controls how caching is enabled
export type MpvCacheMode = "auto" | "yes" | "no";
export type Settings = {
home?: Home | null;
deviceProfile?: "Expo" | "Native" | "Old";
@@ -199,6 +202,11 @@ export type Settings = {
mpvSubtitleAlignX?: "left" | "center" | "right";
mpvSubtitleAlignY?: "top" | "center" | "bottom";
mpvSubtitleFontSize?: number;
// MPV buffer/cache settings
mpvCacheEnabled?: MpvCacheMode;
mpvCacheSeconds?: number;
mpvDemuxerMaxBytes?: number; // MB
mpvDemuxerMaxBackBytes?: number; // MB
// Gesture controls
enableHorizontalSwipeSkip: boolean;
enableLeftSideBrightnessSwipe: boolean;
@@ -290,6 +298,11 @@ export const defaultValues: Settings = {
mpvSubtitleAlignX: undefined,
mpvSubtitleAlignY: undefined,
mpvSubtitleFontSize: undefined,
// MPV buffer/cache defaults
mpvCacheEnabled: "auto",
mpvCacheSeconds: 10,
mpvDemuxerMaxBytes: 150, // MB
mpvDemuxerMaxBackBytes: 50, // MB
// Gesture controls
enableHorizontalSwipeSkip: true,
enableLeftSideBrightnessSwipe: true,