mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-02 07:52:34 +00:00
Compare commits
2 Commits
as-any-rem
...
feat/doubl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5358c1e1d5 | ||
|
|
cd7a7b0e0e |
@@ -27,8 +27,8 @@ const Page: React.FC = () => {
|
||||
ItemFields.MediaStreams,
|
||||
]);
|
||||
|
||||
// preload media sources
|
||||
const { data: itemWithSources } = useItemQuery(id, false, undefined, []);
|
||||
// preload media sources in background
|
||||
useItemQuery(id, false, undefined, []);
|
||||
|
||||
const opacity = useSharedValue(1);
|
||||
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-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||
</Animated.View>
|
||||
{item && (
|
||||
<ItemContent
|
||||
item={item}
|
||||
isOffline={isOffline}
|
||||
itemWithSources={itemWithSources}
|
||||
/>
|
||||
)}
|
||||
{item && <ItemContent item={item} isOffline={isOffline} />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { MMKV } from "react-native-mmkv";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
declare module "react-native-mmkv" {
|
||||
@@ -8,9 +7,9 @@ declare module "react-native-mmkv" {
|
||||
}
|
||||
}
|
||||
|
||||
// Add the augmentation methods directly to the MMKV instance
|
||||
// We need to bind these methods to preserve the 'this' context
|
||||
storage.get = function <T>(this: MMKV, key: string): T | undefined {
|
||||
// Add the augmentation methods directly to the MMKV prototype
|
||||
// This follows the recommended pattern while adding the helper methods your app uses
|
||||
(storage as any).get = function <T>(key: string): T | undefined {
|
||||
try {
|
||||
const serializedItem = this.getString(key);
|
||||
if (!serializedItem) return undefined;
|
||||
@@ -21,11 +20,7 @@ storage.get = function <T>(this: MMKV, key: string): T | undefined {
|
||||
}
|
||||
};
|
||||
|
||||
storage.setAny = function (
|
||||
this: MMKV,
|
||||
key: string,
|
||||
value: any | undefined,
|
||||
): void {
|
||||
(storage as any).setAny = function (key: string, value: any | undefined): void {
|
||||
try {
|
||||
if (value === undefined) {
|
||||
this.remove(key);
|
||||
|
||||
@@ -108,10 +108,10 @@ export const BitrateSheet: React.FC<Props> = ({
|
||||
values={selected ? [selected] : []}
|
||||
multiple={false}
|
||||
searchFilter={(item, query) => {
|
||||
const label = item.key || "";
|
||||
const label = (item as any).key || "";
|
||||
return label.toLowerCase().includes(query.toLowerCase());
|
||||
}}
|
||||
renderItemLabel={(item) => <Text>{item.key || ""}</Text>}
|
||||
renderItemLabel={(item) => <Text>{(item as any).key || ""}</Text>}
|
||||
set={(vals) => {
|
||||
const chosen = vals[0] as Bitrate | undefined;
|
||||
if (chosen) onChange(chosen);
|
||||
|
||||
@@ -46,11 +46,10 @@ export type SelectedOptions = {
|
||||
interface ItemContentProps {
|
||||
item: BaseItemDto;
|
||||
isOffline: boolean;
|
||||
itemWithSources?: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
({ item, isOffline, itemWithSources }) => {
|
||||
({ item, isOffline }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { settings } = useSettings();
|
||||
const { orientation } = useOrientation();
|
||||
@@ -99,7 +98,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV && itemWithSources) {
|
||||
if (!Platform.isTV) {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item &&
|
||||
@@ -109,7 +108,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center'>
|
||||
{!Platform.isTV && (
|
||||
<DownloadSingleItem item={itemWithSources} size='large' />
|
||||
<DownloadSingleItem item={item} size='large' />
|
||||
)}
|
||||
{user?.Policy?.IsAdministrator && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
@@ -126,7 +125,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
{!Platform.isTV && (
|
||||
<DownloadSingleItem item={itemWithSources} size='large' />
|
||||
<DownloadSingleItem item={item} size='large' />
|
||||
)}
|
||||
{user?.Policy?.IsAdministrator && (
|
||||
<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(() => {
|
||||
if (item) {
|
||||
@@ -213,7 +212,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
<MediaSourceButton
|
||||
selectedOptions={selectedOptions}
|
||||
setSelectedOptions={setSelectedOptions}
|
||||
item={itemWithSources}
|
||||
item={item}
|
||||
colors={itemColors}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7,12 +7,13 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||
import { BITRATES } from "./BitRateSheet";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
item?: BaseItemDto | null;
|
||||
item: BaseItemDto;
|
||||
selectedOptions: SelectedOptions;
|
||||
setSelectedOptions: React.Dispatch<
|
||||
React.SetStateAction<SelectedOptions | undefined>
|
||||
@@ -28,6 +29,12 @@ export const MediaSourceButton: React.FC<Props> = ({
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: itemWithSources, isLoading } = useItemQuery(
|
||||
item.Id,
|
||||
false,
|
||||
undefined,
|
||||
[],
|
||||
);
|
||||
|
||||
const effectiveColors = colors || {
|
||||
primary: "#7c3aed",
|
||||
@@ -35,7 +42,7 @@ export const MediaSourceButton: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const firstMediaSource = item?.MediaSources?.[0];
|
||||
const firstMediaSource = itemWithSources?.MediaSources?.[0];
|
||||
if (!firstMediaSource) return;
|
||||
setSelectedOptions((prev) => {
|
||||
if (!prev) return prev;
|
||||
@@ -44,7 +51,7 @@ export const MediaSourceButton: React.FC<Props> = ({
|
||||
mediaSource: firstMediaSource,
|
||||
};
|
||||
});
|
||||
}, [item, setSelectedOptions]);
|
||||
}, [itemWithSources, setSelectedOptions]);
|
||||
|
||||
const getMediaSourceDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||
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)
|
||||
if (item?.MediaSources && item.MediaSources.length > 1) {
|
||||
if (
|
||||
itemWithSources?.MediaSources &&
|
||||
itemWithSources.MediaSources.length > 1
|
||||
) {
|
||||
groups.push({
|
||||
title: t("item_card.video"),
|
||||
options: item.MediaSources.map((source) => ({
|
||||
options: itemWithSources.MediaSources.map((source) => ({
|
||||
type: "radio" as const,
|
||||
label: getMediaSourceDisplayName(source),
|
||||
value: source,
|
||||
@@ -149,7 +159,7 @@ export const MediaSourceButton: React.FC<Props> = ({
|
||||
|
||||
return groups;
|
||||
}, [
|
||||
item,
|
||||
itemWithSources,
|
||||
selectedOptions,
|
||||
audioStreams,
|
||||
subtitleStreams,
|
||||
@@ -160,7 +170,7 @@ export const MediaSourceButton: React.FC<Props> = ({
|
||||
|
||||
const trigger = (
|
||||
<TouchableOpacity
|
||||
disabled={!item}
|
||||
disabled={!item || isLoading}
|
||||
onPress={() => setOpen(true)}
|
||||
className='relative'
|
||||
>
|
||||
@@ -169,7 +179,7 @@ export const MediaSourceButton: React.FC<Props> = ({
|
||||
className='absolute w-12 h-12 rounded-full'
|
||||
/>
|
||||
<View className='w-12 h-12 rounded-full z-10 items-center justify-center'>
|
||||
{!item ? (
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size='small' color={effectiveColors.text} />
|
||||
) : (
|
||||
<Ionicons name='list' size={24} color={effectiveColors.text} />
|
||||
|
||||
@@ -4,19 +4,7 @@ import type { PropsWithChildren } from "react";
|
||||
import { Platform, TouchableOpacity, type ViewProps } from "react-native";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
|
||||
interface Props
|
||||
extends Omit<
|
||||
ViewProps,
|
||||
| "children"
|
||||
| "onPressIn"
|
||||
| "onPressOut"
|
||||
| "onPress"
|
||||
| "nextFocusDown"
|
||||
| "nextFocusForward"
|
||||
| "nextFocusLeft"
|
||||
| "nextFocusRight"
|
||||
| "nextFocusUp"
|
||||
> {
|
||||
interface Props extends ViewProps {
|
||||
onPress?: () => void;
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
background?: boolean;
|
||||
@@ -53,7 +41,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
{...viewProps}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
@@ -72,7 +60,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
{...viewProps}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
@@ -90,7 +78,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
{...viewProps}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
@@ -110,7 +98,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
||||
fillColor ? fillColorClass : "bg-transparent"
|
||||
}`}
|
||||
{...viewProps}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
@@ -124,11 +112,11 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress} {...viewProps}>
|
||||
<TouchableOpacity onPress={handlePress} {...(viewProps as any)}>
|
||||
<BlurView
|
||||
intensity={90}
|
||||
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
{...viewProps}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
|
||||
@@ -81,12 +81,14 @@ export const TrackSheet: React.FC<Props> = ({
|
||||
}
|
||||
multiple={false}
|
||||
searchFilter={(item, query) => {
|
||||
const label = item.DisplayTitle || "";
|
||||
const label = (item as any).DisplayTitle || "";
|
||||
return label.toLowerCase().includes(query.toLowerCase());
|
||||
}}
|
||||
renderItemLabel={(item) => <Text>{item.DisplayTitle || ""}</Text>}
|
||||
renderItemLabel={(item) => (
|
||||
<Text>{(item as any).DisplayTitle || ""}</Text>
|
||||
)}
|
||||
set={(vals) => {
|
||||
const chosen = vals[0];
|
||||
const chosen = vals[0] as any;
|
||||
if (chosen && chosen.Index !== null && chosen.Index !== undefined) {
|
||||
onChange(chosen.Index);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { type Href, useRouter } from "expo-router";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
@@ -340,7 +340,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
const navigateToItem = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
const navigation = getItemNavigation(item, "(home)");
|
||||
router.push(navigation as Href);
|
||||
router.push(navigation as any);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
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 { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
@@ -146,12 +146,12 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
if (isOffline) {
|
||||
// For offline mode, we still need to use query params
|
||||
const url = `${itemRouter(item, from)}&offline=true`;
|
||||
router.push(url as Href);
|
||||
router.push(url as any);
|
||||
return;
|
||||
}
|
||||
|
||||
const navigation = getItemNavigation(item, from);
|
||||
router.push(navigation as Href);
|
||||
router.push(navigation as any);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { type Href, useRouter, useSegments } from "expo-router";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Dimensions, View, type ViewProps } from "react-native";
|
||||
@@ -156,7 +156,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
if (!from) return;
|
||||
lightHapticFeedback();
|
||||
const navigation = getItemNavigation(item, from);
|
||||
router.push(navigation as Href);
|
||||
router.push(navigation as any);
|
||||
}, [item, from]);
|
||||
|
||||
const tap = Gesture.Tap()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Href, router, useSegments } from "expo-router";
|
||||
import { router, useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||
@@ -21,10 +21,10 @@ const CompanySlide: React.FC<
|
||||
const navigate = useCallback(
|
||||
({ id, image, name }: Network | Studio) =>
|
||||
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 },
|
||||
} as Href),
|
||||
[slide, from],
|
||||
}),
|
||||
[slide],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useCallback } from "react";
|
||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||
@@ -18,10 +18,10 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||
const navigate = useCallback(
|
||||
(genre: GenreSliderItem) =>
|
||||
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 },
|
||||
} as Href),
|
||||
[slide, from],
|
||||
}),
|
||||
[slide],
|
||||
);
|
||||
|
||||
const { data } = useQuery({
|
||||
|
||||
@@ -31,16 +31,15 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
||||
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
|
||||
>
|
||||
{Children.map(childrenArray, (child, index) => {
|
||||
if (isValidElement(child)) {
|
||||
const style = StyleSheet.compose(
|
||||
(child.props as { style?: ViewStyle }).style,
|
||||
index < childrenArray.length - 1
|
||||
? styles.borderBottom
|
||||
: undefined,
|
||||
);
|
||||
return cloneElement(child, { style } as Partial<
|
||||
typeof child.props
|
||||
>);
|
||||
if (isValidElement<{ style?: ViewStyle }>(child)) {
|
||||
return cloneElement(child as any, {
|
||||
style: StyleSheet.compose(
|
||||
child.props.style,
|
||||
index < childrenArray.length - 1
|
||||
? styles.borderBottom
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
|
||||
@@ -3,18 +3,7 @@ import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
interface Props
|
||||
extends Omit<
|
||||
ViewProps,
|
||||
| "children"
|
||||
| "onPressIn"
|
||||
| "onPressOut"
|
||||
| "nextFocusDown"
|
||||
| "nextFocusForward"
|
||||
| "nextFocusLeft"
|
||||
| "nextFocusRight"
|
||||
| "nextFocusUp"
|
||||
> {
|
||||
interface Props extends ViewProps {
|
||||
title?: string | null | undefined;
|
||||
subtitle?: 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 ${
|
||||
disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
{...viewProps}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
<ListItemContent
|
||||
title={title}
|
||||
|
||||
@@ -17,7 +17,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
||||
|
||||
const disabled = useMemo(
|
||||
() =>
|
||||
pluginSettings?.enableHorizontalSwipeSkip?.locked === true &&
|
||||
pluginSettings?.enableDoubleTapSkip?.locked === true &&
|
||||
pluginSettings?.enableLeftSideBrightnessSwipe?.locked === true &&
|
||||
pluginSettings?.enableRightSideVolumeSwipe?.locked === true,
|
||||
[pluginSettings],
|
||||
@@ -35,13 +35,13 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
||||
subtitle={t(
|
||||
"home.settings.gesture_controls.horizontal_swipe_skip_description",
|
||||
)}
|
||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||
disabled={pluginSettings?.enableDoubleTapSkip?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.enableHorizontalSwipeSkip}
|
||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||
onValueChange={(enableHorizontalSwipeSkip) =>
|
||||
updateSettings({ enableHorizontalSwipeSkip })
|
||||
value={settings.enableDoubleTapSkip}
|
||||
disabled={pluginSettings?.enableDoubleTapSkip?.locked}
|
||||
onValueChange={(enableDoubleTapSkip) =>
|
||||
updateSettings({ enableDoubleTapSkip })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type Href, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import {
|
||||
type Dispatch,
|
||||
type FC,
|
||||
@@ -379,7 +379,7 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ interface Props {
|
||||
|
||||
interface FeedbackState {
|
||||
visible: boolean;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
icon: string;
|
||||
text: string;
|
||||
side?: "left" | "right";
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export const GestureOverlay = ({
|
||||
|
||||
const [feedback, setFeedback] = useState<FeedbackState>({
|
||||
visible: false,
|
||||
icon: "play",
|
||||
icon: "",
|
||||
text: "",
|
||||
});
|
||||
const [fadeAnim] = useState(new Animated.Value(0));
|
||||
@@ -46,7 +46,7 @@ export const GestureOverlay = ({
|
||||
|
||||
const showFeedback = useCallback(
|
||||
(
|
||||
icon: keyof typeof Ionicons.glyphMap,
|
||||
icon: string,
|
||||
text: string,
|
||||
side?: "left" | "right",
|
||||
isDuringDrag = false,
|
||||
@@ -145,7 +145,7 @@ export const GestureOverlay = ({
|
||||
});
|
||||
|
||||
const handleSkipForward = useCallback(() => {
|
||||
if (!settings.enableHorizontalSwipeSkip) return;
|
||||
if (!settings.enableDoubleTapSkip) return;
|
||||
lightHaptic();
|
||||
// Defer all actions to avoid useInsertionEffect warning
|
||||
requestAnimationFrame(() => {
|
||||
@@ -153,7 +153,7 @@ export const GestureOverlay = ({
|
||||
showFeedback("play-forward", `+${settings.forwardSkipTime}s`);
|
||||
});
|
||||
}, [
|
||||
settings.enableHorizontalSwipeSkip,
|
||||
settings.enableDoubleTapSkip,
|
||||
settings.forwardSkipTime,
|
||||
lightHaptic,
|
||||
onSkipForward,
|
||||
@@ -161,7 +161,7 @@ export const GestureOverlay = ({
|
||||
]);
|
||||
|
||||
const handleSkipBackward = useCallback(() => {
|
||||
if (!settings.enableHorizontalSwipeSkip) return;
|
||||
if (!settings.enableDoubleTapSkip) return;
|
||||
lightHaptic();
|
||||
// Defer all actions to avoid useInsertionEffect warning
|
||||
requestAnimationFrame(() => {
|
||||
@@ -169,7 +169,7 @@ export const GestureOverlay = ({
|
||||
showFeedback("play-back", `-${settings.rewindSkipTime}s`);
|
||||
});
|
||||
}, [
|
||||
settings.enableHorizontalSwipeSkip,
|
||||
settings.enableDoubleTapSkip,
|
||||
settings.rewindSkipTime,
|
||||
lightHaptic,
|
||||
onSkipBackward,
|
||||
@@ -237,8 +237,8 @@ export const GestureOverlay = ({
|
||||
|
||||
const { handleTouchStart, handleTouchMove, handleTouchEnd } =
|
||||
useGestureDetection({
|
||||
onSwipeLeft: handleSkipBackward,
|
||||
onSwipeRight: handleSkipForward,
|
||||
onDoubleTapLeft: handleSkipBackward,
|
||||
onDoubleTapRight: handleSkipForward,
|
||||
onVerticalDragStart: handleVerticalDragStart,
|
||||
onVerticalDragMove: handleVerticalDragMove,
|
||||
onVerticalDragEnd: handleVerticalDragEnd,
|
||||
@@ -247,29 +247,30 @@ export const GestureOverlay = ({
|
||||
screenHeight,
|
||||
});
|
||||
|
||||
// If controls are visible, act like the old tap overlay
|
||||
if (showControls) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onToggleControls}
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
backgroundColor: "black",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
opacity: 0.75,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Background overlay when controls are visible
|
||||
const controlsOverlay = showControls && (
|
||||
<Pressable
|
||||
onPress={onToggleControls}
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
backgroundColor: "black",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
opacity: 0.75,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Gesture detection area */}
|
||||
{/* Controls overlay when visible */}
|
||||
{controlsOverlay}
|
||||
|
||||
{/* Gesture detection area - always present */}
|
||||
<Pressable
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
@@ -320,7 +321,7 @@ export const GestureOverlay = ({
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={feedback.icon}
|
||||
name={feedback.icon as any}
|
||||
size={24}
|
||||
color='white'
|
||||
style={{ marginRight: 8 }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
createContext,
|
||||
@@ -95,7 +95,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
playbackPosition: playbackPosition,
|
||||
}).toString();
|
||||
|
||||
router.replace(`player/direct-player?${queryParams}` as Href);
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
};
|
||||
|
||||
const setTrackParams = (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Platform, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
@@ -53,7 +53,7 @@ const DropdownView = () => {
|
||||
bitrateValue: bitrate.toString(),
|
||||
playbackPosition: playbackPositionRef.current,
|
||||
}).toString();
|
||||
router.replace(`player/direct-player?${queryParams}` as Href);
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
},
|
||||
[audioIndex, subtitleIndex, router],
|
||||
);
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { GestureResponderEvent } from "react-native";
|
||||
export interface SwipeGestureOptions {
|
||||
minDistance?: number;
|
||||
maxDuration?: number;
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
onDoubleTapLeft?: () => void;
|
||||
onDoubleTapRight?: () => void;
|
||||
onVerticalDragStart?: (side: "left" | "right", initialY: number) => void;
|
||||
onVerticalDragMove?: (
|
||||
side: "left" | "right",
|
||||
@@ -21,8 +21,8 @@ export interface SwipeGestureOptions {
|
||||
export const useGestureDetection = ({
|
||||
minDistance = 50,
|
||||
maxDuration = 800,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
onDoubleTapLeft,
|
||||
onDoubleTapRight,
|
||||
onVerticalDragStart,
|
||||
onVerticalDragMove,
|
||||
onVerticalDragEnd,
|
||||
@@ -39,6 +39,11 @@ export const useGestureDetection = ({
|
||||
const gestureType = useRef<"none" | "horizontal" | "vertical">("none");
|
||||
const shouldIgnoreTouch = useRef(false);
|
||||
|
||||
// Double tap detection refs
|
||||
const lastTapTime = useRef(0);
|
||||
const lastTapPosition = useRef({ x: 0, y: 0 });
|
||||
const doubleTapTimeWindow = 300; // 300ms window for double tap
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
const startY = event.nativeEvent.pageY;
|
||||
@@ -102,9 +107,6 @@ export const useGestureDetection = ({
|
||||
isDragging.current = true;
|
||||
dragSide.current = side;
|
||||
onVerticalDragStart?.(side, touchStartPosition.current.y);
|
||||
} else if (absX > absY && absX > 10) {
|
||||
// Horizontal gesture - mark for discrete swipe
|
||||
gestureType.current = "horizontal";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,8 +146,8 @@ export const useGestureDetection = ({
|
||||
const touchDuration = touchEndTime - touchStartTime.current;
|
||||
const deltaX = touchEndPosition.x - touchStartPosition.current.x;
|
||||
const deltaY = touchEndPosition.y - touchStartPosition.current.y;
|
||||
const absX = Math.abs(deltaX);
|
||||
const absY = Math.abs(deltaY);
|
||||
const _absX = Math.abs(deltaX);
|
||||
const _absY = Math.abs(deltaY);
|
||||
const totalDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
|
||||
// End vertical drag if we were dragging
|
||||
@@ -169,25 +171,43 @@ export const useGestureDetection = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle discrete horizontal swipes (for skip) only if it was marked as horizontal
|
||||
// Check if it's a tap (short duration and small movement)
|
||||
if (
|
||||
gestureType.current === "horizontal" &&
|
||||
hasMovedEnough.current &&
|
||||
absX > absY &&
|
||||
totalDistance > minDistance
|
||||
) {
|
||||
if (deltaX > 0) {
|
||||
onSwipeRight?.();
|
||||
} else {
|
||||
onSwipeLeft?.();
|
||||
}
|
||||
} else if (
|
||||
!hasMovedEnough.current &&
|
||||
touchDuration < 300 &&
|
||||
totalDistance < 10
|
||||
) {
|
||||
// It's a tap - short duration and small movement
|
||||
onTap?.();
|
||||
const currentTime = Date.now();
|
||||
const tapX = touchEndPosition.x;
|
||||
const tapY = touchEndPosition.y;
|
||||
|
||||
// Check for double tap
|
||||
const timeSinceLastTap = currentTime - lastTapTime.current;
|
||||
const distanceFromLastTap = Math.sqrt(
|
||||
(tapX - lastTapPosition.current.x) ** 2 +
|
||||
(tapY - lastTapPosition.current.y) ** 2,
|
||||
);
|
||||
|
||||
if (
|
||||
timeSinceLastTap <= doubleTapTimeWindow &&
|
||||
distanceFromLastTap < 50
|
||||
) {
|
||||
// It's a double tap
|
||||
const isLeftSide = tapX < screenWidth / 2;
|
||||
if (isLeftSide) {
|
||||
onDoubleTapLeft?.();
|
||||
} else {
|
||||
onDoubleTapRight?.();
|
||||
}
|
||||
// Reset last tap to prevent triple tap
|
||||
lastTapTime.current = 0;
|
||||
lastTapPosition.current = { x: 0, y: 0 };
|
||||
} else {
|
||||
// It's a single tap - execute immediately
|
||||
onTap?.();
|
||||
lastTapTime.current = currentTime;
|
||||
lastTapPosition.current = { x: tapX, y: tapY };
|
||||
}
|
||||
}
|
||||
|
||||
hasMovedEnough.current = false;
|
||||
@@ -196,10 +216,12 @@ export const useGestureDetection = ({
|
||||
[
|
||||
maxDuration,
|
||||
minDistance,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
onDoubleTapLeft,
|
||||
onDoubleTapRight,
|
||||
onVerticalDragEnd,
|
||||
onTap,
|
||||
doubleTapTimeWindow,
|
||||
screenWidth,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -89,8 +89,8 @@
|
||||
},
|
||||
"gesture_controls": {
|
||||
"gesture_controls_title": "التحكم بالإيماءات",
|
||||
"horizontal_swipe_skip": "السحب الأفقي للتخطي",
|
||||
"horizontal_swipe_skip_description": "اسحب لليسار/لليمين عندما تكون عناصر التحكم مخفية للتخطي",
|
||||
"horizontal_swipe_skip": "النقر المزدوج للتخطي",
|
||||
"horizontal_swipe_skip_description": "انقر نقرًا مزدوجًا على الجانب الأيسر/الأيمن عندما تكون عناصر التحكم مخفية للتخطي",
|
||||
"left_side_brightness": "التحكم في السطوع من الجانب الأيسر",
|
||||
"left_side_brightness_description": "اسحب لأعلى/لأسفل على الجانب الأيسر لضبط السطوع",
|
||||
"right_side_volume": "التحكم في مستوى الصوت من الجانب الأيمن",
|
||||
|
||||
@@ -106,8 +106,8 @@
|
||||
},
|
||||
"gesture_controls": {
|
||||
"gesture_controls_title": "Gesture Controls",
|
||||
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
|
||||
"horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip",
|
||||
"horizontal_swipe_skip": "Double Tap to Skip",
|
||||
"horizontal_swipe_skip_description": "Double tap left/right side when controls are hidden to skip",
|
||||
"left_side_brightness": "Left Side Brightness Control",
|
||||
"left_side_brightness_description": "Swipe up/down on left side to adjust brightness",
|
||||
"right_side_volume": "Right Side Volume Control",
|
||||
|
||||
@@ -94,8 +94,8 @@
|
||||
},
|
||||
"gesture_controls": {
|
||||
"gesture_controls_title": "Gesztusvezérlés",
|
||||
"horizontal_swipe_skip": "Vízszintes Húzás Ugráshoz",
|
||||
"horizontal_swipe_skip_description": "Ha a vezérlők el vannak rejtve, húzd balra vagy jobbra az ugráshoz.",
|
||||
"horizontal_swipe_skip": "Dupla Érintés Ugráshoz",
|
||||
"horizontal_swipe_skip_description": "Dupla érintés bal/jobb oldalon ha a vezérlők el vannak rejtve az ugráshoz.",
|
||||
"left_side_brightness": "Fényerő a Bal Oldalon",
|
||||
"left_side_brightness_description": "Húzd felfelé vagy lefelé a bal oldalon a fényerő állításához",
|
||||
"right_side_volume": "Fényerő a Jobb Oldalon",
|
||||
|
||||
@@ -175,7 +175,7 @@ export type Settings = {
|
||||
vlcOutlineOpacity?: number;
|
||||
vlcIsBold?: boolean;
|
||||
// Gesture controls
|
||||
enableHorizontalSwipeSkip: boolean;
|
||||
enableDoubleTapSkip: boolean;
|
||||
enableLeftSideBrightnessSwipe: boolean;
|
||||
enableRightSideVolumeSwipe: boolean;
|
||||
usePopularPlugin: boolean;
|
||||
@@ -239,7 +239,7 @@ export const defaultValues: Settings = {
|
||||
vlcOutlineOpacity: undefined,
|
||||
vlcIsBold: undefined,
|
||||
// Gesture controls
|
||||
enableHorizontalSwipeSkip: true,
|
||||
enableDoubleTapSkip: true,
|
||||
enableLeftSideBrightnessSwipe: true,
|
||||
enableRightSideVolumeSwipe: true,
|
||||
usePopularPlugin: true,
|
||||
@@ -362,11 +362,10 @@ export const useSettings = () => {
|
||||
value !== undefined &&
|
||||
_settings?.[settingsKey] !== value
|
||||
) {
|
||||
(unlockedPluginDefaults as Record<string, unknown>)[settingsKey] =
|
||||
value;
|
||||
(unlockedPluginDefaults as any)[settingsKey] = value;
|
||||
}
|
||||
|
||||
(acc as Record<string, unknown>)[settingsKey] = locked
|
||||
(acc as any)[settingsKey] = locked
|
||||
? value
|
||||
: (_settings?.[settingsKey] ?? value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user