feat: MPV player for both Android and iOS with added HW decoding PiP (with subtitles) (#1332)

Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
Co-authored-by: Alex <111128610+Alexk2309@users.noreply.github.com>
Co-authored-by: Simon-Eklundh <simon.eklundh@proton.me>
This commit is contained in:
Fredrik Burmester
2026-01-10 19:35:27 +01:00
committed by GitHub
parent df2f44e086
commit f1575ca48b
98 changed files with 3257 additions and 7448 deletions

View File

@@ -96,11 +96,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
style={[
{
position: "absolute",
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
bottom: settings?.safeAreaInControlsEnabled
? Math.max(insets.bottom - 17, 0)
: 0,
right:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
bottom:
(settings?.safeAreaInControlsEnabled ?? true)
? Math.max(insets.bottom - 17, 0)
: 0,
},
]}
className={"flex flex-col px-2"}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { Platform, StyleSheet, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { useSharedValue } from "react-native-reanimated";
@@ -16,10 +16,19 @@ const BrightnessSlider = () => {
const max = useSharedValue(100);
const isUserInteracting = useRef(false);
const lastKnownBrightness = useRef<number>(50);
const brightnessSupportedRef = useRef(true);
const [brightnessSupported, setBrightnessSupported] = useState(true);
// Update brightness from device
const updateBrightnessFromDevice = async () => {
if (isTv || !Brightness || isUserInteracting.current) return;
// Check ref (not state) to avoid stale closure in setInterval
if (
isTv ||
!Brightness ||
isUserInteracting.current ||
!brightnessSupportedRef.current
)
return;
try {
const currentBrightness = await Brightness.getBrightnessAsync();
@@ -31,7 +40,10 @@ const BrightnessSlider = () => {
lastKnownBrightness.current = brightnessPercent;
}
} catch (error) {
console.error("Error fetching brightness:", error);
console.warn("Brightness not supported on this device:", error);
// Update both ref (stops interval) and state (triggers re-render to hide)
brightnessSupportedRef.current = false;
setBrightnessSupported(false);
}
};
@@ -66,7 +78,7 @@ const BrightnessSlider = () => {
}, 100);
};
if (isTv) return null;
if (isTv || !brightnessSupported) return null;
return (
<View style={styles.sliderContainer}>

View File

@@ -38,8 +38,8 @@ export const CenterControls: FC<CenterControlsProps> = ({
style={{
position: "absolute",
top: "50%",
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",

View File

@@ -37,7 +37,6 @@ import { useVideoTime } from "./hooks/useVideoTime";
import { useControlsTimeout } from "./useControlsTimeout";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
import { type AspectRatio } from "./VideoScalingModeSelector";
import { type ScaleFactor } from "./VlcZoomControl";
interface Props {
item: BaseItemDto;
@@ -56,13 +55,7 @@ interface Props {
startPictureInPicture?: () => Promise<void>;
play: () => void;
pause: () => void;
useVlcPlayer?: boolean;
// VLC-specific props
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
aspectRatio?: AspectRatio;
scaleFactor?: ScaleFactor;
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
// KSPlayer-specific props
isZoomedToFill?: boolean;
onZoomToggle?: () => void;
api?: Api | null;
@@ -87,11 +80,7 @@ export const Controls: FC<Props> = ({
showControls,
setShowControls,
mediaSource,
useVlcPlayer = false,
setVideoAspectRatio,
aspectRatio = "default",
scaleFactor = 0,
setVideoScaleFactor,
isZoomedToFill = false,
onZoomToggle,
offline = false,
@@ -121,7 +110,7 @@ export const Controls: FC<Props> = ({
} = useTrickplay(item);
const min = useSharedValue(0);
const max = useSharedValue(item.RunTimeTicks || 0);
const max = useSharedValue(ticksToMs(item.RunTimeTicks || 0));
// Animation values for controls
const controlsOpacity = useSharedValue(showControls ? 1 : 0);
@@ -483,11 +472,7 @@ export const Controls: FC<Props> = ({
goToNextItem={goToNextItem}
previousItem={previousItem}
nextItem={nextItem}
useVlcPlayer={useVlcPlayer}
aspectRatio={aspectRatio}
setVideoAspectRatio={setVideoAspectRatio}
scaleFactor={scaleFactor}
setVideoScaleFactor={setVideoScaleFactor}
isZoomedToFill={isZoomedToFill}
onZoomToggle={onZoomToggle}
playbackSpeed={playbackSpeed}

View File

@@ -7,19 +7,14 @@ import { useRouter } from "expo-router";
import { type FC, useCallback, useState } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PlaybackSpeedSelector } from "@/components/PlaybackSpeedSelector";
import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation";
import { OrientationLock } from "@/packages/expo-screen-orientation";
import { useSettings, VideoPlayerIOS } from "@/utils/atoms/settings";
import { useSettings } from "@/utils/atoms/settings";
import { ICON_SIZES } from "./constants";
import DropdownView from "./dropdown/DropdownView";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
import {
type AspectRatio,
AspectRatioSelector,
} from "./VideoScalingModeSelector";
import { type ScaleFactor, VlcZoomControl } from "./VlcZoomControl";
import { type AspectRatio } from "./VideoScalingModeSelector";
import { ZoomToggle } from "./ZoomToggle";
interface HeaderControlsProps {
@@ -33,13 +28,7 @@ interface HeaderControlsProps {
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
previousItem?: BaseItemDto | null;
nextItem?: BaseItemDto | null;
useVlcPlayer?: boolean;
// VLC-specific props
aspectRatio?: AspectRatio;
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
scaleFactor?: ScaleFactor;
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
// KSPlayer-specific props
isZoomedToFill?: boolean;
onZoomToggle?: () => void;
// Playback speed props
@@ -58,11 +47,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
goToNextItem,
previousItem,
nextItem,
useVlcPlayer = false,
aspectRatio = "default",
setVideoAspectRatio,
scaleFactor = 0,
setVideoScaleFactor,
aspectRatio: _aspectRatio = "default",
isZoomedToFill = false,
onZoomToggle,
playbackSpeed = 1.0,
@@ -109,9 +94,10 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
style={[
{
position: "absolute",
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
right:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
},
]}
pointerEvents={showControls ? "auto" : "none"}
@@ -120,7 +106,10 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
<View className='mr-auto p-2' pointerEvents='box-none'>
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
<View pointerEvents='auto'>
<DropdownView />
<DropdownView
playbackSpeed={playbackSpeed}
setPlaybackSpeed={setPlaybackSpeed}
/>
</View>
)}
</View>
@@ -142,20 +131,18 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
/>
</TouchableOpacity>
)}
{!Platform.isTV &&
startPictureInPicture &&
settings?.videoPlayerIOS !== VideoPlayerIOS.VLC && (
<TouchableOpacity
onPress={startPictureInPicture}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<MaterialIcons
name='picture-in-picture'
size={ICON_SIZES.HEADER}
color='white'
/>
</TouchableOpacity>
)}
{!Platform.isTV && startPictureInPicture && (
<TouchableOpacity
onPress={startPictureInPicture}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<MaterialIcons
name='picture-in-picture'
size={ICON_SIZES.HEADER}
color='white'
/>
</TouchableOpacity>
)}
{item?.Type === "Episode" && (
<TouchableOpacity
onPress={switchOnEpisodeMode}
@@ -188,47 +175,12 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
/>
</TouchableOpacity>
)}
{/* Playback Speed Control */}
{!Platform.isTV && setPlaybackSpeed && (
<PlaybackSpeedSelector
selected={playbackSpeed}
onChange={setPlaybackSpeed}
item={item}
/>
)}
{/* VLC-specific controls: Aspect Ratio and Scale/Zoom */}
{useVlcPlayer && (
<AspectRatioSelector
currentRatio={aspectRatio}
onRatioChange={async (newRatio) => {
if (setVideoAspectRatio) {
const aspectRatioString =
newRatio === "default" ? null : newRatio;
await setVideoAspectRatio(aspectRatioString);
}
}}
disabled={!setVideoAspectRatio}
/>
)}
{useVlcPlayer && (
<VlcZoomControl
currentScale={scaleFactor}
onScaleChange={async (newScale) => {
if (setVideoScaleFactor) {
await setVideoScaleFactor(newScale);
}
}}
disabled={!setVideoScaleFactor}
/>
)}
{/* KSPlayer-specific control: Zoom to Fill */}
{!useVlcPlayer && (
<ZoomToggle
isZoomedToFill={isZoomedToFill}
onToggle={onZoomToggle ?? (() => {})}
disabled={!onZoomToggle}
/>
)}
{/* MPV Zoom Toggle */}
<ZoomToggle
isZoomedToFill={isZoomedToFill}
onToggle={onZoomToggle ?? (() => {})}
disabled={!onZoomToggle}
/>
<TouchableOpacity
onPress={onClose}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'

View File

@@ -1,121 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import React, { useMemo } from "react";
import { Platform, View } from "react-native";
import {
type OptionGroup,
PlatformDropdown,
} from "@/components/PlatformDropdown";
import { useHaptic } from "@/hooks/useHaptic";
import { ICON_SIZES } from "./constants";
export type ScaleFactor = 0 | 0.25 | 0.5 | 0.75 | 1.0 | 1.25 | 1.5 | 2.0;
interface VlcZoomControlProps {
currentScale: ScaleFactor;
onScaleChange: (scale: ScaleFactor) => void;
disabled?: boolean;
}
interface ScaleOption {
id: ScaleFactor;
label: string;
description: string;
}
const SCALE_OPTIONS: ScaleOption[] = [
{
id: 0,
label: "Fit",
description: "Fit video to screen",
},
{
id: 0.25,
label: "25%",
description: "Quarter size",
},
{
id: 0.5,
label: "50%",
description: "Half size",
},
{
id: 0.75,
label: "75%",
description: "Three quarters",
},
{
id: 1.0,
label: "100%",
description: "Original video size",
},
{
id: 1.25,
label: "125%",
description: "Slight zoom",
},
{
id: 1.5,
label: "150%",
description: "Medium zoom",
},
{
id: 2.0,
label: "200%",
description: "Maximum zoom",
},
];
export const VlcZoomControl: React.FC<VlcZoomControlProps> = ({
currentScale,
onScaleChange,
disabled = false,
}) => {
const lightHapticFeedback = useHaptic("light");
const handleScaleSelect = (scale: ScaleFactor) => {
onScaleChange(scale);
lightHapticFeedback();
};
const optionGroups = useMemo<OptionGroup[]>(() => {
return [
{
options: SCALE_OPTIONS.map((option) => ({
type: "radio" as const,
label: option.label,
value: option.id,
selected: option.id === currentScale,
onPress: () => handleScaleSelect(option.id),
disabled,
})),
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentScale, disabled]);
const trigger = useMemo(
() => (
<View
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
style={{ opacity: disabled ? 0.5 : 1 }}
>
<Ionicons name='scan-outline' size={ICON_SIZES.HEADER} color='white' />
</View>
),
[disabled],
);
// Hide on TV platforms
if (Platform.isTV) return null;
return (
<PlatformDropdown
title='Zoom'
groups={optionGroups}
trigger={trigger}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
);
};

View File

@@ -9,19 +9,15 @@ import React, {
useContext,
useMemo,
} from "react";
import type { SfPlayerViewRef, VlcPlayerViewRef } from "@/modules";
import type { MpvPlayerViewRef } from "@/modules";
import type { DownloadedItem } from "@/providers/Downloads/types";
// Union type for both player refs
type PlayerRef = SfPlayerViewRef | VlcPlayerViewRef;
interface PlayerContextProps {
playerRef: MutableRefObject<PlayerRef | null>;
playerRef: MutableRefObject<MpvPlayerViewRef | null>;
item: BaseItemDto;
mediaSource: MediaSourceInfo | null | undefined;
isVideoLoaded: boolean;
tracksReady: boolean;
useVlcPlayer: boolean;
offline: boolean;
downloadedItem: DownloadedItem | null;
}
@@ -30,12 +26,11 @@ const PlayerContext = createContext<PlayerContextProps | undefined>(undefined);
interface PlayerProviderProps {
children: ReactNode;
playerRef: MutableRefObject<PlayerRef | null>;
playerRef: MutableRefObject<MpvPlayerViewRef | null>;
item: BaseItemDto;
mediaSource: MediaSourceInfo | null | undefined;
isVideoLoaded: boolean;
tracksReady: boolean;
useVlcPlayer: boolean;
offline?: boolean;
downloadedItem?: DownloadedItem | null;
}
@@ -47,7 +42,6 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
mediaSource,
isVideoLoaded,
tracksReady,
useVlcPlayer,
offline = false,
downloadedItem = null,
}) => {
@@ -58,7 +52,6 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
mediaSource,
isVideoLoaded,
tracksReady,
useVlcPlayer,
offline,
downloadedItem,
}),
@@ -68,7 +61,6 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
mediaSource,
isVideoLoaded,
tracksReady,
useVlcPlayer,
offline,
downloadedItem,
],
@@ -87,30 +79,26 @@ export const usePlayerContext = () => {
return context;
};
// Player controls hook - supports both SfPlayer (iOS) and VlcPlayer (Android)
// Player controls hook - MPV player only
export const usePlayerControls = () => {
const { playerRef } = usePlayerContext();
// Helper to get SfPlayer-specific ref (for iOS-only features)
const getSfRef = () => playerRef.current as SfPlayerViewRef | null;
return {
// Subtitle controls (both players support these, but with different interfaces)
// Subtitle controls
getSubtitleTracks: async () => {
return playerRef.current?.getSubtitleTracks?.() ?? null;
},
setSubtitleTrack: (trackId: number) => {
playerRef.current?.setSubtitleTrack?.(trackId);
},
// iOS only (SfPlayer)
disableSubtitles: () => {
getSfRef()?.disableSubtitles?.();
playerRef.current?.disableSubtitles?.();
},
addSubtitleFile: (url: string, select = true) => {
getSfRef()?.addSubtitleFile?.(url, select);
playerRef.current?.addSubtitleFile?.(url, select);
},
// Audio controls (both players)
// Audio controls
getAudioTracks: async () => {
return playerRef.current?.getAudioTracks?.() ?? null;
},
@@ -118,26 +106,25 @@ export const usePlayerControls = () => {
playerRef.current?.setAudioTrack?.(trackId);
},
// Playback controls (both players)
// Playback controls
play: () => playerRef.current?.play?.(),
pause: () => playerRef.current?.pause?.(),
seekTo: (position: number) => playerRef.current?.seekTo?.(position),
// iOS only (SfPlayer)
seekBy: (offset: number) => getSfRef()?.seekBy?.(offset),
setSpeed: (speed: number) => getSfRef()?.setSpeed?.(speed),
seekBy: (offset: number) => playerRef.current?.seekBy?.(offset),
setSpeed: (speed: number) => playerRef.current?.setSpeed?.(speed),
// Subtitle positioning - iOS only (SfPlayer)
setSubtitleScale: (scale: number) => getSfRef()?.setSubtitleScale?.(scale),
// Subtitle positioning
setSubtitleScale: (scale: number) =>
playerRef.current?.setSubtitleScale?.(scale),
setSubtitlePosition: (position: number) =>
getSfRef()?.setSubtitlePosition?.(position),
playerRef.current?.setSubtitlePosition?.(position),
setSubtitleMarginY: (margin: number) =>
getSfRef()?.setSubtitleMarginY?.(margin),
playerRef.current?.setSubtitleMarginY?.(margin),
setSubtitleFontSize: (size: number) =>
getSfRef()?.setSubtitleFontSize?.(size),
playerRef.current?.setSubtitleFontSize?.(size),
// PiP (both players)
// PiP
startPictureInPicture: () => playerRef.current?.startPictureInPicture?.(),
// iOS only (SfPlayer)
stopPictureInPicture: () => getSfRef()?.stopPictureInPicture?.(),
stopPictureInPicture: () => playerRef.current?.stopPictureInPicture?.(),
};
};

View File

@@ -8,7 +8,7 @@
* ============================================================================
*
* - Jellyfin is source of truth for subtitle list (embedded + external)
* - KSPlayer only knows about:
* - MPV only knows about:
* - Embedded subs it finds in the video stream
* - External subs we explicitly add via addSubtitleFile()
* - UI shows Jellyfin's complete list
@@ -24,8 +24,8 @@
* - Value of -1 means disabled/none
*
* 2. MPV INDEX (track.mpvIndex)
* - KSPlayer's internal track ID
* - KSPlayer orders tracks as: [all embedded, then all external]
* - MPV's internal track ID
* - MPV orders tracks as: [all embedded, then all external]
* - IDs: 1..embeddedCount for embedded, embeddedCount+1.. for external
* - Value of -1 means track needs replacePlayer() (e.g., burned-in sub)
*
@@ -34,15 +34,15 @@
* ============================================================================
*
* Embedded (DeliveryMethod.Embed):
* - Already in KSPlayer's track list
* - Already in MPV's track list
* - Select via setSubtitleTrack(mpvId)
*
* External (DeliveryMethod.External):
* - Loaded into KSPlayer's srtControl on video start
* - Loaded into MPV on video start
* - Select via setSubtitleTrack(embeddedCount + externalPosition + 1)
*
* Image-based during transcoding:
* - Burned into video by Jellyfin, not in KSPlayer
* - Burned into video by Jellyfin, not in MPV
* - Requires replacePlayer() to change
*/
@@ -57,7 +57,7 @@ import {
useMemo,
useState,
} from "react";
import type { SfAudioTrack, TrackInfo } from "@/modules";
import type { MpvAudioTrack } from "@/modules";
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
import type { Track } from "../types";
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
@@ -75,7 +75,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
const { tracksReady, mediaSource, useVlcPlayer, offline, downloadedItem } =
const { tracksReady, mediaSource, offline, downloadedItem } =
usePlayerContext();
const playerControls = usePlayerControls();
@@ -149,7 +149,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
{
name: downloadedTrack.DisplayTitle || "Audio",
index: downloadedTrack.Index ?? 0,
mpvIndex: useVlcPlayer ? 0 : 1, // Only track in file
mpvIndex: 1, // Only track in file (MPV uses 1-based indexing)
setTrack: () => {
// Track is already selected (only one available)
router.setParams({ audioIndex: String(downloadedTrack.Index) });
@@ -212,99 +212,12 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
return;
}
// For VLC player, use simpler track handling with server indices
if (useVlcPlayer) {
// Get VLC track info (VLC returns TrackInfo[] with 'index' property)
const vlcSubtitleData = (await playerControls
.getSubtitleTracks()
.catch(() => null)) as TrackInfo[] | null;
const vlcAudioData = (await playerControls
.getAudioTracks()
.catch(() => null)) as TrackInfo[] | null;
// VLC reverses HLS subtitles during transcoding
let vlcSubs: TrackInfo[] = vlcSubtitleData ? [...vlcSubtitleData] : [];
if (isTranscoding && vlcSubs.length > 1) {
vlcSubs = [vlcSubs[0], ...vlcSubs.slice(1).reverse()];
}
// Build subtitle tracks for VLC
const subs: Track[] = [];
let vlcSubIndex = 1; // VLC track indices start at 1 (0 is usually "Disable")
for (const sub of allSubs) {
const isTextBased =
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
// Get VLC's internal index for this track
const vlcTrackIndex = vlcSubs[vlcSubIndex]?.index ?? -1;
if (isTextBased) vlcSubIndex++;
// For image-based subs during transcoding, or non-text subs, use replacePlayer
const needsPlayerRefresh =
(isTranscoding && isImageBasedSubtitle(sub)) || !isTextBased;
subs.push({
name: sub.DisplayTitle || "Unknown",
index: sub.Index ?? -1,
mpvIndex: vlcTrackIndex,
setTrack: () => {
if (needsPlayerRefresh) {
replacePlayer({ subtitleIndex: String(sub.Index) });
} else if (vlcTrackIndex !== -1) {
playerControls.setSubtitleTrack(vlcTrackIndex);
router.setParams({ subtitleIndex: String(sub.Index) });
} else {
replacePlayer({ subtitleIndex: String(sub.Index) });
}
},
});
}
// Add "Disable" option
subs.unshift({
name: "Disable",
index: -1,
mpvIndex: -1,
setTrack: () => {
playerControls.setSubtitleTrack(-1);
router.setParams({ subtitleIndex: "-1" });
},
});
// Build audio tracks for VLC
const vlcAudio: TrackInfo[] = vlcAudioData ? [...vlcAudioData] : [];
const audio: Track[] = allAudio.map((a, idx) => {
const vlcTrackIndex = vlcAudio[idx + 1]?.index ?? idx;
return {
name: a.DisplayTitle || "Unknown",
index: a.Index ?? -1,
mpvIndex: vlcTrackIndex,
setTrack: () => {
if (isTranscoding) {
replacePlayer({ audioIndex: String(a.Index) });
} else {
playerControls.setAudioTrack(vlcTrackIndex);
router.setParams({ audioIndex: String(a.Index) });
}
},
};
});
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
setAudioTracks(audio);
return;
}
// KSPlayer track handling (original logic)
// MPV track handling
const audioData = await playerControls.getAudioTracks().catch(() => null);
const playerAudio = (audioData as SfAudioTrack[]) ?? [];
const playerAudio = (audioData as MpvAudioTrack[]) ?? [];
// Separate embedded vs external subtitles from Jellyfin's list
// KSPlayer orders tracks as: [all embedded, then all external]
// MPV orders tracks as: [all embedded, then all external]
const embeddedSubs = allSubs.filter(
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.Embed,
);
@@ -312,7 +225,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.External,
);
// Count embedded subs that will be in KSPlayer
// Count embedded subs that will be in MPV
// (excludes image-based subs during transcoding as they're burned in)
const embeddedInPlayer = embeddedSubs.filter(
(s) => !isTranscoding || !isImageBasedSubtitle(s),
@@ -339,8 +252,8 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
continue;
}
// Calculate KSPlayer track ID based on type
// KSPlayer IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external
// Calculate MPV track ID based on type
// MPV IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external
let mpvId = -1;
if (isEmbedded) {
@@ -428,7 +341,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
};
fetchTracks();
}, [tracksReady, mediaSource, useVlcPlayer, offline, downloadedItem]);
}, [tracksReady, mediaSource, offline, downloadedItem]);
return (
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>

View File

@@ -7,9 +7,11 @@ import {
type OptionGroup,
PlatformDropdown,
} from "@/components/PlatformDropdown";
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
import { useSettings } from "@/utils/atoms/settings";
import { usePlayerContext } from "../contexts/PlayerContext";
import { useVideoContext } from "../contexts/VideoContext";
import { PlaybackSpeedScope } from "../utils/playback-speed-settings";
// Subtitle size presets (stored as scale * 100, so 1.0 = 100)
const SUBTITLE_SIZE_PRESETS = [
@@ -23,9 +25,17 @@ const SUBTITLE_SIZE_PRESETS = [
{ label: "1.2", value: 120 },
] as const;
const DropdownView = () => {
interface DropdownViewProps {
playbackSpeed?: number;
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
}
const DropdownView = ({
playbackSpeed = 1.0,
setPlaybackSpeed,
}: DropdownViewProps) => {
const { subtitleTracks, audioTracks } = useVideoContext();
const { item, mediaSource, useVlcPlayer } = usePlayerContext();
const { item, mediaSource } = usePlayerContext();
const { settings, updateSettings } = useSettings();
const router = useRouter();
@@ -110,19 +120,17 @@ const DropdownView = () => {
})),
});
// Subtitle Size Section (KSPlayer only - VLC uses settings)
if (!useVlcPlayer) {
groups.push({
title: "Subtitle Size",
options: SUBTITLE_SIZE_PRESETS.map((preset) => ({
type: "radio" as const,
label: preset.label,
value: preset.value.toString(),
selected: settings.subtitleSize === preset.value,
onPress: () => updateSettings({ subtitleSize: preset.value }),
})),
});
}
// Subtitle Size Section
groups.push({
title: "Subtitle Size",
options: SUBTITLE_SIZE_PRESETS.map((preset) => ({
type: "radio" as const,
label: preset.label,
value: preset.value.toString(),
selected: settings.subtitleSize === preset.value,
onPress: () => updateSettings({ subtitleSize: preset.value }),
})),
});
}
// Audio Section
@@ -139,6 +147,20 @@ const DropdownView = () => {
});
}
// Speed Section
if (setPlaybackSpeed) {
groups.push({
title: "Speed",
options: PLAYBACK_SPEEDS.map((speed) => ({
type: "radio" as const,
label: speed.label,
value: speed.value.toString(),
selected: playbackSpeed === speed.value,
onPress: () => setPlaybackSpeed(speed.value, PlaybackSpeedScope.All),
})),
});
}
return groups;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
@@ -151,7 +173,8 @@ const DropdownView = () => {
audioIndex,
settings.subtitleSize,
updateSettings,
useVlcPlayer,
playbackSpeed,
setPlaybackSpeed,
// Note: subtitleTracks and audioTracks are intentionally excluded
// because we use subtitleTracksKey and audioTracksKey for stability
]);

