mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +00:00
add double-tap to seek feature
This commit is contained in:
parent
bf774106ff
commit
c240ccefd5
10 changed files with 204 additions and 16 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
20
src/components/player/atoms/Seek.tsx
Normal file
20
src/components/player/atoms/Seek.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue