diff --git a/.eslintrc.cjs b/.eslintrc.cjs index b06fb70c9..2ce8f5ae1 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -7,6 +7,7 @@ const a11yOff = Object.keys(require("eslint-plugin-jsx-a11y").rules).reduce( ); module.exports = { + root: true, env: { browser: true, }, diff --git a/.github/workflows/build_deploy.yml b/.github/workflows/build_deploy.yml new file mode 100644 index 000000000..007626f64 --- /dev/null +++ b/.github/workflows/build_deploy.yml @@ -0,0 +1,46 @@ +name: Deploy Website +description: "hope this works now, im building on github actions on the remote machines then deploying on my home server" +# I should put my ssh private key here in plaintext instead? Seems like the smart thing to do + + +on: + push: + branches: + - production + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install Pnpm + uses: pnpm/action-setup@v3 + with: + version: 8 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + + - name: Install Dependencies + run: pnpm install + + - name: Build Project + run: pnpm build + + - name: Deploy to server + uses: easingthemes/ssh-deploy@main + with: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + ARGS: "-rlgoDzvc --delete" + SOURCE: "dist/" + REMOTE_HOST: ${{ secrets.REMOTE_HOST }} + REMOTE_PORT: ${{ secrets.REMOTE_PORT }} + REMOTE_USER: ${{ secrets.REMOTE_USER }} + TARGET: "/var/www/html/" + EXCLUDE: "/node_modules/" \ No newline at end of file diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index e66911513..5a7c8c77d 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -1392,8 +1392,11 @@ "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.", + "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", + "spatialNavigation": "Spatial Navigation", + "spatialNavigationDescription": "Enable arrow key/remote navigation", + "spatialNavigationLabel": "Enable navigation", "keyboardShortcuts": "Keyboard Shortcuts", "keyboardShortcutsDescription": "Customize the keyboard shortcuts for the application. Hold ` to show this help anytime", "keyboardShortcutsLabel": "Customize Keyboard Shortcuts", diff --git a/src/components/buttons/Button.tsx b/src/components/buttons/Button.tsx index 2bf80dd8d..189a67aa3 100644 --- a/src/components/buttons/Button.tsx +++ b/src/components/buttons/Button.tsx @@ -99,6 +99,7 @@ export function Button(props: Props) { rel="noreferrer" download={props.download} onClick={cb} + data-focusable="true" > {content} @@ -106,13 +107,18 @@ export function Button(props: Props) { if (props.href) return ( - + {content} ); return ( - ); @@ -143,7 +149,12 @@ export function ButtonPlain(props: ButtonPlainProps) { ); return ( - ); diff --git a/src/hooks/useGlobalKeyboardEvents.ts b/src/hooks/useGlobalKeyboardEvents.ts index 6ca70cfab..2e22f43ec 100644 --- a/src/hooks/useGlobalKeyboardEvents.ts +++ b/src/hooks/useGlobalKeyboardEvents.ts @@ -4,9 +4,9 @@ import { useOverlayStack } from "@/stores/interface/overlayStack"; /** * Global keyboard event handler that works across the entire application. - * Handles Escape key to close modals and other global shortcuts. + * Handles Escape key to close modals, navigation keys, and other global shortcuts. */ -export function useGlobalKeyboardEvents() { +export function useGlobalKeyboardEvents(onAction?: (action: string) => void) { const { getTopModal, hideModal, showModal } = useOverlayStack(); const holdTimeoutRef = useRef | undefined>(); const isKeyHeldRef = useRef(false); @@ -21,10 +21,22 @@ export function useGlobalKeyboardEvents() { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { + const isNavigationKey = + event.key === "ArrowUp" || + event.key === "ArrowDown" || + event.key === "ArrowLeft" || + event.key === "ArrowRight" || + event.key === "Enter" || + event.key === "Backspace" || + event.key === "Escape"; + // Don't handle keyboard events if user is typing in an input if ( event.target && - (event.target as HTMLInputElement).nodeName === "INPUT" + ((event.target as HTMLElement).nodeName === "INPUT" || + (event.target as HTMLElement).nodeName === "TEXTAREA" || + (event.target as HTMLElement).contentEditable === "true") && + !isNavigationKey ) { return; } @@ -52,6 +64,40 @@ export function useGlobalKeyboardEvents() { const topModal = getTopModal(); if (topModal) { hideModal(topModal); + } else if (onAction) { + onAction("back"); + } + } + + // Handle navigation and action keys + if (onAction) { + switch (event.key) { + case "ArrowUp": + event.preventDefault(); + onAction("navigate-up"); + break; + case "ArrowDown": + event.preventDefault(); + onAction("navigate-down"); + break; + case "ArrowLeft": + event.preventDefault(); + onAction("navigate-left"); + break; + case "ArrowRight": + event.preventDefault(); + onAction("navigate-right"); + break; + case "Enter": + event.preventDefault(); + onAction("confirm"); + break; + case "Backspace": + event.preventDefault(); + onAction("back"); + break; + default: + break; } } }; @@ -86,5 +132,11 @@ export function useGlobalKeyboardEvents() { clearTimeout(holdTimeoutRef.current); } }; - }, [getTopModal, hideModal, showKeyboardCommands, hideKeyboardCommands]); + }, [ + getTopModal, + hideModal, + showKeyboardCommands, + hideKeyboardCommands, + onAction, + ]); } diff --git a/src/hooks/useProviderScrape.tsx b/src/hooks/useProviderScrape.tsx index e48786eb3..c58c96b71 100644 --- a/src/hooks/useProviderScrape.tsx +++ b/src/hooks/useProviderScrape.tsx @@ -1,4 +1,8 @@ -import { FullScraperEvents, RunOutput, ScrapeMedia } from "@jenish094/providers"; +import { + FullScraperEvents, + RunOutput, + ScrapeMedia, +} from "@jenish094/providers"; import { RefObject, useCallback, useEffect, useRef, useState } from "react"; import { isExtensionActiveCached } from "@/backend/extension/messaging"; diff --git a/src/hooks/useSpatialNavigation.ts b/src/hooks/useSpatialNavigation.ts new file mode 100644 index 000000000..c06d20961 --- /dev/null +++ b/src/hooks/useSpatialNavigation.ts @@ -0,0 +1,272 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +interface FocusableElement { + element: HTMLElement; + x: number; + y: number; +} + +interface Rect { + left: number; + top: number; + width: number; + height: number; +} + +const FOCUSABLE_SELECTOR = + '[data-focusable="true"], button:not([disabled]), a[href], [role="button"], [tabindex]:not([tabindex="-1"]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled])'; + +export function useSpatialNavigation() { + const focusableElementsRef = useRef([]); + const currentFocusRef = useRef(null); + const [currentRect, setCurrentRect] = useState(null); + + const getRect = useCallback((element: HTMLElement): Rect => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + }, []); + + const setFocusedElement = useCallback( + (element: HTMLElement | null) => { + if (!element) { + currentFocusRef.current = null; + setCurrentRect(null); + return; + } + + currentFocusRef.current = element; + element.focus({ preventScroll: true }); + setCurrentRect(getRect(element)); + }, + [getRect], + ); + + const updateFocusableElements = useCallback(() => { + const elements = document.querySelectorAll(FOCUSABLE_SELECTOR); + const focusables: FocusableElement[] = []; + + elements.forEach((el) => { + const element = el as HTMLElement; + const rect = el.getBoundingClientRect(); + const style = window.getComputedStyle(element); + + if ( + rect.width <= 0 || + rect.height <= 0 || + style.visibility === "hidden" || + style.display === "none" || + element.getAttribute("aria-hidden") === "true" + ) { + return; + } + + focusables.push({ + element, + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }); + }); + + focusableElementsRef.current = focusables; + }, []); + + const findClosestElement = useCallback< + ( + direction: "up" | "down" | "left" | "right", + currentX: number, + currentY: number, + ) => FocusableElement | null + >( + ( + direction: "up" | "down" | "left" | "right", + currentX: number, + currentY: number, + ) => { + const elements = focusableElementsRef.current; + let closest: FocusableElement | null = null; + let minDistance = Infinity; + + elements.forEach((item) => { + if (item.element === currentFocusRef.current) return; + + let valid = false; + let distance = 0; + + switch (direction) { + case "up": + if (item.y < currentY) { + valid = true; + distance = Math.abs(item.x - currentX) + (currentY - item.y); + } + break; + case "down": + if (item.y > currentY) { + valid = true; + distance = Math.abs(item.x - currentX) + (item.y - currentY); + } + break; + case "left": + if (item.x < currentX) { + valid = true; + distance = Math.abs(item.y - currentY) + (currentX - item.x); + } + break; + case "right": + if (item.x > currentX) { + valid = true; + distance = Math.abs(item.y - currentY) + (item.x - currentX); + } + break; + default: + valid = false; + break; + } + + if (valid && distance < minDistance) { + minDistance = distance; + closest = item; + } + }); + + return closest; + }, + [], + ); + + const resetNavigation = useCallback(() => { + currentFocusRef.current = null; + setCurrentRect(null); + }, []); + + const navigate = useCallback( + (direction: "up" | "down" | "left" | "right") => { + updateFocusableElements(); + + const elements = focusableElementsRef.current; + if (elements.length === 0) { + resetNavigation(); + return; + } + + const currentActive = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + + const current = + currentFocusRef.current && + elements.some((entry) => entry.element === currentFocusRef.current) + ? currentFocusRef.current + : currentActive && + elements.some((entry) => entry.element === currentActive) + ? currentActive + : null; + + if (!current) { + setFocusedElement(elements[0].element); + return; + } + + const rect = current.getBoundingClientRect(); + const currentX = rect.left + rect.width / 2; + const currentY = rect.top + rect.height / 2; + + const next: FocusableElement | null = findClosestElement( + direction, + currentX, + currentY, + ); + if (next) { + setFocusedElement(next.element); + } else { + setFocusedElement(current); + } + }, + [ + findClosestElement, + resetNavigation, + setFocusedElement, + updateFocusableElements, + ], + ); + + const handleAction = useCallback( + (action: string) => { + switch (action) { + case "navigate-up": + navigate("up"); + break; + case "navigate-down": + navigate("down"); + break; + case "navigate-left": + navigate("left"); + break; + case "navigate-right": + navigate("right"); + break; + case "confirm": + if (document.activeElement) { + (document.activeElement as HTMLElement).click(); + } + break; + case "back": + // Handle back navigation + window.history.back(); + break; + default: + break; + } + }, + [navigate], + ); + + useEffect(() => { + updateFocusableElements(); + + const handleResize = () => { + updateFocusableElements(); + if ( + currentFocusRef.current && + currentFocusRef.current.isConnected && + document.contains(currentFocusRef.current) + ) { + setCurrentRect(getRect(currentFocusRef.current)); + } else { + resetNavigation(); + } + }; + + const handleFocusIn = (event: FocusEvent) => { + const target = event.target as HTMLElement | null; + if (!target) return; + + if (target.matches(FOCUSABLE_SELECTOR)) { + currentFocusRef.current = target; + setCurrentRect(getRect(target)); + } + }; + + window.addEventListener("resize", handleResize); + window.addEventListener("scroll", handleResize, true); + document.addEventListener("focusin", handleFocusIn); + + return () => { + window.removeEventListener("resize", handleResize); + window.removeEventListener("scroll", handleResize, true); + document.removeEventListener("focusin", handleFocusIn); + }; + }, [getRect, resetNavigation, updateFocusableElements]); + + return { + handleAction, + updateFocusableElements, + currentRect, + resetNavigation, + }; +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index ecd3cb19c..33aa2b8f9 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -540,6 +540,12 @@ export function SettingsPage() { const setEnablePauseOverlay = usePreferencesStore( (s) => s.setEnablePauseOverlay, ); + const enableSpatialNavigation = usePreferencesStore( + (s) => s.enableSpatialNavigation, + ); + const setEnableSpatialNavigation = usePreferencesStore( + (s) => s.setEnableSpatialNavigation, + ); const setEnableNumberKeySeeking = usePreferencesStore( (s) => s.setEnableNumberKeySeeking, ); @@ -1113,6 +1119,8 @@ export function SettingsPage() { setEnableAutoResumeOnPlaybackError={ state.enableAutoResumeOnPlaybackError.set } + enableSpatialNavigation={enableSpatialNavigation} + setEnableSpatialNavigation={setEnableSpatialNavigation} /> )} diff --git a/src/pages/parts/settings/PreferencesPart.tsx b/src/pages/parts/settings/PreferencesPart.tsx index 34a5224da..36fb48d3b 100644 --- a/src/pages/parts/settings/PreferencesPart.tsx +++ b/src/pages/parts/settings/PreferencesPart.tsx @@ -44,6 +44,8 @@ export function PreferencesPart(props: { setEnableDoubleClickToSeek: (v: boolean) => void; enableAutoResumeOnPlaybackError: boolean; setEnableAutoResumeOnPlaybackError: (v: boolean) => void; + enableSpatialNavigation: boolean; + setEnableSpatialNavigation: (v: boolean) => void; }) { const { t } = useTranslation(); const { showModal } = useOverlayStack(); @@ -90,6 +92,10 @@ export function PreferencesPart(props: { props.setEnableLowPerformanceMode(!props.enableLowPerformanceMode); }; + const handleSpatialNavigationToggle = () => { + props.setEnableSpatialNavigation(!props.enableSpatialNavigation); + }; + return (
{t("settings.preferences.title")} @@ -276,6 +282,25 @@ export function PreferencesPart(props: {
+ {/* Navigation Thing Settings */} +
+

+ {t("settings.preferences.spatialNavigation")} +

+

+ {t("settings.preferences.spatialNavigationDescription")} +

+
+ +

+ {t("settings.preferences.spatialNavigationLabel")} +

+
+
+ {/* Keyboard Shortcuts Preference */}

diff --git a/src/setup/App.tsx b/src/setup/App.tsx index aa3cd373b..de8a79fd1 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -18,8 +18,10 @@ import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsMod import { NotificationModal } from "@/components/overlays/notificationsModal"; import { SupportInfoModal } from "@/components/overlays/SupportInfoModal"; import { TraktAuthHandler } from "@/components/TraktAuthHandler"; +import { useGamepadPolling } from "@/hooks/useGamepad"; import { useGlobalKeyboardEvents } from "@/hooks/useGlobalKeyboardEvents"; import { useOnlineListener } from "@/hooks/usePing"; +import { useSpatialNavigation } from "@/hooks/useSpatialNavigation"; import { AboutPage } from "@/pages/About"; import { AdminPage } from "@/pages/admin/AdminPage"; import { AllBookmarks } from "@/pages/bookmarks/AllBookmarks"; @@ -108,13 +110,23 @@ function QueryView() { export const maintenanceTime = "March 31th 11:00 PM - 5:00 AM EST"; function App() { + const location = useLocation(); useHistoryListener(); useOnlineListener(); - useGlobalKeyboardEvents(); useClearModalsOnNavigation(); const maintenance = false; // Shows maintance page const [showDowntime, setShowDowntime] = useState(maintenance); + const { + handleAction, + currentRect, + resetNavigation, + updateFocusableElements, + } = useSpatialNavigation(); + + useGlobalKeyboardEvents(handleAction); + useGamepadPolling({ onAction: handleAction, enabled: true }); + const handleButtonClick = () => { setShowDowntime(false); }; @@ -127,6 +139,13 @@ function App() { } }, [setShowDowntime, maintenance]); + useEffect(() => { + resetNavigation(); + requestAnimationFrame(() => { + updateFocusableElements(); + }); + }, [location.pathname, resetNavigation, updateFocusableElements]); + return ( @@ -231,6 +250,17 @@ function App() { } /> )} + {currentRect && ( +

+ )} {showDowntime && ( )} diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index b3c9b566a..edc311591 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -431,9 +431,8 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ }); try { - const { scrapeExternalSubtitles } = await import( - "@/utils/externalSubtitles" - ); + const { scrapeExternalSubtitles } = + await import("@/utils/externalSubtitles"); const externalCaptions = await scrapeExternalSubtitles(store.meta); if (externalCaptions.length > 0) { diff --git a/src/stores/preferences/index.tsx b/src/stores/preferences/index.tsx index 1c744df18..3923df261 100644 --- a/src/stores/preferences/index.tsx +++ b/src/stores/preferences/index.tsx @@ -41,6 +41,7 @@ export interface PreferencesStore { enableNumberKeySeeking: boolean; enablePauseOverlay: boolean; enableGamepadControls: boolean; + enableSpatialNavigation: boolean; gamepadMapping: Record; keyboardShortcuts: KeyboardShortcuts; @@ -77,6 +78,7 @@ export interface PreferencesStore { setEnableNumberKeySeeking(v: boolean): void; setEnablePauseOverlay(v: boolean): void; setEnableGamepadControls(v: boolean): void; + setEnableSpatialNavigation(v: boolean): void; setGamepadMapping(v: Record): void; setKeyboardShortcuts(v: KeyboardShortcuts): void; } @@ -117,6 +119,7 @@ export const usePreferencesStore = create( enableNumberKeySeeking: true, enablePauseOverlay: false, enableGamepadControls: false, + enableSpatialNavigation: true, gamepadMapping: {}, keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, setEnableThumbnails(v) { @@ -289,6 +292,11 @@ export const usePreferencesStore = create( s.enableGamepadControls = v; }); }, + setEnableSpatialNavigation(v) { + set((s) => { + s.enableSpatialNavigation = v; + }); + }, setGamepadMapping(v) { set((s) => { s.gamepadMapping = v; diff --git a/src/utils/autoplay.ts b/src/utils/autoplay.ts index aee01ffbc..99f18cab1 100644 --- a/src/utils/autoplay.ts +++ b/src/utils/autoplay.ts @@ -5,7 +5,7 @@ import { useAuthStore } from "@/stores/auth"; export function isAutoplayAllowed() { return Boolean( conf().ALLOW_AUTOPLAY || - isExtensionActiveCached() || - useAuthStore.getState().proxySet, + isExtensionActiveCached() || + useAuthStore.getState().proxySet, ); }