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

@@ -1,6 +1,7 @@
import { Feather } from "@expo/vector-icons";
import { useCallback, useEffect } from "react";
import { Platform, TouchableOpacity } from "react-native";
import { Platform } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import GoogleCast, {
CastButton,
CastContext,
@@ -44,7 +45,7 @@ export function Chromecast({
if (Platform.OS === "ios") {
return (
<TouchableOpacity
<Pressable
className='mr-4'
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
@@ -54,7 +55,7 @@ export function Chromecast({
>
<AndroidCastButton />
<Feather name='cast' size={22} color={"white"} />
</TouchableOpacity>
</Pressable>
);
}

View File

@@ -209,6 +209,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
subtitleStreamIndex: subtitleIndex ?? -1,
maxBitrate: selectedOptions?.bitrate || defaultBitrate,
deviceId: api.deviceInfo.id,
audioMode: settings?.audioTranscodeMode,
});
return {

View File

@@ -1,7 +1,8 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import type { PropsWithChildren } from "react";
import { Platform, TouchableOpacity, type ViewProps } from "react-native";
import { Platform, type ViewProps } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends ViewProps {
@@ -38,7 +39,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
if (Platform.OS === "ios") {
return (
<TouchableOpacity
<Pressable
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...(viewProps as any)}
@@ -51,13 +52,13 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
/>
) : null}
{children ? children : null}
</TouchableOpacity>
</Pressable>
);
}
if (fillColor)
return (
<TouchableOpacity
<Pressable
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...(viewProps as any)}
@@ -70,12 +71,12 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
/>
) : null}
{children ? children : null}
</TouchableOpacity>
</Pressable>
);
if (background === false)
return (
<TouchableOpacity
<Pressable
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...(viewProps as any)}
@@ -88,12 +89,12 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
/>
) : null}
{children ? children : null}
</TouchableOpacity>
</Pressable>
);
if (Platform.OS === "android")
return (
<TouchableOpacity
<Pressable
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${
fillColor ? fillColorClass : "bg-transparent"
@@ -108,11 +109,11 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
/>
) : null}
{children ? children : null}
</TouchableOpacity>
</Pressable>
);
return (
<TouchableOpacity onPress={handlePress} {...(viewProps as any)}>
<Pressable onPress={handlePress} {...(viewProps as any)}>
<BlurView
intensity={90}
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
@@ -127,6 +128,6 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
) : null}
{children ? children : null}
</BlurView>
</TouchableOpacity>
</Pressable>
);
};

View File

