Compare commits

..

1 Commits

Author SHA1 Message Date
Fredrik Burmester
b127df39a7 feat: hide certain control buttons/sliders 2025-11-17 07:38:36 +01:00
23 changed files with 270 additions and 184 deletions

View File

@@ -1,5 +1,6 @@
import { Platform, ScrollView, View } from "react-native"; import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ControlsSettings } from "@/components/settings/ControlsSettings";
import { GestureControls } from "@/components/settings/GestureControls"; import { GestureControls } from "@/components/settings/GestureControls";
import { MediaProvider } from "@/components/settings/MediaContext"; import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles"; import { MediaToggles } from "@/components/settings/MediaToggles";
@@ -25,6 +26,7 @@ export default function PlaybackControlsPage() {
<MediaProvider> <MediaProvider>
<MediaToggles className='mb-4' /> <MediaToggles className='mb-4' />
<GestureControls className='mb-4' /> <GestureControls className='mb-4' />
<ControlsSettings className='mb-4' />
<PlaybackControlsSettings /> <PlaybackControlsSettings />
</MediaProvider> </MediaProvider>
</View> </View>

View File

@@ -27,8 +27,8 @@ const Page: React.FC = () => {
ItemFields.MediaStreams, ItemFields.MediaStreams,
]); ]);
// preload media sources // preload media sources in background
const { data: itemWithSources } = useItemQuery(id, false, undefined, []); useItemQuery(id, false, undefined, []);
const opacity = useSharedValue(1); const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => { const animatedStyle = useAnimatedStyle(() => {
@@ -98,13 +98,7 @@ const Page: React.FC = () => {
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' /> <View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' /> <View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
</Animated.View> </Animated.View>
{item && ( {item && <ItemContent item={item} isOffline={isOffline} />}
<ItemContent
item={item}
isOffline={isOffline}
itemWithSources={itemWithSources}
/>
)}
</View> </View>
); );
}; };

View File

