add keyboard shortcut modal

This commit is contained in:
Pas 2025-10-24 19:59:04 -06:00
parent 99d3e11b80
commit 3a31d172b1
4 changed files with 292 additions and 4 deletions

View file

@ -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": {

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

View file

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

View file

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