From c240ccefd582b3128aba27909a9667269f032a91 Mon Sep 17 00:00:00 2001 From: Aykhan Date: Fri, 10 Oct 2025 20:50:25 +0400 Subject: [PATCH] add double-tap to seek feature --- src/assets/locales/en.json | 3 + src/backend/accounts/settings.ts | 2 + src/components/player/atoms/Seek.tsx | 20 +++ src/components/player/atoms/Skips.tsx | 11 +- .../player/internals/VideoClickTarget.tsx | 117 +++++++++++++++--- src/hooks/useSettingsState.ts | 14 +++ src/pages/Settings.tsx | 13 ++ src/pages/parts/settings/PreferencesPart.tsx | 22 ++++ src/stores/preferences/index.tsx | 8 ++ tailwind.config.ts | 10 ++ 10 files changed, 204 insertions(+), 16 deletions(-) create mode 100644 src/components/player/atoms/Seek.tsx diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 79271675..b590d556 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -1050,6 +1050,9 @@ "holdToBoost": "Hold to boost speed", "holdToBoostDescription": "Hold spacebar or touch and hold the screen to temporarily increase playback speed to 2x. Release to return to previous speed.", "holdToBoostLabel": "Enable hold to boost", + "doubleClickToSeek": "Double tap to seek", + "doubleClickToSeekDescription": "Double tap on the left or right side of the player to seek 10 seconds forward or backward.", + "doubleClickToSeekLabel": "Enable double tap to seek", "sourceOrder": "Reordering sources", "sourceOrderDescription": "Drag and drop to reorder sources. This will determine the order in which sources are checked for the media you are trying to watch. If a source is greyed out, it means the extension is required for that source.

