diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 5f2afc8f..84cd7da2 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -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[] = useMemo( @@ -138,6 +140,28 @@ export default function SettingsTV() { [currentAlignY], ); + // Cache mode options + const cacheModeOptions: TVOptionItem[] = 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[] = 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 ( @@ -382,6 +411,77 @@ export default function SettingsTV() { "Get your free API key at opensubtitles.com/en/consumers"} + {/* Buffer Settings Section */} + + + showOptions({ + title: t("home.settings.buffer.cache_mode"), + options: cacheModeOptions, + onSelect: (value) => updateSettings({ mpvCacheEnabled: value }), + }) + } + /> + { + 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`} + /> + { + 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`} + /> + { + 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 */} + {!Platform.isTV && } diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 13c88a92..17fa7506 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -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 () => { diff --git a/components/settings/MpvBufferSettings.tsx b/components/settings/MpvBufferSettings.tsx new file mode 100644 index 00000000..6df37412 --- /dev/null +++ b/components/settings/MpvBufferSettings.tsx @@ -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 ( + + + + + {currentCacheModeLabel} + + + + } + title={t("home.settings.buffer.cache_mode")} + /> + + + + updateSettings({ mpvCacheSeconds: value })} + appendValue='s' + /> + + + + updateSettings({ mpvDemuxerMaxBytes: value })} + appendValue=' MB' + /> + + + + + updateSettings({ mpvDemuxerMaxBackBytes: value }) + } + appendValue=' MB' + /> + + + ); +}; diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index af55826f..a6f00fd1 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -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)) diff --git a/modules/mpv-player/ios/MpvPlayerModule.swift b/modules/mpv-player/ios/MpvPlayerModule.swift index b60a3d40..c85c7fa3 100644 --- a/modules/mpv-player/ios/MpvPlayerModule.swift +++ b/modules/mpv-player/ios/MpvPlayerModule.swift @@ -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) } diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index 69c6d272..1dd2555f 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -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]) } diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts index 23f86093..c700cb82 100644 --- a/modules/mpv-player/src/MpvPlayer.types.ts +++ b/modules/mpv-player/src/MpvPlayer.types.ts @@ -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 = { diff --git a/translations/en.json b/translations/en.json index b3c6963f..6e016a12 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index aa4ac0c8..59aea126 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -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,