From ad0d93714ae355fb7d2fb63aedffb467c6d46930 Mon Sep 17 00:00:00 2001 From: Roland Date: Mon, 23 Feb 2026 17:29:09 -0700 Subject: [PATCH] Adding videos page hotkeys to help play and navigate videos. Adding f hotkey for all local search fields (channel/domain/etc). Focusing when opening cookie modals. --- app/src/DashboardPage.js | 10 +++ app/src/components/Archive.js | 19 ++++++ app/src/components/Channels.js | 10 +++ app/src/components/Common.js | 6 +- app/src/components/HelpModal.js | 6 +- .../components/KeyboardShortcutsProvider.js | 10 +++ app/src/components/VideoPlayer.js | 61 +++++++++++++++++++ app/src/components/Videos.js | 58 +++++++++++++++--- 8 files changed, 166 insertions(+), 14 deletions(-) diff --git a/app/src/DashboardPage.js b/app/src/DashboardPage.js index d23e9afb..b8acc7a5 100644 --- a/app/src/DashboardPage.js +++ b/app/src/DashboardPage.js @@ -8,6 +8,7 @@ import { useTitle } from "./components/Common"; import React, {useContext, useState} from "react"; +import {useHotkeys} from "react-hotkeys-hook"; import {Media, SettingsContext, StatusContext} from "./contexts/contexts"; import {DownloadMenu} from "./components/Download"; import { @@ -308,6 +309,14 @@ function DashboardRecentFiles() { export function DashboardPage() { const navigate = useNavigate(); + const searchInputRef = React.useRef(); + + useHotkeys('f', (e) => { + e.preventDefault(); + if (searchInputRef.current) { + searchInputRef.current.focus(); + } + }, {enableOnFormTags: false}); // The search the user submitted. const {searchStr, setSearchStr, activeTags, isEmpty, anyTag} = useSearch(); @@ -384,6 +393,7 @@ export function DashboardPage() { handleResultSelect={handleResultSelect} resultRenderer={resultRenderer} loading={loading} + inputRef={searchInputRef} {...props} />; }; diff --git a/app/src/components/Archive.js b/app/src/components/Archive.js index fa82a7c7..86a51a6e 100644 --- a/app/src/components/Archive.js +++ b/app/src/components/Archive.js @@ -1,4 +1,5 @@ import React, {useContext, useState} from "react"; +import {useHotkeys} from "react-hotkeys-hook"; import { CardContent, CardDescription, @@ -453,6 +454,14 @@ export function DomainsPage() { const [domains] = useDomains(); const [searchStr, setSearchStr] = useOneQuery('domain'); + const searchInputRef = React.useRef(); + + useHotkeys('f', (e) => { + e.preventDefault(); + if (searchInputRef.current) { + searchInputRef.current.focus(); + } + }, {enableOnFormTags: false}); // Header section matching ChannelsPage pattern const header =
@@ -467,6 +476,7 @@ export function DomainsPage() { onClear={() => setSearchStr('')} onChange={setSearchStr} onSubmit={null} + inputRef={searchInputRef} /> @@ -1045,6 +1055,14 @@ function ArchiveSettingsPage() { function ArchivesPage() { const [selectedArchives, setSelectedArchives] = useState([]); + const searchInputRef = React.useRef(); + + useHotkeys('f', (e) => { + e.preventDefault(); + if (searchInputRef.current) { + searchInputRef.current.focus(); + } + }, {enableOnFormTags: false}); const {domain, domains} = useSearchDomain(); @@ -1150,6 +1168,7 @@ function ArchivesPage() { searchStr={localSearchStr} onSubmit={setSearchStr} placeholder='Search Archives...' + inputRef={searchInputRef} />; // Domain header with edit link when filtering by domain diff --git a/app/src/components/Channels.js b/app/src/components/Channels.js index aa67a33a..5774f00a 100644 --- a/app/src/components/Channels.js +++ b/app/src/components/Channels.js @@ -1,4 +1,5 @@ import React, {useState} from "react"; +import {useHotkeys} from "react-hotkeys-hook"; import {Grid, StatisticLabel, StatisticValue,} from "semantic-ui-react"; import {createChannel, deleteChannel, refreshChannel, tagChannel, tagChannelInfo} from "../api"; import {CollectionTagModal} from "./collections/CollectionTagModal"; @@ -523,6 +524,14 @@ export function ChannelsPage() { const [channels] = useChannels(); const [searchStr, setSearchStr] = useOneQuery('name'); + const searchInputRef = React.useRef(); + + useHotkeys('f', (e) => { + e.preventDefault(); + if (searchInputRef.current) { + searchInputRef.current.focus(); + } + }, {enableOnFormTags: false}); // Header section matching DomainsPage pattern const header =
@@ -537,6 +546,7 @@ export function ChannelsPage() { onClear={() => setSearchStr('')} onChange={setSearchStr} onSubmit={null} + inputRef={searchInputRef} /> diff --git a/app/src/components/Common.js b/app/src/components/Common.js index ab0d91d1..0f33deff 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -421,7 +421,7 @@ export function scrollToTopOfElement(element, smooth = true) { }); } -const useClearableInput = (searchStr, onChange, onClear, onSubmit, size = 'small', placeholder = 'Search...', icon = 'search', clearDisabled = null) => { +const useClearableInput = (searchStr, onChange, onClear, onSubmit, size = 'small', placeholder = 'Search...', icon = 'search', clearDisabled = null, inputRef = null) => { const [value, setValue] = useState(searchStr || ''); const [submitted, setSubmitted] = useState(false); @@ -497,6 +497,7 @@ const useClearableInput = (searchStr, onChange, onClear, onSubmit, size = 'small size={size} className='search-input' action={action} + ref={inputRef} />; return {value, submitted, clearButton, input, localOnSubmit, handleChange} @@ -510,10 +511,11 @@ export function SearchInput({ size = 'small', placeholder = 'Search...', icon = 'search', + inputRef = null, ...props }) { // A Semantic with a Clear button as the action. - let {input, localOnSubmit} = useClearableInput(searchStr, onChange, onClear, onSubmit, size, placeholder, icon); + let {input, localOnSubmit} = useClearableInput(searchStr, onChange, onClear, onSubmit, size, placeholder, icon, null, inputRef); return
{input} diff --git a/app/src/components/HelpModal.js b/app/src/components/HelpModal.js index e48f1803..c153c641 100644 --- a/app/src/components/HelpModal.js +++ b/app/src/components/HelpModal.js @@ -65,7 +65,7 @@ function ShortcutKeys({keys, isMac}) { case 'escape': return 'Esc'; default: - return part.toUpperCase(); + return part.toLowerCase(); } }); @@ -78,7 +78,7 @@ function ShortcutKeys({keys, isMac}) { {idx > 0 && ' / '} {seqParts.map((p, i) => ( - {p.toUpperCase()} + {p.toLowerCase()} {i < seqParts.length - 1 && ' then '} ))} @@ -121,7 +121,7 @@ export default function HelpModal({open, onClose}) { const groupedShortcuts = groupShortcutsByCategory(SHORTCUTS); // Order categories - const categoryOrder = ['Search', 'General', 'Help', 'Navigation']; + const categoryOrder = ['Search', 'General', 'Help', 'Navigation', 'Page Search', 'Video Player']; const orderedCategories = categoryOrder.filter(cat => groupedShortcuts[cat]); return ( diff --git a/app/src/components/KeyboardShortcutsProvider.js b/app/src/components/KeyboardShortcutsProvider.js index baa51a5c..b80867d9 100644 --- a/app/src/components/KeyboardShortcutsProvider.js +++ b/app/src/components/KeyboardShortcutsProvider.js @@ -24,6 +24,16 @@ export const SHORTCUTS = [ {keys: 'g f', description: 'Go to Files', category: 'Navigation', path: '/files'}, {keys: 'g s', description: 'Go to Settings', category: 'Navigation', path: '/admin'}, {keys: 'g i', description: 'Go to Inventory', category: 'Navigation', path: '/inventory'}, + + // Page Search + {keys: 'f', description: 'Focus search input', category: 'Page Search'}, + + // Video Player + {keys: 'k', description: 'Play/pause video', category: 'Video Player'}, + {keys: 'j', description: 'Jump back 15 seconds', category: 'Video Player'}, + {keys: 'l', description: 'Jump forward 30 seconds', category: 'Video Player'}, + {keys: 'n', description: 'Navigate to newer video', category: 'Video Player'}, + {keys: 'p', description: 'Navigate to older video', category: 'Video Player'}, ]; function SearchModal({open, onClose}) { diff --git a/app/src/components/VideoPlayer.js b/app/src/components/VideoPlayer.js index 05593ca3..525cf2c1 100644 --- a/app/src/components/VideoPlayer.js +++ b/app/src/components/VideoPlayer.js @@ -1,4 +1,5 @@ import React, {useContext} from 'react'; +import {useHotkeys} from 'react-hotkeys-hook'; import {deleteVideos, downloadVideoMetadata, tagFileGroup, untagFileGroup} from "../api"; import {Link, useNavigate, useParams} from "react-router"; import _ from "lodash"; @@ -172,6 +173,21 @@ const Comments = ({comments, video}) => { } +// Generate a video page URL for a file object. +function getVideoLink(file) { + if (!file || !file.video) return null; + const {video} = file; + const channel = video.channel ? video.channel : null; + + if (file.slug) { + return `/videos/video/${file.slug}`; + } else if (channel) { + return `/videos/channel/${channel.id}/video/${video.id}`; + } else { + return `/videos/video/${video.id}`; + } +} + function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { const {theme} = useContext(ThemeContext); @@ -194,6 +210,51 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { const {channel} = useChannel(video && video.channel_id ? video.channel_id : channelId); const {comments, captions} = useVideoExtras(videoFile?.video?.id); + // Keyboard shortcuts for video player controls + // k: Play/pause + useHotkeys('k', () => { + if (videoRef.current) { + if (videoRef.current.paused) { + videoRef.current.play(); + } else { + videoRef.current.pause(); + } + } + }, {enableOnFormTags: false}); + + // j: Jump back 15 seconds + useHotkeys('j', () => { + if (videoRef.current) { + videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime - 15); + } + }, {enableOnFormTags: false}); + + // l: Jump forward 30 seconds + useHotkeys('l', () => { + if (videoRef.current) { + videoRef.current.currentTime = Math.min( + videoRef.current.duration || Infinity, + videoRef.current.currentTime + 30 + ); + } + }, {enableOnFormTags: false}); + + // n: Navigate to newer video + useHotkeys('n', () => { + const link = getVideoLink(nextFile); + if (link) { + navigate(link); + } + }, {enableOnFormTags: false}, [nextFile, navigate]); + + // p: Navigate to older video + useHotkeys('p', () => { + const link = getVideoLink(prevFile); + if (link) { + navigate(link); + } + }, {enableOnFormTags: false}, [prevFile, navigate]); + if (videoFile === null) { return ; } diff --git a/app/src/components/Videos.js b/app/src/components/Videos.js index 70f758aa..e0cb2316 100644 --- a/app/src/components/Videos.js +++ b/app/src/components/Videos.js @@ -1,5 +1,6 @@ -import React, {useContext, useEffect, useState} from 'react'; +import React, {useContext, useEffect, useRef, useState} from 'react'; import {Link, Route, Routes, useParams} from "react-router"; +import {useHotkeys} from "react-hotkeys-hook"; import { APIButton, CardLink, @@ -33,7 +34,8 @@ import { Form, FormDropdown, FormInput, - FormTextArea, + FormField, + TextArea, Image, Label, PlaceholderHeader, @@ -84,6 +86,14 @@ function VideosPage() { const {channelId} = useParams(); const {searchParams} = React.useContext(QueryContext); const [selectedVideos, setSelectedVideos] = useState([]); + const searchInputRef = React.useRef(); + + useHotkeys('f', (e) => { + e.preventDefault(); + if (searchInputRef.current) { + searchInputRef.current.focus(); + } + }, {enableOnFormTags: false}); let searchOrder = defaultFileOrder; if (searchParams.get('order')) { @@ -206,6 +216,7 @@ function VideosPage() { onClear={() => setLocalSearchStr(null)} onSubmit={setSearchStr} placeholder='Search Videos...' + inputRef={searchInputRef} />; return <> @@ -543,6 +554,31 @@ function CookiesSettingsSection() { const [password, setPassword] = useState(''); const [unlockPassword, setUnlockPassword] = useState(''); const [submitting, setSubmitting] = useState(false); + const unlockPasswordRef = useRef(null); + const cookiesContentRef = useRef(null); + + useEffect(() => { + if (uploadModalOpen) { + const timer = setTimeout(() => { + if (cookiesContentRef.current) { + cookiesContentRef.current.focus(); + } + }, 100); + return () => clearTimeout(timer); + } + }, [uploadModalOpen]); + + useEffect(() => { + if (unlockModalOpen) { + // Delay focus to allow modal animation to complete + const timer = setTimeout(() => { + if (unlockPasswordRef.current) { + unlockPasswordRef.current.focus(); + } + }, 100); + return () => clearTimeout(timer); + } + }, [unlockModalOpen]); const fetchStatus = async () => { setLoading(true); @@ -693,13 +729,16 @@ function CookiesSettingsSection() { Upload Cookies - setCookiesContent(e.target.value)} - rows={10} - /> + + +