(The default order is best for most users)", "sourceOrderEnableLabel": "Custom source order", diff --git a/src/backend/accounts/settings.ts b/src/backend/accounts/settings.ts index affd9028..a6ecf766 100644 --- a/src/backend/accounts/settings.ts +++ b/src/backend/accounts/settings.ts @@ -27,6 +27,7 @@ export interface SettingsInput { enableLowPerformanceMode?: boolean; enableNativeSubtitles?: boolean; enableHoldToBoost?: boolean; + enableDoubleClickToSeek?: boolean; manualSourceSelection?: boolean; } @@ -53,6 +54,7 @@ export interface SettingsResponse { enableLowPerformanceMode?: boolean; enableNativeSubtitles?: boolean; enableHoldToBoost?: boolean; + enableDoubleClickToSeek?: boolean; manualSourceSelection?: boolean; } diff --git a/src/components/player/atoms/Seek.tsx b/src/components/player/atoms/Seek.tsx new file mode 100644 index 00000000..081f1766 --- /dev/null +++ b/src/components/player/atoms/Seek.tsx @@ -0,0 +1,20 @@ +import { Icon, Icons } from "@/components/Icon"; + +export type SeekDirection = "backward" | "forward"; + +export function Seek(props: { direction: SeekDirection }) { + return ( +
+ +
+ ); +} diff --git a/src/components/player/atoms/Skips.tsx b/src/components/player/atoms/Skips.tsx index 168a2a53..b333fd81 100644 --- a/src/components/player/atoms/Skips.tsx +++ b/src/components/player/atoms/Skips.tsx @@ -3,6 +3,7 @@ import { useCallback } from "react"; import { Icons } from "@/components/Icon"; import { VideoPlayerButton } from "@/components/player/internals/Button"; import { usePlayerStore } from "@/stores/player/store"; +import { usePreferencesStore } from "@/stores/preferences"; export function SkipForward(props: { iconSizeClass?: string; @@ -10,10 +11,13 @@ export function SkipForward(props: { }) { const display = usePlayerStore((s) => s.display); const time = usePlayerStore((s) => s.progress.time); + const enableDoubleClickToSeek = usePreferencesStore( + (s) => s.enableDoubleClickToSeek, + ); const commit = useCallback(() => { display?.setTime(time + 10); }, [display, time]); - if (!props.inControl) return null; + if (!props.inControl || enableDoubleClickToSeek) return null; return ( s.display); const time = usePlayerStore((s) => s.progress.time); + const enableDoubleClickToSeek = usePreferencesStore( + (s) => s.enableDoubleClickToSeek, + ); const commit = useCallback(() => { display?.setTime(time - 10); }, [display, time]); - if (!props.inControl) return null; + if (!props.inControl || enableDoubleClickToSeek) return null; return ( s.display); + const time = usePlayerStore((s) => s.progress.time); const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused); const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate); const updateInterfaceHovering = usePlayerStore( @@ -23,6 +25,9 @@ export function VideoClickTarget(props: { showingControls: boolean }) { const setCurrentOverlay = useOverlayStack((s) => s.setCurrentOverlay); const isInWatchParty = useWatchPartyStore((s) => s.enabled); const enableHoldToBoost = usePreferencesStore((s) => s.enableHoldToBoost); + const enableDoubleClickToSeek = usePreferencesStore( + (s) => s.enableDoubleClickToSeek, + ); const [_, cancel, reset] = useTimeoutFn(() => { updateInterfaceHovering(PlayerHoverState.NOT_HOVERING); @@ -36,11 +41,56 @@ export function VideoClickTarget(props: { showingControls: boolean }) { const speedIndicatorTimeoutRef = useRef(null); const boostTimeoutRef = useRef(null); const [isPendingBoost, setIsPendingBoost] = useState(false); + const [seekDirection, setSeekDirection] = useState( + null, + ); + const [seekId, setSeekId] = useState(0); + const [isSeeking, setIsSeeking] = useState(false); + const seekTimeoutRef = useRef(null); + const singleTapTimeout = useRef(null); const toggleFullscreen = useCallback(() => { display?.toggleFullscreen(); }, [display]); + const handleDoubleClick = useCallback( + (e: PointerEvent) => { + if (!enableDoubleClickToSeek) { + toggleFullscreen(); + return; + } + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left; + const oneThird = rect.width / 3; + + if (x < oneThird) { + display?.setTime(time - 10); + setSeekDirection("backward"); + setSeekId((s) => s + 1); + setIsSeeking(true); + } else if (x > oneThird * 2) { + display?.setTime(time + 10); + setSeekDirection("forward"); + setSeekId((s) => s + 1); + setIsSeeking(true); + } else { + toggleFullscreen(); + } + }, + [display, toggleFullscreen, enableDoubleClickToSeek, time], + ); + + useEffect(() => { + if (isSeeking) { + if (seekTimeoutRef.current) { + clearTimeout(seekTimeoutRef.current); + } + seekTimeoutRef.current = setTimeout(() => { + setIsSeeking(false); + }, 400); + } + }, [seekId, isSeeking]); + const togglePause = useCallback( (e: PointerEvent) => { // Don't toggle pause if holding for speed change @@ -65,6 +115,7 @@ export function VideoClickTarget(props: { showingControls: boolean }) { } // toggle on other types of clicks + if (isSeeking) return; if (hovering !== PlayerHoverState.MOBILE_TAPPED) { updateInterfaceHovering(PlayerHoverState.MOBILE_TAPPED); reset(); @@ -81,9 +132,33 @@ export function VideoClickTarget(props: { showingControls: boolean }) { reset, cancel, isPendingBoost, + isSeeking, ], ); + const handleTap = useCallback( + (e: PointerEvent) => { + if (e.pointerType === "mouse" && e.button !== 0) return; + + if (singleTapTimeout.current) { + clearTimeout(singleTapTimeout.current); + singleTapTimeout.current = null; + handleDoubleClick(e); + } else { + if (!enableDoubleClickToSeek) { + togglePause(e); + } + singleTapTimeout.current = setTimeout(() => { + if (enableDoubleClickToSeek) { + togglePause(e); + } + singleTapTimeout.current = null; + }, 250); + } + }, + [handleDoubleClick, togglePause, enableDoubleClickToSeek], + ); + const handlePointerDown = useCallback( (e: PointerEvent) => { if ( @@ -141,7 +216,7 @@ export function VideoClickTarget(props: { showingControls: boolean }) { if (isPendingBoost) { clearTimeout(boostTimeoutRef.current!); setIsPendingBoost(false); - togglePause(e); + handleTap(e); return; } @@ -170,12 +245,12 @@ export function VideoClickTarget(props: { showingControls: boolean }) { }, 1500); } else { // Regular click handler - togglePause(e); + handleTap(e); } }, [ display, - togglePause, + handleTap, setSpeedBoosted, setShowSpeedIndicator, setCurrentOverlay, @@ -221,15 +296,29 @@ export function VideoClickTarget(props: { showingControls: boolean }) { if (!show) return null; return ( -
+ <> + {seekDirection ? ( +
setSeekDirection(null)} + className={ + seekDirection === "backward" + ? "absolute inset-0 flex items-center justify-start ml-32" + : "absolute inset-0 flex items-center justify-end mr-32" + } + > + +
+ ) : null} +
+ ); } diff --git a/src/hooks/useSettingsState.ts b/src/hooks/useSettingsState.ts index 7f8ddef8..8d3932fb 100644 --- a/src/hooks/useSettingsState.ts +++ b/src/hooks/useSettingsState.ts @@ -66,6 +66,7 @@ export function useSettingsState( forceCompactEpisodeView: boolean, enableLowPerformanceMode: boolean, enableHoldToBoost: boolean, + enableDoubleClickToSeek: boolean, homeSectionOrder: string[], manualSourceSelection: boolean, ) { @@ -183,6 +184,12 @@ export function useSettingsState( resetEnableHoldToBoost, enableHoldToBoostChanged, ] = useDerived(enableHoldToBoost); + const [ + enableDoubleClickToSeekState, + setEnableDoubleClickToSeekState, + resetEnableDoubleClickToSeek, + enableDoubleClickToSeekChanged, + ] = useDerived(enableDoubleClickToSeek); const [ homeSectionOrderState, setHomeSectionOrderState, @@ -221,6 +228,7 @@ export function useSettingsState( resetForceCompactEpisodeView(); resetEnableLowPerformanceMode(); resetEnableHoldToBoost(); + resetEnableDoubleClickToSeek(); resetHomeSectionOrder(); resetManualSourceSelection(); } @@ -249,6 +257,7 @@ export function useSettingsState( forceCompactEpisodeViewChanged || enableLowPerformanceModeChanged || enableHoldToBoostChanged || + enableDoubleClickToSeekChanged || homeSectionOrderChanged || manualSourceSelectionChanged; @@ -370,6 +379,11 @@ export function useSettingsState( set: setEnableHoldToBoostState, changed: enableHoldToBoostChanged, }, + enableDoubleClickToSeek: { + state: enableDoubleClickToSeekState, + set: setEnableDoubleClickToSeekState, + changed: enableDoubleClickToSeekChanged, + }, homeSectionOrder: { state: homeSectionOrderState, set: setHomeSectionOrderState, diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index fc0b4d20..4b30350a 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -197,6 +197,13 @@ export function SettingsPage() { (s) => s.setEnableHoldToBoost, ); + const enableDoubleClickToSeek = usePreferencesStore( + (s) => s.enableDoubleClickToSeek, + ); + const setEnableDoubleClickToSeek = usePreferencesStore( + (s) => s.setEnableDoubleClickToSeek, + ); + const homeSectionOrder = usePreferencesStore((s) => s.homeSectionOrder); const setHomeSectionOrder = usePreferencesStore((s) => s.setHomeSectionOrder); @@ -259,6 +266,7 @@ export function SettingsPage() { forceCompactEpisodeView, enableLowPerformanceMode, enableHoldToBoost, + enableDoubleClickToSeek, homeSectionOrder, manualSourceSelection, ); @@ -320,6 +328,7 @@ export function SettingsPage() { state.forceCompactEpisodeView.changed || state.enableLowPerformanceMode.changed || state.enableHoldToBoost.changed || + state.enableDoubleClickToSeek.changed || state.manualSourceSelection.changed ) { await updateSettings(backendUrl, account, { @@ -342,6 +351,7 @@ export function SettingsPage() { forceCompactEpisodeView: state.forceCompactEpisodeView.state, enableLowPerformanceMode: state.enableLowPerformanceMode.state, enableHoldToBoost: state.enableHoldToBoost.state, + enableDoubleClickToSeek: state.enableDoubleClickToSeek.state, manualSourceSelection: state.manualSourceSelection.state, }); } @@ -383,6 +393,7 @@ export function SettingsPage() { setForceCompactEpisodeView(state.forceCompactEpisodeView.state); setEnableLowPerformanceMode(state.enableLowPerformanceMode.state); setEnableHoldToBoost(state.enableHoldToBoost.state); + setEnableDoubleClickToSeek(state.enableDoubleClickToSeek.state); setHomeSectionOrder(state.homeSectionOrder.state); setManualSourceSelection(state.manualSourceSelection.state); @@ -483,6 +494,8 @@ export function SettingsPage() { setEnableLowPerformanceMode={state.enableLowPerformanceMode.set} enableHoldToBoost={state.enableHoldToBoost.state} setEnableHoldToBoost={state.enableHoldToBoost.set} + enableDoubleClickToSeek={state.enableDoubleClickToSeek.state} + setEnableDoubleClickToSeek={state.enableDoubleClickToSeek.set} manualSourceSelection={state.manualSourceSelection.state} setManualSourceSelection={state.manualSourceSelection.set} /> diff --git a/src/pages/parts/settings/PreferencesPart.tsx b/src/pages/parts/settings/PreferencesPart.tsx index 00a873e4..f523ecc0 100644 --- a/src/pages/parts/settings/PreferencesPart.tsx +++ b/src/pages/parts/settings/PreferencesPart.tsx @@ -31,6 +31,8 @@ export function PreferencesPart(props: { setEnableLowPerformanceMode: (v: boolean) => void; enableHoldToBoost: boolean; setEnableHoldToBoost: (v: boolean) => void; + enableDoubleClickToSeek: boolean; + setEnableDoubleClickToSeek: (v: boolean) => void; manualSourceSelection: boolean; setManualSourceSelection: (v: boolean) => void; }) { @@ -217,6 +219,26 @@ export function PreferencesPart(props: {
+ {/* double click to seek preference */} +
+

+ {t("settings.preferences.doubleClickToSeek")} +

+

+ {t("settings.preferences.doubleClickToSeekDescription")} +

+
+ props.setEnableDoubleClickToSeek(!props.enableDoubleClickToSeek) + } + className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg" + > + +

+ {t("settings.preferences.doubleClickToSeekLabel")} +

+
+
{/* Column */}
diff --git a/src/stores/preferences/index.tsx b/src/stores/preferences/index.tsx index 6354bfb3..f1c2129e 100644 --- a/src/stores/preferences/index.tsx +++ b/src/stores/preferences/index.tsx @@ -22,6 +22,7 @@ export interface PreferencesStore { enableLowPerformanceMode: boolean; enableNativeSubtitles: boolean; enableHoldToBoost: boolean; + enableDoubleClickToSeek: boolean; homeSectionOrder: string[]; manualSourceSelection: boolean; @@ -44,6 +45,7 @@ export interface PreferencesStore { setEnableLowPerformanceMode(v: boolean): void; setEnableNativeSubtitles(v: boolean): void; setEnableHoldToBoost(v: boolean): void; + setEnableDoubleClickToSeek(v: boolean): void; setHomeSectionOrder(v: string[]): void; setManualSourceSelection(v: boolean): void; } @@ -70,6 +72,7 @@ export const usePreferencesStore = create( enableLowPerformanceMode: false, enableNativeSubtitles: false, enableHoldToBoost: true, + enableDoubleClickToSeek: false, homeSectionOrder: ["watching", "bookmarks"], manualSourceSelection: false, setEnableThumbnails(v) { @@ -167,6 +170,11 @@ export const usePreferencesStore = create( s.enableHoldToBoost = v; }); }, + setEnableDoubleClickToSeek(v) { + set((s) => { + s.enableDoubleClickToSeek = v; + }); + }, setHomeSectionOrder(v) { set((s) => { s.homeSectionOrder = v; diff --git a/tailwind.config.ts b/tailwind.config.ts index a56abebc..4c82fbae 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -33,10 +33,20 @@ const config: Config = { "0%": { opacity: "0" }, "100%": { opacity: "1" }, }, + "seek-left": { + "0%": { transform: "translateX(0) scale(1)", opacity: "1" }, + "100%": { transform: "translateX(-50px) scale(1.2)", opacity: "0" }, + }, + "seek-right": { + "0%": { transform: "translateX(0) scale(1)", opacity: "1" }, + "100%": { transform: "translateX(50px) scale(1.2)", opacity: "0" }, + }, }, animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite", "fade-in": "fade-in 200ms ease-out forwards", + "seek-left": "seek-left 0.5s cubic-bezier(0, 0, 0.2, 1) forwards", + "seek-right": "seek-right 0.5s cubic-bezier(0, 0, 0.2, 1) forwards", }, }, },