@@ -1,4 +1,3 @@
import { MMKV } from "react-native-mmkv";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
declare module "react-native-mmkv" { declare module "react-native-mmkv" {
@@ -8,9 +7,9 @@ declare module "react-native-mmkv" {
} }
} }
// Add the augmentation methods directly to the MMKV instance // Add the augmentation methods directly to the MMKV prototype
// We need to bind these methods to preserve the 'this' context // This follows the recommended pattern while adding the helper methods your app uses
storage.get = function <T>(this: MMKV, key: string): T | undefined { (storage as any).get = function <T>(key: string): T | undefined {
try { try {
const serializedItem = this.getString(key); const serializedItem = this.getString(key);
if (!serializedItem) return undefined; if (!serializedItem) return undefined;
@@ -21,11 +20,7 @@ storage.get = function <T>(this: MMKV, key: string): T | undefined {
} }
}; };
storage.setAny = function ( (storage as any).setAny = function (key: string, value: any | undefined): void {
this: MMKV,
key: string,
value: any | undefined,
): void {
try { try {
if (value === undefined) { if (value === undefined) {
this.remove(key); this.remove(key);

View File

@@ -108,10 +108,10 @@ export const BitrateSheet: React.FC<Props> = ({
values={selected ? [selected] : []} values={selected ? [selected] : []}
multiple={false} multiple={false}
searchFilter={(item, query) => { searchFilter={(item, query) => {
const label = item.key || ""; const label = (item as any).key || "";
return label.toLowerCase().includes(query.toLowerCase()); return label.toLowerCase().includes(query.toLowerCase());
}} }}
renderItemLabel={(item) => <Text>{item.key || ""}</Text>} renderItemLabel={(item) => <Text>{(item as any).key || ""}</Text>}
set={(vals) => { set={(vals) => {
const chosen = vals[0] as Bitrate | undefined; const chosen = vals[0] as Bitrate | undefined;
if (chosen) onChange(chosen); if (chosen) onChange(chosen);

View File

@@ -46,11 +46,10 @@ export type SelectedOptions = {
interface ItemContentProps { interface ItemContentProps {
item: BaseItemDto; item: BaseItemDto;
isOffline: boolean; isOffline: boolean;
itemWithSources?: BaseItemDto | null;
} }
export const ItemContent: React.FC<ItemContentProps> = React.memo( export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, isOffline, itemWithSources }) => { ({ item, isOffline }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const { settings } = useSettings(); const { settings } = useSettings();
const { orientation } = useOrientation(); const { orientation } = useOrientation();
@@ -99,7 +98,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
]); ]);
useEffect(() => { useEffect(() => {
if (!Platform.isTV && itemWithSources) { if (!Platform.isTV) {
navigation.setOptions({ navigation.setOptions({
headerRight: () => headerRight: () =>
item && item &&
@@ -109,7 +108,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<View className='flex flex-row items-center'> <View className='flex flex-row items-center'>
{!Platform.isTV && ( {!Platform.isTV && (
<DownloadSingleItem item={itemWithSources} size='large' /> <DownloadSingleItem item={item} size='large' />
)} )}
{user?.Policy?.IsAdministrator && ( {user?.Policy?.IsAdministrator && (
<PlayInRemoteSessionButton item={item} size='large' /> <PlayInRemoteSessionButton item={item} size='large' />
@@ -126,7 +125,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<View className='flex flex-row items-center space-x-2'> <View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && ( {!Platform.isTV && (
<DownloadSingleItem item={itemWithSources} size='large' /> <DownloadSingleItem item={item} size='large' />
)} )}
{user?.Policy?.IsAdministrator && ( {user?.Policy?.IsAdministrator && (
<PlayInRemoteSessionButton item={item} size='large' /> <PlayInRemoteSessionButton item={item} size='large' />
@@ -140,7 +139,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
)), )),
}); });
} }
}, [item, navigation, user, itemWithSources]); }, [item, navigation, user]);
useEffect(() => { useEffect(() => {
if (item) { if (item) {
@@ -213,7 +212,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
<MediaSourceButton <MediaSourceButton
selectedOptions={selectedOptions} selectedOptions={selectedOptions}
setSelectedOptions={setSelectedOptions} setSelectedOptions={setSelectedOptions}
item={itemWithSources} item={item}
colors={itemColors} colors={itemColors}
/> />
)} )}

View File

@@ -7,12 +7,13 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { useItemQuery } from "@/hooks/useItemQuery";
import { BITRATES } from "./BitRateSheet"; import { BITRATES } from "./BitRateSheet";
import type { SelectedOptions } from "./ItemContent"; import type { SelectedOptions } from "./ItemContent";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown"; import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
interface Props extends React.ComponentProps<typeof TouchableOpacity> { interface Props extends React.ComponentProps<typeof TouchableOpacity> {
item?: BaseItemDto | null; item: BaseItemDto;
selectedOptions: SelectedOptions; selectedOptions: SelectedOptions;
setSelectedOptions: React.Dispatch< setSelectedOptions: React.Dispatch<
React.SetStateAction<SelectedOptions | undefined> React.SetStateAction<SelectedOptions | undefined>
@@ -28,6 +29,12 @@ export const MediaSourceButton: React.FC<Props> = ({
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { data: itemWithSources, isLoading } = useItemQuery(
item.Id,
false,
undefined,
[],
);
const effectiveColors = colors || { const effectiveColors = colors || {
primary: "#7c3aed", primary: "#7c3aed",
@@ -35,7 +42,7 @@ export const MediaSourceButton: React.FC<Props> = ({
}; };
useEffect(() => { useEffect(() => {
const firstMediaSource = item?.MediaSources?.[0]; const firstMediaSource = itemWithSources?.MediaSources?.[0];
if (!firstMediaSource) return; if (!firstMediaSource) return;
setSelectedOptions((prev) => { setSelectedOptions((prev) => {
if (!prev) return prev; if (!prev) return prev;
@@ -44,7 +51,7 @@ export const MediaSourceButton: React.FC<Props> = ({
mediaSource: firstMediaSource, mediaSource: firstMediaSource,
}; };
}); });
}, [item, setSelectedOptions]); }, [itemWithSources, setSelectedOptions]);
const getMediaSourceDisplayName = useCallback((source: MediaSourceInfo) => { const getMediaSourceDisplayName = useCallback((source: MediaSourceInfo) => {
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video"); const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
@@ -86,10 +93,13 @@ export const MediaSourceButton: React.FC<Props> = ({
}); });
// Media Source group (only if multiple sources) // Media Source group (only if multiple sources)
if (item?.MediaSources && item.MediaSources.length > 1) { if (
itemWithSources?.MediaSources &&
itemWithSources.MediaSources.length > 1
) {
groups.push({ groups.push({
title: t("item_card.video"), title: t("item_card.video"),
options: item.MediaSources.map((source) => ({ options: itemWithSources.MediaSources.map((source) => ({
type: "radio" as const, type: "radio" as const,
label: getMediaSourceDisplayName(source), label: getMediaSourceDisplayName(source),
value: source, value: source,
@@ -149,7 +159,7 @@ export const MediaSourceButton: React.FC<Props> = ({
return groups; return groups;
}, [ }, [
item, itemWithSources,
selectedOptions, selectedOptions,
audioStreams, audioStreams,
subtitleStreams, subtitleStreams,
@@ -160,7 +170,7 @@ export const MediaSourceButton: React.FC<Props> = ({
const trigger = ( const trigger = (
<TouchableOpacity <TouchableOpacity
disabled={!item} disabled={!item || isLoading}
onPress={() => setOpen(true)} onPress={() => setOpen(true)}
className='relative' className='relative'
> >
@@ -169,7 +179,7 @@ export const MediaSourceButton: React.FC<Props> = ({
className='absolute w-12 h-12 rounded-full' className='absolute w-12 h-12 rounded-full'
/> />
<View className='w-12 h-12 rounded-full z-10 items-center justify-center'> <View className='w-12 h-12 rounded-full z-10 items-center justify-center'>
{!item ? ( {isLoading ? (
<ActivityIndicator size='small' color={effectiveColors.text} /> <ActivityIndicator size='small' color={effectiveColors.text} />
) : ( ) : (
<Ionicons name='list' size={24} color={effectiveColors.text} /> <Ionicons name='list' size={24} color={effectiveColors.text} />

View File

@@ -4,19 +4,7 @@ import type { PropsWithChildren } from "react";
import { Platform, TouchableOpacity, type ViewProps } from "react-native"; import { Platform, TouchableOpacity, type ViewProps } from "react-native";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
interface Props interface Props extends ViewProps {
extends Omit<
ViewProps,
| "children"
| "onPressIn"
| "onPressOut"
| "onPress"
| "nextFocusDown"
| "nextFocusForward"
| "nextFocusLeft"
| "nextFocusRight"
| "nextFocusUp"
> {
onPress?: () => void; onPress?: () => void;
icon?: keyof typeof Ionicons.glyphMap; icon?: keyof typeof Ionicons.glyphMap;
background?: boolean; background?: boolean;
@@ -53,7 +41,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity <TouchableOpacity
onPress={handlePress} onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`} className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...viewProps} {...(viewProps as any)}
> >
{icon ? ( {icon ? (
<Ionicons <Ionicons
@@ -72,7 +60,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity <TouchableOpacity
onPress={handlePress} onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`} className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...viewProps} {...(viewProps as any)}
> >
{icon ? ( {icon ? (
<Ionicons <Ionicons
@@ -90,7 +78,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity <TouchableOpacity
onPress={handlePress} onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`} className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...viewProps} {...(viewProps as any)}
> >
{icon ? ( {icon ? (
<Ionicons <Ionicons
@@ -110,7 +98,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
className={`rounded-full ${buttonSize} flex items-center justify-center ${ className={`rounded-full ${buttonSize} flex items-center justify-center ${
fillColor ? fillColorClass : "bg-transparent" fillColor ? fillColorClass : "bg-transparent"
}`} }`}
{...viewProps} {...(viewProps as any)}
> >
{icon ? ( {icon ? (
<Ionicons <Ionicons
@@ -124,11 +112,11 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
); );
return ( return (
<TouchableOpacity onPress={handlePress} {...viewProps}> <TouchableOpacity onPress={handlePress} {...(viewProps as any)}>
<BlurView <BlurView
intensity={90} intensity={90}
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`} className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...viewProps} {...(viewProps as any)}
> >
{icon ? ( {icon ? (
<Ionicons <Ionicons

View File

@@ -81,12 +81,14 @@ export const TrackSheet: React.FC<Props> = ({
} }
multiple={false} multiple={false}
searchFilter={(item, query) => { searchFilter={(item, query) => {
const label = item.DisplayTitle || ""; const label = (item as any).DisplayTitle || "";
return label.toLowerCase().includes(query.toLowerCase()); return label.toLowerCase().includes(query.toLowerCase());
}} }}
renderItemLabel={(item) => <Text>{item.DisplayTitle || ""}</Text>} renderItemLabel={(item) => (
<Text>{(item as any).DisplayTitle || ""}</Text>
)}
set={(vals) => { set={(vals) => {
const chosen = vals[0]; const chosen = vals[0] as any;
if (chosen && chosen.Index !== null && chosen.Index !== undefined) { if (chosen && chosen.Index !== null && chosen.Index !== undefined) {
onChange(chosen.Index); onChange(chosen.Index);
} }

View File

@@ -7,7 +7,7 @@ import {
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { type Href, useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
@@ -340,7 +340,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
const navigateToItem = useCallback( const navigateToItem = useCallback(
(item: BaseItemDto) => { (item: BaseItemDto) => {
const navigation = getItemNavigation(item, "(home)"); const navigation = getItemNavigation(item, "(home)");
router.push(navigation as Href); router.push(navigation as any);
}, },
[router], [router],
); );

View File

@@ -1,6 +1,6 @@
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { type Href, useRouter, useSegments } from "expo-router"; import { useRouter, useSegments } from "expo-router";
import { type PropsWithChildren, useCallback } from "react"; import { type PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import { useFavorite } from "@/hooks/useFavorite"; import { useFavorite } from "@/hooks/useFavorite";
@@ -146,12 +146,12 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
if (isOffline) { if (isOffline) {
// For offline mode, we still need to use query params // For offline mode, we still need to use query params
const url = `${itemRouter(item, from)}&offline=true`; const url = `${itemRouter(item, from)}&offline=true`;
router.push(url as Href); router.push(url as any);
return; return;
} }
const navigation = getItemNavigation(item, from); const navigation = getItemNavigation(item, from);
router.push(navigation as Href); router.push(navigation as any);
}} }}
{...props} {...props}
> >

View File

@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { type Href, useRouter, useSegments } from "expo-router"; import { useRouter, useSegments } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { Dimensions, View, type ViewProps } from "react-native"; import { Dimensions, View, type ViewProps } from "react-native";
@@ -156,7 +156,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
if (!from) return; if (!from) return;
lightHapticFeedback(); lightHapticFeedback();
const navigation = getItemNavigation(item, from); const navigation = getItemNavigation(item, from);
router.push(navigation as Href); router.push(navigation as any);
}, [item, from]); }, [item, from]);
const tap = Gesture.Tap() const tap = Gesture.Tap()

View File

@@ -1,4 +1,4 @@
import { type Href, router, useSegments } from "expo-router"; import { router, useSegments } from "expo-router";
import type React from "react"; import type React from "react";
import { useCallback } from "react"; import { useCallback } from "react";
import { TouchableOpacity, type ViewProps } from "react-native"; import { TouchableOpacity, type ViewProps } from "react-native";
@@ -21,10 +21,10 @@ const CompanySlide: React.FC<
const navigate = useCallback( const navigate = useCallback(
({ id, image, name }: Network | Studio) => ({ id, image, name }: Network | Studio) =>
router.push({ router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`, pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}` as any,
params: { id, image, name, type: slide.type }, params: { id, image, name, type: slide.type },
} as Href), }),
[slide, from], [slide],
); );
return ( return (

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { type Href, router, useSegments } from "expo-router"; import { router, useSegments } from "expo-router";
import type React from "react"; import type React from "react";
import { useCallback } from "react"; import { useCallback } from "react";
import { TouchableOpacity, type ViewProps } from "react-native"; import { TouchableOpacity, type ViewProps } from "react-native";
@@ -18,10 +18,10 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const navigate = useCallback( const navigate = useCallback(
(genre: GenreSliderItem) => (genre: GenreSliderItem) =>
router.push({ router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`, pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}` as any,
params: { type: slide.type, name: genre.name }, params: { type: slide.type, name: genre.name },
} as Href), }),
[slide, from], [slide],
); );
const { data } = useQuery({ const { data } = useQuery({

View File

@@ -31,16 +31,15 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900' className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
> >
{Children.map(childrenArray, (child, index) => { {Children.map(childrenArray, (child, index) => {
if (isValidElement(child)) { if (isValidElement<{ style?: ViewStyle }>(child)) {
const style = StyleSheet.compose( return cloneElement(child as any, {
(child.props as { style?: ViewStyle }).style, style: StyleSheet.compose(
index < childrenArray.length - 1 child.props.style,
? styles.borderBottom index < childrenArray.length - 1
: undefined, ? styles.borderBottom
); : undefined,
return cloneElement(child, { style } as Partial< ),
typeof child.props });
>);
} }
return child; return child;
})} })}

View File

@@ -3,18 +3,7 @@ import type { PropsWithChildren, ReactNode } from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native"; import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
interface Props interface Props extends ViewProps {
extends Omit<
ViewProps,
| "children"
| "onPressIn"
| "onPressOut"
| "nextFocusDown"
| "nextFocusForward"
| "nextFocusLeft"
| "nextFocusRight"
| "nextFocusUp"
> {
title?: string | null | undefined; title?: string | null | undefined;
subtitle?: string | null | undefined; subtitle?: string | null | undefined;
value?: string | null | undefined; value?: string | null | undefined;
@@ -48,7 +37,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${ className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${
disabled ? "opacity-50" : "" disabled ? "opacity-50" : ""
}`} }`}
{...viewProps} {...(viewProps as any)}
> >
<ListItemContent <ListItemContent
title={title} title={title}

View File

@@ -0,0 +1,76 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { ViewProps } from "react-native";
import { Switch } from "react-native";
import { ListItem } from "@/components/list/ListItem";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import DisabledSetting from "./DisabledSetting";
interface Props extends ViewProps {}
export const ControlsSettings: React.FC<Props> = ({ ...props }) => {
const { t } = useTranslation();
const { settings, updateSettings, pluginSettings } = useSettings();
const disabled = useMemo(
() =>
pluginSettings?.showVolumeSlider?.locked === true &&
pluginSettings?.showBrightnessSlider?.locked === true &&
pluginSettings?.showSeekButtons?.locked === true,
[pluginSettings],
);
if (!settings) return null;
return (
<DisabledSetting disabled={disabled} {...props}>
<ListGroup title={t("home.settings.controls.controls_title")}>
<ListItem
title={t("home.settings.controls.show_volume_slider")}
subtitle={t("home.settings.controls.show_volume_slider_description")}
disabled={pluginSettings?.showVolumeSlider?.locked}
>
<Switch
value={settings.showVolumeSlider}
disabled={pluginSettings?.showVolumeSlider?.locked}
onValueChange={(showVolumeSlider) =>
updateSettings({ showVolumeSlider })
}
/>
</ListItem>
<ListItem
title={t("home.settings.controls.show_brightness_slider")}
subtitle={t(
"home.settings.controls.show_brightness_slider_description",
)}
disabled={pluginSettings?.showBrightnessSlider?.locked}
>
<Switch
value={settings.showBrightnessSlider}
disabled={pluginSettings?.showBrightnessSlider?.locked}
onValueChange={(showBrightnessSlider) =>
updateSettings({ showBrightnessSlider })
}
/>
</ListItem>
<ListItem
title={t("home.settings.controls.show_seek_buttons")}
subtitle={t("home.settings.controls.show_seek_buttons_description")}
disabled={pluginSettings?.showSeekButtons?.locked}
>
<Switch
value={settings.showSeekButtons}
disabled={pluginSettings?.showSeekButtons?.locked}
onValueChange={(showSeekButtons) =>
updateSettings({ showSeekButtons })
}
/>
</ListItem>
</ListGroup>
</DisabledSetting>
);
};

View File

@@ -48,49 +48,57 @@ export const CenterControls: FC<CenterControlsProps> = ({
}} }}
pointerEvents={showControls ? "box-none" : "none"} pointerEvents={showControls ? "box-none" : "none"}
> >
<View {settings?.showBrightnessSlider && (
style={{ <View
position: "absolute", style={{
alignItems: "center", position: "absolute",
transform: [{ rotate: "270deg" }], alignItems: "center",
left: 0, transform: [{ rotate: "270deg" }],
bottom: 30, left: 0,
}} bottom: 30,
> }}
<BrightnessSlider /> >
</View> <BrightnessSlider />
</View>
)}
{!Platform.isTV && ( {!Platform.isTV ? (
<TouchableOpacity onPress={handleSkipBackward}> settings?.showSeekButtons ? (
<View <TouchableOpacity onPress={handleSkipBackward}>
style={{ <View
position: "relative",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='refresh-outline'
size={ICON_SIZES.CENTER}
color='white'
style={{ style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }], position: "relative",
}} justifyContent: "center",
/> alignItems: "center",
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}} }}
> >
{settings?.rewindSkipTime} <Ionicons
</Text> name='refresh-outline'
</View> size={ICON_SIZES.CENTER}
</TouchableOpacity> color='white'
)} style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.rewindSkipTime}
</Text>
</View>
</TouchableOpacity>
) : (
<View
style={{ width: ICON_SIZES.CENTER, height: ICON_SIZES.CENTER }}
/>
)
) : null}
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}> <View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
<TouchableOpacity onPress={togglePlay}> <TouchableOpacity onPress={togglePlay}>
@@ -106,47 +114,55 @@ export const CenterControls: FC<CenterControlsProps> = ({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{!Platform.isTV && ( {!Platform.isTV ? (
<TouchableOpacity onPress={handleSkipForward}> settings?.showSeekButtons ? (
<View <TouchableOpacity onPress={handleSkipForward}>
style={{ <View
position: "relative",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='refresh-outline'
size={ICON_SIZES.CENTER}
color='white'
/>
<Text
style={{ style={{
position: "absolute", position: "relative",
color: "white", justifyContent: "center",
fontSize: 16, alignItems: "center",
fontWeight: "bold",
bottom: 10,
}} }}
> >
{settings?.forwardSkipTime} <Ionicons
</Text> name='refresh-outline'
</View> size={ICON_SIZES.CENTER}
</TouchableOpacity> color='white'
)} />
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.forwardSkipTime}
</Text>
</View>
</TouchableOpacity>
) : (
<View
style={{ width: ICON_SIZES.CENTER, height: ICON_SIZES.CENTER }}
/>
)
) : null}
<View {settings?.showVolumeSlider && (
style={{ <View
position: "absolute", style={{
alignItems: "center", position: "absolute",
transform: [{ rotate: "270deg" }], alignItems: "center",
bottom: 30, transform: [{ rotate: "270deg" }],
right: 0, bottom: 30,
opacity: showAudioSlider || showControls ? 1 : 0, right: 0,
}} opacity: showAudioSlider || showControls ? 1 : 0,
> }}
<AudioSlider setVisibility={setShowAudioSlider} /> >
</View> <AudioSlider setVisibility={setShowAudioSlider} />
</View>
)}
</View> </View>
); );
}; };

View File

@@ -3,7 +3,7 @@ import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { type Href, useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { import {
type Dispatch, type Dispatch,
type FC, type FC,
@@ -379,7 +379,7 @@ export const Controls: FC<Props> = ({
console.log("queryParams", queryParams); console.log("queryParams", queryParams);
router.replace(`player/direct-player?${queryParams}` as Href); router.replace(`player/direct-player?${queryParams}` as any);
}, },
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router], [settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
); );

View File

@@ -18,7 +18,7 @@ interface Props {
interface FeedbackState { interface FeedbackState {
visible: boolean; visible: boolean;
icon: keyof typeof Ionicons.glyphMap; icon: string;
text: string; text: string;
side?: "left" | "right"; side?: "left" | "right";
} }
@@ -36,7 +36,7 @@ export const GestureOverlay = ({
const [feedback, setFeedback] = useState<FeedbackState>({ const [feedback, setFeedback] = useState<FeedbackState>({
visible: false, visible: false,
icon: "play", icon: "",
text: "", text: "",
}); });
const [fadeAnim] = useState(new Animated.Value(0)); const [fadeAnim] = useState(new Animated.Value(0));
@@ -46,7 +46,7 @@ export const GestureOverlay = ({
const showFeedback = useCallback( const showFeedback = useCallback(
( (
icon: keyof typeof Ionicons.glyphMap, icon: string,
text: string, text: string,
side?: "left" | "right", side?: "left" | "right",
isDuringDrag = false, isDuringDrag = false,
@@ -320,7 +320,7 @@ export const GestureOverlay = ({
}} }}
> >
<Ionicons <Ionicons
name={feedback.icon} name={feedback.icon as any}
size={24} size={24}
color='white' color='white'
style={{ marginRight: 8 }} style={{ marginRight: 8 }}

View File

@@ -1,5 +1,5 @@
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client"; import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
import { type Href, router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import type React from "react"; import type React from "react";
import { import {
createContext, createContext,
@@ -95,7 +95,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
playbackPosition: playbackPosition, playbackPosition: playbackPosition,
}).toString(); }).toString();
router.replace(`player/direct-player?${queryParams}` as Href); router.replace(`player/direct-player?${queryParams}` as any);
}; };
const setTrackParams = ( const setTrackParams = (

View File

@@ -1,5 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { type Href, useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useMemo, useRef } from "react";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
@@ -53,7 +53,7 @@ const DropdownView = () => {
bitrateValue: bitrate.toString(), bitrateValue: bitrate.toString(),
playbackPosition: playbackPositionRef.current, playbackPosition: playbackPositionRef.current,
}).toString(); }).toString();
router.replace(`player/direct-player?${queryParams}` as Href); router.replace(`player/direct-player?${queryParams}` as any);
}, },
[audioIndex, subtitleIndex, router], [audioIndex, subtitleIndex, router],
); );

View File

@@ -113,6 +113,15 @@
"right_side_volume": "Right Side Volume Control", "right_side_volume": "Right Side Volume Control",
"right_side_volume_description": "Swipe up/down on right side to adjust volume" "right_side_volume_description": "Swipe up/down on right side to adjust volume"
}, },
"controls": {
"controls_title": "Controls",
"show_volume_slider": "Show Volume Slider",
"show_volume_slider_description": "Display volume slider on the right side of video controls",
"show_brightness_slider": "Show Brightness Slider",
"show_brightness_slider_description": "Display brightness slider on the left side of video controls",
"show_seek_buttons": "Show Seek Buttons",
"show_seek_buttons_description": "Display forward/rewind buttons next to the play button"
},
"audio": { "audio": {
"audio_title": "Audio", "audio_title": "Audio",
"set_audio_track": "Set Audio Track From Previous Item", "set_audio_track": "Set Audio Track From Previous Item",

View File

@@ -180,6 +180,10 @@ export type Settings = {
enableRightSideVolumeSwipe: boolean; enableRightSideVolumeSwipe: boolean;
usePopularPlugin: boolean; usePopularPlugin: boolean;
showLargeHomeCarousel: boolean; showLargeHomeCarousel: boolean;
// Controls
showVolumeSlider: boolean;
showBrightnessSlider: boolean;
showSeekButtons: boolean;
}; };
export interface Lockable<T> { export interface Lockable<T> {
@@ -244,6 +248,10 @@ export const defaultValues: Settings = {
enableRightSideVolumeSwipe: true, enableRightSideVolumeSwipe: true,
usePopularPlugin: true, usePopularPlugin: true,
showLargeHomeCarousel: false, showLargeHomeCarousel: false,
// Controls
showVolumeSlider: true,
showBrightnessSlider: true,
showSeekButtons: true,
}; };
const loadSettings = (): Partial<Settings> => { const loadSettings = (): Partial<Settings> => {
@@ -362,11 +370,10 @@ export const useSettings = () => {
value !== undefined && value !== undefined &&
_settings?.[settingsKey] !== value _settings?.[settingsKey] !== value
) { ) {
(unlockedPluginDefaults as Record<string, unknown>)[settingsKey] = (unlockedPluginDefaults as any)[settingsKey] = value;
value;
} }
(acc as Record<string, unknown>)[settingsKey] = locked (acc as any)[settingsKey] = locked
? value ? value
: (_settings?.[settingsKey] ?? value); : (_settings?.[settingsKey] ?? value);
} }