mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
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:
committed by
GitHub
parent
df2f44e086
commit
f1575ca48b
@@ -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"}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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?.(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user