View File

@@ -34,6 +34,7 @@ export const useVolumeAndBrightness = ({
const initialVolume = useRef<number | null>(null);
const initialBrightness = useRef<number | null>(null);
const dragStartY = useRef<number | null>(null);
const brightnessSupported = useRef(true);
const startVolumeDrag = useCallback(async (startY: number) => {
if (Platform.isTV || !VolumeManager) return;
@@ -88,20 +89,26 @@ export const useVolumeAndBrightness = ({
}, []);
const startBrightnessDrag = useCallback(async (startY: number) => {
if (Platform.isTV || !Brightness) return;
if (Platform.isTV || !Brightness || !brightnessSupported.current) return;
try {
const brightness = await Brightness.getBrightnessAsync();
initialBrightness.current = brightness;
dragStartY.current = startY;
} catch (error) {
console.error("Error starting brightness drag:", error);
console.warn("Brightness not supported on this device:", error);
brightnessSupported.current = false;
}
}, []);
const updateBrightnessDrag = useCallback(
async (deltaY: number) => {
if (Platform.isTV || !Brightness || initialBrightness.current === null)
if (
Platform.isTV ||
!Brightness ||
initialBrightness.current === null ||
!brightnessSupported.current
)
return;
try {
@@ -118,7 +125,8 @@ export const useVolumeAndBrightness = ({
const brightnessPercent = Math.round(newBrightness * 100);
onBrightnessChange?.(brightnessPercent);
} catch (error) {
console.error("Error updating brightness:", error);
console.warn("Brightness not supported on this device:", error);
brightnessSupported.current = false;
}
},
[onBrightnessChange],