@@ -1,42 +1,36 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView, type BlurViewProps } from "expo-blur";
import { useRouter } from "expo-router";
import {
Platform,
TouchableOpacity,
type TouchableOpacityProps,
} from "react-native";
import { Platform } from "react-native";
import { Pressable, type PressableProps } from "react-native-gesture-handler";
interface Props extends BlurViewProps {
background?: "blur" | "transparent";
touchableOpacityProps?: TouchableOpacityProps;
pressableProps?: Omit<PressableProps, "onPress">;
}
export const HeaderBackButton: React.FC<Props> = ({
background = "transparent",
touchableOpacityProps,
pressableProps,
...props
}) => {
const router = useRouter();
if (Platform.OS === "ios") {
return (
<TouchableOpacity
<Pressable
onPress={() => router.back()}
className='flex items-center justify-center w-9 h-9'
{...touchableOpacityProps}
{...pressableProps}
>
<Ionicons name='arrow-back' size={24} color='white' />
</TouchableOpacity>
</Pressable>
);
}
if (background === "transparent" && Platform.OS !== "android")
return (
<TouchableOpacity
onPress={() => router.back()}
{...touchableOpacityProps}
>
<Pressable onPress={() => router.back()} {...pressableProps}>
<BlurView
{...props}
intensity={100}
@@ -49,14 +43,14 @@ export const HeaderBackButton: React.FC<Props> = ({
color='white'
/>
</BlurView>
</TouchableOpacity>
</Pressable>
);
return (
<TouchableOpacity
<Pressable
onPress={() => router.back()}
className=' rounded-full p-2'
{...touchableOpacityProps}
{...pressableProps}
>
<Ionicons
className='drop-shadow-2xl'
@@ -64,6 +58,6 @@ export const HeaderBackButton: React.FC<Props> = ({
size={24}
color='white'
/>
</TouchableOpacity>
</Pressable>
);
};

View File

@@ -21,9 +21,9 @@ import {
Platform,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
@@ -118,7 +118,7 @@ export const Home = () => {
}
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
<Pressable
onPress={() => {
router.push("/(auth)/downloads");
}}
@@ -130,7 +130,7 @@ export const Home = () => {
color={hasDownloads ? Colors.primary : "white"}
size={24}
/>
</TouchableOpacity>
</Pressable>
),
});
}, [navigation, router, hasDownloads]);

View File

@@ -1,40 +0,0 @@
import type React from "react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Platform, Switch } from "react-native";
import { setHardwareDecode } from "@/modules/sf-player";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const KSPlayerSettings: React.FC = () => {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation();
const handleHardwareDecodeChange = useCallback(
(value: boolean) => {
updateSettings({ ksHardwareDecode: value });
setHardwareDecode(value);
},
[updateSettings],
);
if (Platform.OS !== "ios" || !settings) return null;
return (
<ListGroup
title={t("home.settings.subtitles.ksplayer_title")}
className='mt-4'
>
<ListItem
title={t("home.settings.subtitles.hardware_decode")}
subtitle={t("home.settings.subtitles.hardware_decode_description")}
>
<Switch
value={settings.ksHardwareDecode}
onValueChange={handleHardwareDecodeChange}
/>
</ListItem>
</ListGroup>
);
};

View File

@@ -0,0 +1,133 @@
import { Ionicons } from "@expo/vector-icons";
import { useMemo } from "react";
import { Platform, View, type ViewProps } from "react-native";
import { Stepper } from "@/components/inputs/Stepper";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { PlatformDropdown } from "../PlatformDropdown";
import { useMedia } from "./MediaContext";
interface Props extends ViewProps {}
type AlignX = "left" | "center" | "right";
type AlignY = "top" | "center" | "bottom";
export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV;
const media = useMedia();
const { settings, updateSettings } = media;
const alignXOptions: AlignX[] = ["left", "center", "right"];
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
const alignXLabels: Record<AlignX, string> = {
left: "Left",
center: "Center",
right: "Right",
};
const alignYLabels: Record<AlignY, string> = {
top: "Top",
center: "Center",
bottom: "Bottom",
};
const alignXOptionGroups = useMemo(() => {
const options = alignXOptions.map((align) => ({
type: "radio" as const,
label: alignXLabels[align],
value: align,
selected: align === (settings?.mpvSubtitleAlignX ?? "center"),
onPress: () => updateSettings({ mpvSubtitleAlignX: align }),
}));
return [{ options }];
}, [settings?.mpvSubtitleAlignX, updateSettings]);
const alignYOptionGroups = useMemo(() => {
const options = alignYOptions.map((align) => ({
type: "radio" as const,
label: alignYLabels[align],
value: align,
selected: align === (settings?.mpvSubtitleAlignY ?? "bottom"),
onPress: () => updateSettings({ mpvSubtitleAlignY: align }),
}));
return [{ options }];
}, [settings?.mpvSubtitleAlignY, updateSettings]);
if (isTv) return null;
if (!settings) return null;
return (
<View {...props}>
<ListGroup
title='MPV Subtitle Settings'
description={
<Text className='text-[#8E8D91] text-xs'>
Advanced subtitle customization for MPV player
</Text>
}
>
<ListItem title='Subtitle Scale'>
<Stepper
value={settings.mpvSubtitleScale ?? 1.0}
step={0.1}
min={0.5}
max={2.0}
onUpdate={(value) =>
updateSettings({ mpvSubtitleScale: Math.round(value * 10) / 10 })
}
/>
</ListItem>
<ListItem title='Vertical Margin'>
<Stepper
value={settings.mpvSubtitleMarginY ?? 0}
step={5}
min={0}
max={100}
onUpdate={(value) => updateSettings({ mpvSubtitleMarginY: value })}
/>
</ListItem>
<ListItem title='Horizontal Alignment'>
<PlatformDropdown
groups={alignXOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{alignXLabels[settings?.mpvSubtitleAlignX ?? "center"]}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title='Horizontal Alignment'
/>
</ListItem>
<ListItem title='Vertical Alignment'>
<PlatformDropdown
groups={alignYOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{alignYLabels[settings?.mpvSubtitleAlignY ?? "bottom"]}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title='Vertical Alignment'
/>
</ListItem>
</ListGroup>
</View>
);
};

View File

@@ -141,36 +141,6 @@ export const OtherSettings: React.FC = () => {
/>
</ListItem>
{/* {(Platform.OS === "ios" || Platform.isTVOS)&& (
<ListItem
title={t("home.settings.other.video_player")}
disabled={pluginSettings?.defaultPlayer?.locked}
>
<Dropdown
data={Object.values(VideoPlayer).filter(isNumber)}
disabled={pluginSettings?.defaultPlayer?.locked}
keyExtractor={String}
titleExtractor={(item) => t(`home.settings.other.video_players.${VideoPlayer[item]}`)}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-1.5 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{t(`home.settings.other.video_players.${VideoPlayer[settings.defaultPlayer]}`)}
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
</TouchableOpacity>
}
label={t("home.settings.other.orientation")}
onSelected={(defaultPlayer) =>
updateSettings({ defaultPlayer })
}
/>
</ListItem>
)} */}
<ListItem
title={t("home.settings.other.show_custom_menu_links")}
disabled={pluginSettings?.showCustomMenuLinks?.locked}

View File

@@ -13,7 +13,6 @@ import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { VideoPlayerSettings } from "./VideoPlayerSettings";
export const PlaybackControlsSettings: React.FC = () => {
const { settings, updateSettings, pluginSettings } = useSettings();
@@ -231,8 +230,6 @@ export const PlaybackControlsSettings: React.FC = () => {
/>
</ListItem>
</ListGroup>
<VideoPlayerSettings />
</DisabledSetting>
);
};

View File

@@ -1,93 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, Switch, View } from "react-native";
import { setHardwareDecode } from "@/modules/sf-player";
import { useSettings, VideoPlayerIOS } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { PlatformDropdown } from "../PlatformDropdown";
export const VideoPlayerSettings: React.FC = () => {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation();
const handleHardwareDecodeChange = useCallback(
(value: boolean) => {
updateSettings({ ksHardwareDecode: value });
setHardwareDecode(value);
},
[updateSettings],
);
const videoPlayerOptions = useMemo(
() => [
{
options: [
{
type: "radio" as const,
label: t("home.settings.video_player.ksplayer"),
value: VideoPlayerIOS.KSPlayer,
selected: settings?.videoPlayerIOS === VideoPlayerIOS.KSPlayer,
onPress: () =>
updateSettings({ videoPlayerIOS: VideoPlayerIOS.KSPlayer }),
},
{
type: "radio" as const,
label: t("home.settings.video_player.vlc"),
value: VideoPlayerIOS.VLC,
selected: settings?.videoPlayerIOS === VideoPlayerIOS.VLC,
onPress: () =>
updateSettings({ videoPlayerIOS: VideoPlayerIOS.VLC }),
},
],
},
],
[settings?.videoPlayerIOS, t, updateSettings],
);
const getPlayerLabel = useCallback(() => {
switch (settings?.videoPlayerIOS) {
case VideoPlayerIOS.VLC:
return t("home.settings.video_player.vlc");
default:
return t("home.settings.video_player.ksplayer");
}
}, [settings?.videoPlayerIOS, t]);
if (Platform.OS !== "ios" || !settings) return null;
return (
<ListGroup title={t("home.settings.video_player.title")} className='mt-4'>
<ListItem
title={t("home.settings.video_player.video_player")}
subtitle={t("home.settings.video_player.video_player_description")}
>
<PlatformDropdown
groups={videoPlayerOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>{getPlayerLabel()}</Text>
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
</View>
}
title={t("home.settings.video_player.video_player")}
/>
</ListItem>
{settings.videoPlayerIOS === VideoPlayerIOS.KSPlayer && (
<ListItem
title={t("home.settings.subtitles.hardware_decode")}
subtitle={t("home.settings.subtitles.hardware_decode_description")}
>
<Switch
value={settings.ksHardwareDecode}
onValueChange={handleHardwareDecodeChange}
/>
</ListItem>
)}
</ListGroup>
);
};

View File

@@ -1,245 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Switch } from "react-native-gesture-handler";
import {
OUTLINE_THICKNESS_OPTIONS,
VLC_COLOR_OPTIONS,
} from "@/constants/SubtitleConstants";
import { useSettings, VideoPlayerIOS } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { Stepper } from "../inputs/Stepper";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { PlatformDropdown } from "../PlatformDropdown";
interface Props extends ViewProps {}
/**
* VLC Subtitle Settings component
* Only shown when VLC is the active player (Android always, iOS when VLC selected)
* Note: These settings are applied via VLC init options and take effect on next playback
*/
export const VlcSubtitleSettings: React.FC<Props> = ({ ...props }) => {
const { t } = useTranslation();
const { settings, updateSettings } = useSettings();
// Only show for VLC users
const isVlcPlayer =
Platform.OS === "android" ||
(Platform.OS === "ios" && settings.videoPlayerIOS === VideoPlayerIOS.VLC);
const textColorOptions = useMemo(
() => [
{
options: VLC_COLOR_OPTIONS.map((color) => ({
type: "radio" as const,
label: color,
value: color,
selected: settings.vlcTextColor === color,
onPress: () => updateSettings({ vlcTextColor: color }),
})),
},
],
[settings.vlcTextColor, updateSettings],
);
const backgroundColorOptions = useMemo(
() => [
{
options: VLC_COLOR_OPTIONS.map((color) => ({
type: "radio" as const,
label: color,
value: color,
selected: settings.vlcBackgroundColor === color,
onPress: () => updateSettings({ vlcBackgroundColor: color }),
})),
},
],
[settings.vlcBackgroundColor, updateSettings],
);
const outlineColorOptions = useMemo(
() => [
{
options: VLC_COLOR_OPTIONS.map((color) => ({
type: "radio" as const,
label: color,
value: color,
selected: settings.vlcOutlineColor === color,
onPress: () => updateSettings({ vlcOutlineColor: color }),
})),
},
],
[settings.vlcOutlineColor, updateSettings],
);
const outlineThicknessOptions = useMemo(
() => [
{
options: OUTLINE_THICKNESS_OPTIONS.map((thickness) => ({
type: "radio" as const,
label: thickness,
value: thickness,
selected: settings.vlcOutlineThickness === thickness,
onPress: () => updateSettings({ vlcOutlineThickness: thickness }),
})),
},
],
[settings.vlcOutlineThickness, updateSettings],
);
if (!isVlcPlayer) return null;
if (Platform.isTV) return null;
return (
<View {...props}>
<ListGroup
title={t("home.settings.vlc_subtitles.title")}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.vlc_subtitles.hint")}
</Text>
}
>
{/* Text Color */}
<ListItem title={t("home.settings.vlc_subtitles.text_color")}>
<PlatformDropdown
groups={textColorOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{settings.vlcTextColor || "White"}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.vlc_subtitles.text_color")}
/>
</ListItem>
{/* Background Color */}
<ListItem title={t("home.settings.vlc_subtitles.background_color")}>
<PlatformDropdown
groups={backgroundColorOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{settings.vlcBackgroundColor || "Black"}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.vlc_subtitles.background_color")}
/>
</ListItem>
{/* Background Opacity */}
<ListItem title={t("home.settings.vlc_subtitles.background_opacity")}>
<Stepper
value={Math.round(
((settings.vlcBackgroundOpacity ?? 128) / 255) * 100,
)}
step={10}
min={0}
max={100}
appendValue='%'
onUpdate={(value) =>
updateSettings({
vlcBackgroundOpacity: Math.round((value / 100) * 255),
})
}
/>
</ListItem>
{/* Outline Color */}
<ListItem title={t("home.settings.vlc_subtitles.outline_color")}>
<PlatformDropdown
groups={outlineColorOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{settings.vlcOutlineColor || "Black"}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.vlc_subtitles.outline_color")}
/>
</ListItem>
{/* Outline Opacity */}
<ListItem title={t("home.settings.vlc_subtitles.outline_opacity")}>
<Stepper
value={Math.round(
((settings.vlcOutlineOpacity ?? 255) / 255) * 100,
)}
step={10}
min={0}
max={100}
appendValue='%'
onUpdate={(value) =>
updateSettings({
vlcOutlineOpacity: Math.round((value / 100) * 255),
})
}
/>
</ListItem>
{/* Outline Thickness */}
<ListItem title={t("home.settings.vlc_subtitles.outline_thickness")}>
<PlatformDropdown
groups={outlineThicknessOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{settings.vlcOutlineThickness || "Normal"}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.vlc_subtitles.outline_thickness")}
/>
</ListItem>
{/* Bold Text */}
<ListItem title={t("home.settings.vlc_subtitles.bold")}>
<Switch
value={settings.vlcIsBold ?? false}
onValueChange={(value) => updateSettings({ vlcIsBold: value })}
/>
</ListItem>
{/* Subtitle Margin */}
<ListItem title={t("home.settings.vlc_subtitles.margin")}>
<Stepper
value={settings.vlcSubtitleMargin ?? 40}
step={10}
min={0}
max={200}
onUpdate={(value) =>
updateSettings({ vlcSubtitleMargin: Math.round(value) })
}
/>
</ListItem>
</ListGroup>
</View>
);
};

View File

@@ -19,7 +19,14 @@ export const commonScreenOptions: ICommonScreenOptions = {
headerLeft: () => <HeaderBackButton />,
};
const routes = ["persons/[personId]", "items/page", "series/[id]"];
const routes = [
"persons/[personId]",
"items/page",
"series/[id]",
"music/album/[albumId]",
"music/artist/[artistId]",
"music/playlist/[playlistId]",
];
export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> =
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));

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],