mirror of
https://github.com/p-stream/p-stream.git
synced 2026-08-20 06:06:17 +00:00
add keyboard shortcut modal
This commit is contained in:
parent
99d3e11b80
commit
3a31d172b1
4 changed files with 292 additions and 4 deletions
|
|
@ -237,6 +237,43 @@
|
|||
"settings": "Settings",
|
||||
"migration": "Migrate Account",
|
||||
"jip": "Jip"
|
||||
},
|
||||
"keyboardShortcuts": {
|
||||
"title": "Keyboard Shortcuts",
|
||||
"subtitle": "Hold ` to show this help anytime",
|
||||
"groups": {
|
||||
"videoPlayback": "Video Playback",
|
||||
"jumpToPosition": "Jump to Position",
|
||||
"audioVideo": "Audio & Video",
|
||||
"subtitlesAccessibility": "Subtitles & Accessibility",
|
||||
"interface": "Interface"
|
||||
},
|
||||
"shortcuts": {
|
||||
"playPause": "Play/pause (or hold for 2x speed when enabled)",
|
||||
"playPauseAlt": "Play/pause",
|
||||
"skipForward5": "Skip forward 5 seconds",
|
||||
"skipBackward5": "Skip backward 5 seconds",
|
||||
"skipBackward10": "Skip backward 10 seconds",
|
||||
"skipForward10": "Skip forward 10 seconds",
|
||||
"skipForward1": "Skip forward 1 second (when paused)",
|
||||
"skipBackward1": "Skip backward 1 second (when paused)",
|
||||
"jumpTo0": "Jump to 0% (beginning)",
|
||||
"jumpTo9": "Jump to 90%",
|
||||
"increaseVolume": "Increase volume",
|
||||
"decreaseVolume": "Decrease volume",
|
||||
"mute": "Mute/unmute",
|
||||
"changeSpeed": "Increase/decrease playback speed",
|
||||
"toggleFullscreen": "Toggle fullscreen",
|
||||
"toggleCaptions": "Toggle captions",
|
||||
"syncSubtitlesEarlier": "Sync subtitles earlier (-0.5s)",
|
||||
"syncSubtitlesLater": "Sync subtitles later (+0.5s)",
|
||||
"barrelRoll": "Do a barrel roll! 🌀",
|
||||
"closeOverlay": "Close overlay/modal"
|
||||
},
|
||||
"conditions": {
|
||||
"notInWatchParty": "Not in watch party",
|
||||
"whenPaused": "When paused"
|
||||
}
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
|
|
|
|||
200
src/components/overlays/KeyboardCommandsModal.tsx
Normal file
200
src/components/overlays/KeyboardCommandsModal.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Modal, ModalCard } from "@/components/overlays/Modal";
|
||||
import { Heading2 } from "@/components/utils/Text";
|
||||
|
||||
interface KeyboardShortcut {
|
||||
key: string;
|
||||
description: string;
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
interface ShortcutGroup {
|
||||
title: string;
|
||||
shortcuts: KeyboardShortcut[];
|
||||
}
|
||||
|
||||
const getShortcutGroups = (t: (key: string) => string): ShortcutGroup[] => [
|
||||
{
|
||||
title: t("global.keyboardShortcuts.groups.videoPlayback"),
|
||||
shortcuts: [
|
||||
{
|
||||
key: "Space",
|
||||
description: t("global.keyboardShortcuts.shortcuts.playPause"),
|
||||
},
|
||||
{
|
||||
key: "K",
|
||||
description: t("global.keyboardShortcuts.shortcuts.playPauseAlt"),
|
||||
},
|
||||
{
|
||||
key: "→",
|
||||
description: t("global.keyboardShortcuts.shortcuts.skipForward5"),
|
||||
},
|
||||
{
|
||||
key: "←",
|
||||
description: t("global.keyboardShortcuts.shortcuts.skipBackward5"),
|
||||
},
|
||||
{
|
||||
key: "J",
|
||||
description: t("global.keyboardShortcuts.shortcuts.skipBackward10"),
|
||||
},
|
||||
{
|
||||
key: "L",
|
||||
description: t("global.keyboardShortcuts.shortcuts.skipForward10"),
|
||||
},
|
||||
{
|
||||
key: ".",
|
||||
description: t("global.keyboardShortcuts.shortcuts.skipForward1"),
|
||||
condition: t("global.keyboardShortcuts.conditions.whenPaused"),
|
||||
},
|
||||
{
|
||||
key: ",",
|
||||
description: t("global.keyboardShortcuts.shortcuts.skipBackward1"),
|
||||
condition: t("global.keyboardShortcuts.conditions.whenPaused"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("global.keyboardShortcuts.groups.jumpToPosition"),
|
||||
shortcuts: [
|
||||
{
|
||||
key: "0",
|
||||
description: t("global.keyboardShortcuts.shortcuts.jumpTo0"),
|
||||
},
|
||||
{
|
||||
key: "9",
|
||||
description: t("global.keyboardShortcuts.shortcuts.jumpTo9"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("global.keyboardShortcuts.groups.audioVideo"),
|
||||
shortcuts: [
|
||||
{
|
||||
key: "↑",
|
||||
description: t("global.keyboardShortcuts.shortcuts.increaseVolume"),
|
||||
},
|
||||
{
|
||||
key: "↓",
|
||||
description: t("global.keyboardShortcuts.shortcuts.decreaseVolume"),
|
||||
},
|
||||
{ key: "M", description: t("global.keyboardShortcuts.shortcuts.mute") },
|
||||
{
|
||||
key: ">/",
|
||||
description: t("global.keyboardShortcuts.shortcuts.changeSpeed"),
|
||||
condition: t("global.keyboardShortcuts.conditions.notInWatchParty"),
|
||||
},
|
||||
{
|
||||
key: "F",
|
||||
description: t("global.keyboardShortcuts.shortcuts.toggleFullscreen"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("global.keyboardShortcuts.groups.subtitlesAccessibility"),
|
||||
shortcuts: [
|
||||
{
|
||||
key: "C",
|
||||
description: t("global.keyboardShortcuts.shortcuts.toggleCaptions"),
|
||||
},
|
||||
{
|
||||
key: "[",
|
||||
description: t(
|
||||
"global.keyboardShortcuts.shortcuts.syncSubtitlesEarlier",
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "]",
|
||||
description: t("global.keyboardShortcuts.shortcuts.syncSubtitlesLater"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("global.keyboardShortcuts.groups.interface"),
|
||||
shortcuts: [
|
||||
{
|
||||
key: "R",
|
||||
description: t("global.keyboardShortcuts.shortcuts.barrelRoll"),
|
||||
},
|
||||
{
|
||||
key: "Escape",
|
||||
description: t("global.keyboardShortcuts.shortcuts.closeOverlay"),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function KeyBadge({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<kbd className="inline-flex items-center justify-center min-w-[2rem] h-8 px-2 text-sm font-mono bg-gray-800 text-gray-200 rounded border border-gray-600 shadow-sm">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
interface KeyboardCommandsModalProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function KeyboardCommandsModal({ id }: KeyboardCommandsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const shortcutGroups = getShortcutGroups(t);
|
||||
|
||||
return (
|
||||
<Modal id={id}>
|
||||
<ModalCard>
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<Heading2 className="!mt-0 !mb-2">
|
||||
{t("global.keyboardShortcuts.title")}
|
||||
</Heading2>
|
||||
<p className="text-type-secondary text-lg">
|
||||
{(() => {
|
||||
const subtitle = t("global.keyboardShortcuts.subtitle");
|
||||
const [before, after] = subtitle.split("`");
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
<KeyBadge>`</KeyBadge>
|
||||
{after}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 max-h-[60vh] overflow-y-auto">
|
||||
{shortcutGroups.map((group) => (
|
||||
<div key={group.title} className="space-y-3">
|
||||
<h3 className="text-lg font-semibold text-white border-b border-gray-700 pb-2">
|
||||
{group.title}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{group.shortcuts.map((shortcut) => (
|
||||
<div
|
||||
key={shortcut.key}
|
||||
className="flex items-center justify-between py-1"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<KeyBadge>{shortcut.key}</KeyBadge>
|
||||
<span className="text-type-secondary">
|
||||
{shortcut.description}
|
||||
</span>
|
||||
</div>
|
||||
{shortcut.condition && (
|
||||
<span className="text-xs text-gray-400 italic">
|
||||
{shortcut.condition}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ModalCard>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { useOverlayStack } from "@/stores/interface/overlayStack";
|
||||
|
||||
|
|
@ -7,7 +7,17 @@ import { useOverlayStack } from "@/stores/interface/overlayStack";
|
|||
* Handles Escape key to close modals and other global shortcuts.
|
||||
*/
|
||||
export function useGlobalKeyboardEvents() {
|
||||
const { getTopModal, hideModal } = useOverlayStack();
|
||||
const { getTopModal, hideModal, showModal } = useOverlayStack();
|
||||
const holdTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>();
|
||||
const isKeyHeldRef = useRef<boolean>(false);
|
||||
|
||||
const showKeyboardCommands = useCallback(() => {
|
||||
showModal("keyboard-commands");
|
||||
}, [showModal]);
|
||||
|
||||
const hideKeyboardCommands = useCallback(() => {
|
||||
hideModal("keyboard-commands");
|
||||
}, [hideModal]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
|
|
@ -19,6 +29,21 @@ export function useGlobalKeyboardEvents() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Handle backtick (`) key hold for keyboard commands
|
||||
if (event.key === "`") {
|
||||
// Prevent default browser behavior (console opening in some browsers)
|
||||
event.preventDefault();
|
||||
|
||||
if (!isKeyHeldRef.current) {
|
||||
isKeyHeldRef.current = true;
|
||||
|
||||
// Show modal after 500ms hold
|
||||
holdTimeoutRef.current = setTimeout(() => {
|
||||
showKeyboardCommands();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Escape key to close modals
|
||||
if (event.key === "Escape") {
|
||||
const topModal = getTopModal();
|
||||
|
|
@ -28,11 +53,35 @@ export function useGlobalKeyboardEvents() {
|
|||
}
|
||||
};
|
||||
|
||||
// Add event listener to document for global coverage
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === "`") {
|
||||
// Clear the hold timeout if key is released before modal shows
|
||||
if (holdTimeoutRef.current) {
|
||||
clearTimeout(holdTimeoutRef.current);
|
||||
holdTimeoutRef.current = undefined;
|
||||
}
|
||||
|
||||
// Hide modal if it was shown
|
||||
if (isKeyHeldRef.current) {
|
||||
hideKeyboardCommands();
|
||||
}
|
||||
|
||||
isKeyHeldRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listeners to document for global coverage
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener("keyup", handleKeyUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener("keyup", handleKeyUp);
|
||||
|
||||
// Clean up any pending timeouts
|
||||
if (holdTimeoutRef.current) {
|
||||
clearTimeout(holdTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [getTopModal, hideModal]);
|
||||
}, [getTopModal, hideModal, showKeyboardCommands, hideKeyboardCommands]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
|
||||
import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta";
|
||||
import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb";
|
||||
import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsModal";
|
||||
import { NotificationModal } from "@/components/overlays/notificationsModal";
|
||||
import { useGlobalKeyboardEvents } from "@/hooks/useGlobalKeyboardEvents";
|
||||
import { useOnlineListener } from "@/hooks/usePing";
|
||||
|
|
@ -121,6 +122,7 @@ function App() {
|
|||
<Layout>
|
||||
<LanguageProvider />
|
||||
<NotificationModal id="notifications" />
|
||||
<KeyboardCommandsModal id="keyboard-commands" />
|
||||
{!showDowntime && (
|
||||
<Routes>
|
||||
{/* functional routes */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue