add double-tap to seek feature

This commit is contained in:
Aykhan 2025-10-10 20:50:25 +04:00
parent bf774106ff
commit c240ccefd5
10 changed files with 204 additions and 16 deletions

View file

@ -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 <bold>extension</bold> is required for that source. <br><br> <strong>(The default order is best for most users)</strong>",
"sourceOrderEnableLabel": "Custom source order",

View file

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

View file

@ -0,0 +1,20 @@
import { Icon, Icons } from "@/components/Icon";
export type SeekDirection = "backward" | "forward";
export function Seek(props: { direction: SeekDirection }) {
return (
<div
className={`pointer-events-none flex h-20 w-20 items-center justify-center rounded-full bg-black bg-opacity-50 text-white ${
props.direction === "backward" ? "animate-seek-left" : "animate-seek-right"
}`}
>
<Icon
icon={
props.direction === "backward" ? Icons.SKIP_BACKWARD : Icons.SKIP_FORWARD
}
className="text-3xl"
/>
</div>
);
}

View file

@ -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 (
<VideoPlayerButton
iconSizeClass={props.iconSizeClass}
@ -29,10 +33,13 @@ export function SkipBackward(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 (
<VideoPlayerButton
iconSizeClass={props.iconSizeClass}

View file

@ -1,7 +1,8 @@
import classNames from "classnames";
import { PointerEvent, useCallback, useRef, useState } from "react";
import { PointerEvent, useCallback, useEffect, useRef, useState } from "react";
import { useEffectOnce, useTimeoutFn } from "react-use";
import { Seek, SeekDirection } from "@/components/player/atoms/Seek";
import { useShouldShowVideoElement } from "@/components/player/internals/VideoContainer";
import { useOverlayStack } from "@/stores/interface/overlayStack";
import { PlayerHoverState } from "@/stores/player/slices/interface";
@ -12,6 +13,7 @@ import { useWatchPartyStore } from "@/stores/watchParty";
export function VideoClickTarget(props: { showingControls: boolean }) {
const show = useShouldShowVideoElement();
const display = usePlayerStore((s) => 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<NodeJS.Timeout | null>(null);
const boostTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isPendingBoost, setIsPendingBoost] = useState(false);
const [seekDirection, setSeekDirection] = useState<SeekDirection | null>(
null,
);
const [seekId, setSeekId] = useState(0);
const [isSeeking, setIsSeeking] = useState(false);
const seekTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const singleTapTimeout = useRef<NodeJS.Timeout | null>(null);
const toggleFullscreen = useCallback(() => {
display?.toggleFullscreen();
}, [display]);
const handleDoubleClick = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
// 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div
className={classNames("absolute inset-0", {
"absolute inset-0": true,
"cursor-none": !props.showingControls,
})}
onDoubleClick={toggleFullscreen}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
/>
<>
{seekDirection ? (
<div
key={seekId}
onAnimationEnd={() => 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"
}
>
<Seek direction={seekDirection} />
</div>
) : null}
<div
className={classNames("absolute inset-0", {
"absolute inset-0": true,
"cursor-none": !props.showingControls,
})}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
/>
</>
);
}

View file

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

View file

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

View file

@ -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: {
</div>
</div>
</div>
{/* double click to seek preference */}
<div>
<p className="text-white font-bold mb-3">
{t("settings.preferences.doubleClickToSeek")}
</p>
<p className="max-w-[25rem] font-medium">
{t("settings.preferences.doubleClickToSeekDescription")}
</p>
<div
onClick={() =>
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"
>
<Toggle enabled={props.enableDoubleClickToSeek} />
<p className="flex-1 text-white font-bold">
{t("settings.preferences.doubleClickToSeekLabel")}
</p>
</div>
</div>
{/* Column */}
<div id="source-order" className="space-y-8">

View file

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

View file

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