Compare commits

..

2 Commits

Author SHA1 Message Date
Fredrik Burmester
5358c1e1d5 fix: double tap works indipendant of controls showing 2025-11-17 07:25:20 +01:00
Fredrik Burmester
cd7a7b0e0e feat: double tap to seek 2025-11-16 22:13:35 +01:00
24 changed files with 171 additions and 173 deletions

View File

@@ -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>
);
};

View File

@@ -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);

View File

@@ -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);

View File

@@ -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}
/>
)}

View File

@@ -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} />

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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],
);

View File

@@ -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}
>

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 { 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()

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 { 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 (

View File

@@ -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({

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'
>
{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;
})}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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],
);

View File

@@ -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 }}

View File

@@ -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 = (

View File

@@ -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],
);

View File

@@ -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,
],
);

View File

@@ -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": "التحكم في مستوى الصوت من الجانب الأيمن",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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);
}