From 1018d70a55f39df6d48825052a19d2f76d814150 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:19:23 +0100 Subject: [PATCH 01/72] Update PauseOverlay.tsx --- .../player/overlays/PauseOverlay.tsx | 195 ++++++++---------- 1 file changed, 90 insertions(+), 105 deletions(-) diff --git a/src/components/player/overlays/PauseOverlay.tsx b/src/components/player/overlays/PauseOverlay.tsx index d3f6b54b7..272598b2a 100644 --- a/src/components/player/overlays/PauseOverlay.tsx +++ b/src/components/player/overlays/PauseOverlay.tsx @@ -14,21 +14,20 @@ import { playerStatus } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; import { usePreferencesStore } from "@/stores/preferences"; import { durationExceedsHour, formatSeconds } from "@/utils/formatSeconds"; -import { uses12HourClock } from "@/utils/uses12HourClock"; interface PauseDetails { voteAverage: number | null; genres: string[]; + runtime: number | null; } export function PauseOverlay() { - const isIdle = useIdle(5e3); // 5 seconds + const isIdle = useIdle(10e3); // 10 seconds const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused); const status = usePlayerStore((s) => s.status); const meta = usePlayerStore((s) => s.meta); const { time, duration, draggingTime } = usePlayerStore((s) => s.progress); const { isSeeking } = usePlayerStore((s) => s.interface); - const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate); const enablePauseOverlay = usePreferencesStore((s) => s.enablePauseOverlay); const enableImageLogos = usePreferencesStore((s) => s.enableImageLogos); const { isMobile } = useIsMobile(); @@ -38,6 +37,7 @@ export function PauseOverlay() { const [details, setDetails] = useState({ voteAverage: null, genres: [], + runtime: null, }); let shouldShow = isPaused && isIdle && enablePauseOverlay; @@ -72,14 +72,13 @@ export function PauseOverlay() { let mounted = true; const fetchDetails = async () => { if (!meta?.tmdbId) { - setDetails({ voteAverage: null, genres: [] }); + setDetails({ voteAverage: null, genres: [], runtime: null }); return; } try { const type = meta.type === "movie" ? TMDBContentTypes.MOVIE : TMDBContentTypes.TV; - // For shows with episode, fetch episode-specific rating const isShowWithEpisode = meta.type === "show" && meta.season && meta.episode; let voteAverage: number | null = null; @@ -100,16 +99,29 @@ export function PauseOverlay() { const genres = (data.genres ?? []).map( (g: { name: string }) => g.name, ); - // Use episode rating for shows (never fall back to show rating) const finalVoteAverage = isShowWithEpisode ? voteAverage : typeof data.vote_average === "number" ? data.vote_average : null; - setDetails({ voteAverage: finalVoteAverage, genres }); + + // Get runtime + let runtime: number | null = null; + if (isShowWithEpisode) { + const episodeData = await getEpisodeDetails( + meta.tmdbId, + meta.season?.number ?? 0, + meta.episode?.number ?? 0, + ); + runtime = episodeData?.runtime ?? null; + } else { + runtime = (data as any).runtime ?? null; + } + + setDetails({ voteAverage: finalVoteAverage, genres, runtime }); } } catch { - if (mounted) setDetails({ voteAverage: null, genres: [] }); + if (mounted) setDetails({ voteAverage: null, genres: [], runtime: null }); } }; @@ -124,120 +136,93 @@ export function PauseOverlay() { const overview = meta.type === "show" ? meta.episode?.overview : meta.overview; - const hasHours = durationExceedsHour(duration); - const currentTime = Math.min( - Math.max(isSeeking ? draggingTime : time, 0), - duration, - ); - const secondsRemaining = Math.abs(currentTime - duration); - const secondsRemainingAdjusted = - playbackRate > 0 ? secondsRemaining / playbackRate : secondsRemaining; - - const timeLeft = formatSeconds( - secondsRemaining, - durationExceedsHour(secondsRemaining), - ); - const timeWatched = formatSeconds(currentTime, hasHours); - const timeFinished = new Date(Date.now() + secondsRemainingAdjusted * 1e3); - const durationFormatted = formatSeconds(duration, hasHours); - - const localizationKey = "remaining"; - - // Don't render anything if we don't have content, but keep structure for fade if valid - const hasDetails = details.voteAverage !== null || details.genres.length > 0; - const hasContent = overview || logoUrl || meta.title || hasDetails; - if (!hasContent) return null; + const formatRuntime = (minutes: number) => { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return h > 0 ? `${h}h ${m}m` : `${m}m`; + }; return (
-
- {logoUrl ? ( - {meta.title} - ) : ( -

- {meta.title} -

- )} - - {meta.type === "show" && meta.episode && ( -

- {meta.episode.title} -

- )} - - {(details.voteAverage !== null || - details.genres.length > 0 || - duration > 0) && ( -
- {details.voteAverage !== null && ( - - {details.voteAverage.toFixed(1)} - /10 - + {/* Main content - left aligned, vertically centered */} +
+
+ {/* "You are watching" label */} +

+ {t("player.pauseOverlay.youAreWatching", "You are watching")} +

+ + {/* Title / Logo */} + {logoUrl ? ( + {meta.title} + ) : ( +

+ {meta.title} +

+ )} + + {/* Season / Episode info */} + {meta.type === "show" && meta.season && meta.episode && ( +

+ Season {meta.season.number} · Episode {meta.episode.number} +

+ )} + + {/* Episode title */} + {meta.type === "show" && meta.episode && ( +

+ {meta.episode.title} +

+ )} + + {/* Description */} + {overview && ( +

+ {overview} +

+ )} + + {/* Rating + Runtime */} +
+ {details.voteAverage !== null && details.voteAverage > 0 && ( + <> + + {details.voteAverage.toFixed(0)} + )} - {details.genres.length > 0 && ( + {details.runtime && details.runtime > 0 && ( <> - {details.voteAverage !== null && ( - + {details.voteAverage !== null && details.voteAverage > 0 && ( + · )} - {details.genres.slice(0, 4).join(", ")} + {formatRuntime(details.runtime)} )} - {duration > 0 && ( + {duration > 0 && !details.runtime && ( <> - {(details.voteAverage !== null || - details.genres.length > 0) && ( - + {details.voteAverage !== null && details.voteAverage > 0 && ( + · )} - - {(() => { - const text = t(`player.time.${localizationKey}`, { - timeFinished, - timeWatched, - timeLeft, - duration: durationFormatted, - formatParams: { - timeFinished: { - hour: "numeric", - minute: "numeric", - hour12: uses12HourClock(), - }, - }, - }); - if ( - localizationKey === "remaining" && - text.includes(" • ") - ) { - const [left, right] = text.split(" • "); - return ( - <> - {left} - - {right} - - ); - } - return text; - })()} - + {formatRuntime(Math.round(duration / 60))} )}
- )} +
+
- {overview && ( -

- {overview} -

- )} + {/* "Paused" indicator - bottom right */} +
+ + {t("player.pauseOverlay.paused", "Paused")} +
); From ad46a7717a41ebcd3d40319e3cf077f992c95c25 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:20:23 +0100 Subject: [PATCH 02/72] Create GamepadEvents.tsx --- .../player/internals/GamepadEvents.tsx | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/components/player/internals/GamepadEvents.tsx diff --git a/src/components/player/internals/GamepadEvents.tsx b/src/components/player/internals/GamepadEvents.tsx new file mode 100644 index 000000000..544199c7c --- /dev/null +++ b/src/components/player/internals/GamepadEvents.tsx @@ -0,0 +1,76 @@ +import { useCallback } from "react"; + +import { useVolume } from "@/components/player/hooks/useVolume"; +import { useCaptions } from "@/components/player/hooks/useCaptions"; +import { usePlayerStore } from "@/stores/player/store"; +import { usePreferencesStore } from "@/stores/preferences"; +import { + useGamepadPolling, + DEFAULT_PLAYER_GAMEPAD_MAPPING, +} from "@/hooks/useGamepad"; + +export function GamepadEvents() { + const display = usePlayerStore((s) => s.display); + const mediaPlaying = usePlayerStore((s) => s.mediaPlaying); + const time = usePlayerStore((s) => s.progress.time); + const duration = usePlayerStore((s) => s.progress.duration); + const { setVolume, toggleMute } = useVolume(); + const { toggleLastUsed } = useCaptions(); + const enableGamepadControls = usePreferencesStore( + (s) => s.enableGamepadControls, + ); + + const handleAction = useCallback( + (action: string) => { + if (!display) return; + + switch (action) { + case "play-pause": + if (mediaPlaying.isPaused) display.play(); + else display.pause(); + break; + case "skip-forward": + display.setTime(Math.min(time + 10, duration)); + break; + case "skip-backward": + display.setTime(Math.max(time - 10, 0)); + break; + case "skip-forward-30": + display.setTime(Math.min(time + 30, duration)); + break; + case "skip-backward-30": + display.setTime(Math.max(time - 30, 0)); + break; + case "volume-up": + setVolume((mediaPlaying?.volume || 0) + 0.1); + break; + case "volume-down": + setVolume((mediaPlaying?.volume || 0) - 0.1); + break; + case "mute": + toggleMute(); + break; + case "toggle-fullscreen": + display.toggleFullscreen(); + break; + case "toggle-captions": + toggleLastUsed(); + break; + case "back": + // Navigate back + window.history.back(); + break; + default: + break; + } + }, + [display, mediaPlaying, time, duration, setVolume, toggleMute, toggleLastUsed], + ); + + useGamepadPolling({ + onAction: handleAction, + enabled: enableGamepadControls, + }); + + return null; +} From 5770d3c3d717ff7a3aab67dad2ca65733f4f9135 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:21:35 +0100 Subject: [PATCH 03/72] Create useGamepad.ts --- src/hooks/useGamepad.ts | 208 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 src/hooks/useGamepad.ts diff --git a/src/hooks/useGamepad.ts b/src/hooks/useGamepad.ts new file mode 100644 index 000000000..e64ae4033 --- /dev/null +++ b/src/hooks/useGamepad.ts @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useRef } from "react"; + +export interface GamepadMapping { + // D-pad + dpadUp: string; + dpadDown: string; + dpadLeft: string; + dpadRight: string; + // Face buttons (Xbox: A/B/X/Y, PS: Cross/Circle/Square/Triangle) + actionSouth: string; // A / Cross - confirm/play-pause + actionEast: string; // B / Circle - back + actionWest: string; // X / Square + actionNorth: string; // Y / Triangle + // Bumpers/triggers + leftBumper: string; + rightBumper: string; + leftTrigger: string; + rightTrigger: string; + // Special + start: string; + select: string; +} + +export const DEFAULT_GAMEPAD_MAPPING: GamepadMapping = { + dpadUp: "navigate-up", + dpadDown: "navigate-down", + dpadLeft: "navigate-left", + dpadRight: "navigate-right", + actionSouth: "confirm", + actionEast: "back", + actionWest: "toggle-fullscreen", + actionNorth: "toggle-captions", + leftBumper: "skip-backward", + rightBumper: "skip-forward", + leftTrigger: "volume-down", + rightTrigger: "volume-up", + start: "play-pause", + select: "mute", +}; + +export const DEFAULT_PLAYER_GAMEPAD_MAPPING: GamepadMapping = { + dpadUp: "volume-up", + dpadDown: "volume-down", + dpadLeft: "skip-backward", + dpadRight: "skip-forward", + actionSouth: "play-pause", + actionEast: "back", + actionWest: "toggle-fullscreen", + actionNorth: "toggle-captions", + leftBumper: "previous-episode", + rightBumper: "next-episode", + leftTrigger: "skip-backward-30", + rightTrigger: "skip-forward-30", + start: "play-pause", + select: "mute", +}; + +// Standard gamepad button indices +const BUTTON_MAP = { + ACTION_SOUTH: 0, // A / Cross + ACTION_EAST: 1, // B / Circle + ACTION_WEST: 2, // X / Square + ACTION_NORTH: 3, // Y / Triangle + LEFT_BUMPER: 4, + RIGHT_BUMPER: 5, + LEFT_TRIGGER: 6, + RIGHT_TRIGGER: 7, + SELECT: 8, + START: 9, + DPAD_UP: 12, + DPAD_DOWN: 13, + DPAD_LEFT: 14, + DPAD_RIGHT: 15, +}; + +interface GamepadCallbacks { + onAction: (action: string) => void; + enabled: boolean; +} + +export function useGamepadPolling({ onAction, enabled }: GamepadCallbacks) { + const prevButtonStates = useRef>({}); + const animFrameRef = useRef(null); + const onActionRef = useRef(onAction); + const enabledRef = useRef(enabled); + const mappingRef = useRef(DEFAULT_GAMEPAD_MAPPING); + + useEffect(() => { + onActionRef.current = onAction; + }, [onAction]); + + useEffect(() => { + enabledRef.current = enabled; + }, [enabled]); + + const setMapping = useCallback((mapping: GamepadMapping) => { + mappingRef.current = mapping; + }, []); + + useEffect(() => { + if (!enabled) return; + + const buttonToAction: Record = { + [BUTTON_MAP.ACTION_SOUTH]: "actionSouth", + [BUTTON_MAP.ACTION_EAST]: "actionEast", + [BUTTON_MAP.ACTION_WEST]: "actionWest", + [BUTTON_MAP.ACTION_NORTH]: "actionNorth", + [BUTTON_MAP.LEFT_BUMPER]: "leftBumper", + [BUTTON_MAP.RIGHT_BUMPER]: "rightBumper", + [BUTTON_MAP.LEFT_TRIGGER]: "leftTrigger", + [BUTTON_MAP.RIGHT_TRIGGER]: "rightTrigger", + [BUTTON_MAP.SELECT]: "select", + [BUTTON_MAP.START]: "start", + [BUTTON_MAP.DPAD_UP]: "dpadUp", + [BUTTON_MAP.DPAD_DOWN]: "dpadDown", + [BUTTON_MAP.DPAD_LEFT]: "dpadLeft", + [BUTTON_MAP.DPAD_RIGHT]: "dpadRight", + }; + + const poll = () => { + if (!enabledRef.current) return; + + const gamepads = navigator.getGamepads?.(); + if (!gamepads) { + animFrameRef.current = requestAnimationFrame(poll); + return; + } + + for (const gp of gamepads) { + if (!gp) continue; + + for (const [btnIdx, mappingKey] of Object.entries(buttonToAction)) { + const idx = Number(btnIdx); + const button = gp.buttons[idx]; + if (!button) continue; + + const isPressed = button.pressed || button.value > 0.5; + const wasPressed = prevButtonStates.current[idx] ?? false; + + // Only fire on button down (not held) + if (isPressed && !wasPressed) { + const action = mappingRef.current[mappingKey]; + if (action) { + onActionRef.current(action); + } + } + + prevButtonStates.current[idx] = isPressed; + } + + // Only process first connected gamepad + break; + } + + animFrameRef.current = requestAnimationFrame(poll); + }; + + animFrameRef.current = requestAnimationFrame(poll); + + return () => { + if (animFrameRef.current !== null) { + cancelAnimationFrame(animFrameRef.current); + } + }; + }, [enabled]); + + return { setMapping }; +} + +export const GAMEPAD_ACTION_LABELS: Record = { + "navigate-up": "Navigate Up", + "navigate-down": "Navigate Down", + "navigate-left": "Navigate Left", + "navigate-right": "Navigate Right", + confirm: "Confirm / Select", + back: "Go Back", + "play-pause": "Play / Pause", + "skip-forward": "Skip Forward (+10s)", + "skip-backward": "Skip Backward (-10s)", + "skip-forward-30": "Skip Forward (+30s)", + "skip-backward-30": "Skip Backward (-30s)", + "volume-up": "Volume Up", + "volume-down": "Volume Down", + mute: "Mute / Unmute", + "toggle-fullscreen": "Toggle Fullscreen", + "toggle-captions": "Toggle Captions", + "next-episode": "Next Episode", + "previous-episode": "Previous Episode", +}; + +export const GAMEPAD_BUTTON_LABELS: Record = { + dpadUp: { xbox: "D-Pad ↑", ps: "D-Pad ↑" }, + dpadDown: { xbox: "D-Pad ↓", ps: "D-Pad ↓" }, + dpadLeft: { xbox: "D-Pad ←", ps: "D-Pad ←" }, + dpadRight: { xbox: "D-Pad →", ps: "D-Pad →" }, + actionSouth: { xbox: "A", ps: "✕" }, + actionEast: { xbox: "B", ps: "○" }, + actionWest: { xbox: "X", ps: "□" }, + actionNorth: { xbox: "Y", ps: "△" }, + leftBumper: { xbox: "LB", ps: "L1" }, + rightBumper: { xbox: "RB", ps: "R1" }, + leftTrigger: { xbox: "LT", ps: "L2" }, + rightTrigger: { xbox: "RT", ps: "R2" }, + start: { xbox: "Menu", ps: "Options" }, + select: { xbox: "View", ps: "Share" }, +}; + +export const ALL_GAMEPAD_ACTIONS = Object.keys(GAMEPAD_ACTION_LABELS); From 5122938fd7f689800c76dd71b6f2c9ddc429751b Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:22:48 +0100 Subject: [PATCH 04/72] Update index.tsx --- src/stores/preferences/index.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/stores/preferences/index.tsx b/src/stores/preferences/index.tsx index eef74d5e0..1c744df18 100644 --- a/src/stores/preferences/index.tsx +++ b/src/stores/preferences/index.tsx @@ -40,6 +40,8 @@ export interface PreferencesStore { enableAutoResumeOnPlaybackError: boolean; enableNumberKeySeeking: boolean; enablePauseOverlay: boolean; + enableGamepadControls: boolean; + gamepadMapping: Record; keyboardShortcuts: KeyboardShortcuts; setEnableThumbnails(v: boolean): void; @@ -74,6 +76,8 @@ export interface PreferencesStore { setEnableAutoResumeOnPlaybackError(v: boolean): void; setEnableNumberKeySeeking(v: boolean): void; setEnablePauseOverlay(v: boolean): void; + setEnableGamepadControls(v: boolean): void; + setGamepadMapping(v: Record): void; setKeyboardShortcuts(v: KeyboardShortcuts): void; } @@ -112,6 +116,8 @@ export const usePreferencesStore = create( enableAutoResumeOnPlaybackError: true, enableNumberKeySeeking: true, enablePauseOverlay: false, + enableGamepadControls: false, + gamepadMapping: {}, keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, setEnableThumbnails(v) { set((s) => { @@ -278,6 +284,16 @@ export const usePreferencesStore = create( s.enablePauseOverlay = v; }); }, + setEnableGamepadControls(v) { + set((s) => { + s.enableGamepadControls = v; + }); + }, + setGamepadMapping(v) { + set((s) => { + s.gamepadMapping = v; + }); + }, setKeyboardShortcuts(v) { set((s) => { s.keyboardShortcuts = v; From 6a5b2ae7e988f5f9351134a2b759b3ee84c65009 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:24:05 +0100 Subject: [PATCH 05/72] Update PauseOverlay.tsx --- src/components/player/overlays/PauseOverlay.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/player/overlays/PauseOverlay.tsx b/src/components/player/overlays/PauseOverlay.tsx index 272598b2a..beb6e0c8a 100644 --- a/src/components/player/overlays/PauseOverlay.tsx +++ b/src/components/player/overlays/PauseOverlay.tsx @@ -108,14 +108,14 @@ export function PauseOverlay() { // Get runtime let runtime: number | null = null; if (isShowWithEpisode) { - const episodeData = await getEpisodeDetails( + const epData = await getEpisodeDetails( meta.tmdbId, meta.season?.number ?? 0, meta.episode?.number ?? 0, ); - runtime = episodeData?.runtime ?? null; + runtime = (epData as any)?.runtime ?? null; } else { - runtime = (data as any).runtime ?? null; + runtime = (data as any)?.runtime ?? null; } setDetails({ voteAverage: finalVoteAverage, genres, runtime }); From 9d4193e3e6967bd4c8a6670fb8478c20ea27cdd2 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:25:09 +0100 Subject: [PATCH 06/72] Create GamepadControlsModal.tsx --- .../overlays/GamepadControlsModal.tsx | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 src/components/overlays/GamepadControlsModal.tsx diff --git a/src/components/overlays/GamepadControlsModal.tsx b/src/components/overlays/GamepadControlsModal.tsx new file mode 100644 index 000000000..089774708 --- /dev/null +++ b/src/components/overlays/GamepadControlsModal.tsx @@ -0,0 +1,207 @@ +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/buttons/Button"; +import { Icon, Icons } from "@/components/Icon"; +import { Modal, ModalCard, useModal } from "@/components/overlays/Modal"; +import { Heading2 } from "@/components/utils/Text"; +import { + GamepadMapping, + DEFAULT_PLAYER_GAMEPAD_MAPPING, + GAMEPAD_ACTION_LABELS, + GAMEPAD_BUTTON_LABELS, + ALL_GAMEPAD_ACTIONS, +} from "@/hooks/useGamepad"; +import { usePreferencesStore } from "@/stores/preferences"; +import { Dropdown } from "@/components/form/Dropdown"; + +interface GamepadControlsModalProps { + id: string; +} + +type ControllerType = "xbox" | "ps"; + +function ButtonBadge({ + label, + hasConflict, +}: { + label: string; + hasConflict?: boolean; +}) { + return ( + + {label} + + ); +} + +export function GamepadControlsModal({ id }: GamepadControlsModalProps) { + const { t } = useTranslation(); + const modal = useModal(id); + const gamepadMapping = usePreferencesStore((s) => s.gamepadMapping); + const setGamepadMapping = usePreferencesStore((s) => s.setGamepadMapping); + + const defaultMapping = DEFAULT_PLAYER_GAMEPAD_MAPPING; + const currentMapping: GamepadMapping = { + ...defaultMapping, + ...gamepadMapping, + }; + + const [editingMapping, setEditingMapping] = + useState(currentMapping); + const [controllerType, setControllerType] = useState("xbox"); + + const actionOptions = ALL_GAMEPAD_ACTIONS.map((action) => ({ + id: action, + name: GAMEPAD_ACTION_LABELS[action] || action, + })); + + const handleSave = useCallback(() => { + setGamepadMapping(editingMapping as unknown as Record); + modal.hide(); + }, [editingMapping, setGamepadMapping, modal]); + + const handleCancel = useCallback(() => { + setEditingMapping(currentMapping); + modal.hide(); + }, [currentMapping, modal]); + + const handleResetAll = useCallback(() => { + setEditingMapping(DEFAULT_PLAYER_GAMEPAD_MAPPING); + }, []); + + const buttonGroups = [ + { + title: "D-Pad", + buttons: ["dpadUp", "dpadDown", "dpadLeft", "dpadRight"] as (keyof GamepadMapping)[], + }, + { + title: "Face Buttons", + buttons: ["actionSouth", "actionEast", "actionWest", "actionNorth"] as (keyof GamepadMapping)[], + }, + { + title: "Bumpers & Triggers", + buttons: ["leftBumper", "rightBumper", "leftTrigger", "rightTrigger"] as (keyof GamepadMapping)[], + }, + { + title: "System", + buttons: ["start", "select"] as (keyof GamepadMapping)[], + }, + ]; + + // Check for duplicate actions + const actionCounts: Record = {}; + Object.values(editingMapping).forEach((action) => { + actionCounts[action] = (actionCounts[action] || 0) + 1; + }); + + return ( + + +
+
+ + {t("settings.preferences.gamepadControls", "Controller Controls")} + +

+ {t( + "settings.preferences.gamepadControlsDescription", + "Configure your Xbox or PlayStation controller button mappings", + )} +

+
+ + {/* Controller type toggle */} +
+ + +
+ +
+ +
+ +
+ {buttonGroups.map((group) => ( +
+

+ {group.title} +

+
+ {group.buttons.map((btnKey) => { + const currentAction = editingMapping[btnKey]; + const hasDuplicate = (actionCounts[currentAction] || 0) > 1; + const label = GAMEPAD_BUTTON_LABELS[btnKey]; + + return ( +
+
+ +
+
+ opt.id === currentAction, + ) || actionOptions[0] + } + setSelectedItem={(item) => + setEditingMapping((prev) => ({ + ...prev, + [btnKey]: item.id, + })) + } + options={actionOptions} + className="w-full !my-0" + /> +
+
+ ); + })} +
+
+ ))} +
+ +
+ + +
+
+
+
+ ); +} From cb8ebb6fc59465589a1566862aa7781ec1a4fa6c Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:26:06 +0100 Subject: [PATCH 07/72] Update Container.tsx --- src/components/player/base/Container.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/player/base/Container.tsx b/src/components/player/base/Container.tsx index 27bac29d1..914a80f9a 100644 --- a/src/components/player/base/Container.tsx +++ b/src/components/player/base/Container.tsx @@ -5,6 +5,7 @@ import { AutoSkipSegments } from "@/components/player/internals/AutoSkipSegments import { SkipTracker } from "@/components/player/internals/Backend/SkipTracker"; import { CastingInternal } from "@/components/player/internals/CastingInternal"; import { HeadUpdater } from "@/components/player/internals/HeadUpdater"; +import { GamepadEvents } from "@/components/player/internals/GamepadEvents"; import { KeyboardEvents } from "@/components/player/internals/KeyboardEvents"; import { MediaSession } from "@/components/player/internals/MediaSession"; import { MetaReporter } from "@/components/player/internals/MetaReporter"; From d9f202bb7664b49f1cc08d3d70f945c8da280bc9 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:30:14 +0100 Subject: [PATCH 08/72] Update GamepadEvents.tsx From 0ddfdbd0b04be7a4691223236f05f81dc0684e3d Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:30:40 +0100 Subject: [PATCH 09/72] Update PauseOverlay.tsx --- src/components/player/overlays/PauseOverlay.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/player/overlays/PauseOverlay.tsx b/src/components/player/overlays/PauseOverlay.tsx index beb6e0c8a..b69917e34 100644 --- a/src/components/player/overlays/PauseOverlay.tsx +++ b/src/components/player/overlays/PauseOverlay.tsx @@ -108,12 +108,12 @@ export function PauseOverlay() { // Get runtime let runtime: number | null = null; if (isShowWithEpisode) { - const epData = await getEpisodeDetails( + const epData = await getEpisodeDetails( meta.tmdbId, meta.season?.number ?? 0, meta.episode?.number ?? 0, ); - runtime = (epData as any)?.runtime ?? null; + runtime = (epData as any)?.runtime ?? null; } else { runtime = (data as any)?.runtime ?? null; } From 121fa2e29b2bc37b9eb5a5416243f0219954f6ac Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:31:10 +0100 Subject: [PATCH 10/72] Update PreferencesPart.tsx --- src/pages/parts/settings/PreferencesPart.tsx | 34 +++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/pages/parts/settings/PreferencesPart.tsx b/src/pages/parts/settings/PreferencesPart.tsx index 09873e5e2..5a55e7630 100644 --- a/src/pages/parts/settings/PreferencesPart.tsx +++ b/src/pages/parts/settings/PreferencesPart.tsx @@ -13,6 +13,7 @@ import { Icon, Icons } from "@/components/Icon"; import { Heading1 } from "@/components/utils/Text"; import { appLanguageOptions } from "@/setup/i18n"; import { useOverlayStack } from "@/stores/interface/overlayStack"; +import { usePreferencesStore } from "@/stores/preferences"; import { isAutoplayAllowed } from "@/utils/autoplay"; import { getLocaleInfo, sortLangCodes } from "@/utils/language"; @@ -47,6 +48,8 @@ export function PreferencesPart(props: { const { t } = useTranslation(); const { showModal } = useOverlayStack(); const [isSourceListExpanded, setIsSourceListExpanded] = useState(false); + const enableGamepadControls = usePreferencesStore((s) => s.enableGamepadControls); + const setEnableGamepadControls = usePreferencesStore((s) => s.setEnableGamepadControls); const sorted = sortLangCodes( appLanguageOptions.map((item) => item.code), props.language, @@ -278,12 +281,33 @@ export function PreferencesPart(props: { {t("settings.preferences.keyboardShortcutsDescription")}

- + +
+ + {/* Gamepad Enable Toggle */} +
setEnableGamepadControls(!enableGamepadControls)} + className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg" > - {t("settings.preferences.keyboardShortcutsLabel")} - + +

+ {t("settings.preferences.enableGamepadControls", "Enable controller support")} +

+
{/* Column */} From d59c697c5e91065bcc475806ddd308ad346b9d85 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:31:26 +0100 Subject: [PATCH 11/72] Update index.tsx From de3d56bfa6268f47a1d985bd74ab13db5f39ac62 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:31:42 +0100 Subject: [PATCH 12/72] Update App.tsx --- src/setup/App.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/setup/App.tsx b/src/setup/App.tsx index 02aac5f5d..aa3cd373b 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -12,6 +12,7 @@ import { import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta"; import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb"; import { DetailsModal } from "@/components/overlays/detailsModal"; +import { GamepadControlsModal } from "@/components/overlays/GamepadControlsModal"; import { KeyboardCommandsEditModal } from "@/components/overlays/KeyboardCommandsEditModal"; import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsModal"; import { NotificationModal } from "@/components/overlays/notificationsModal"; @@ -133,6 +134,7 @@ function App() { + From 42b84c136bcc56e04d47d279711a37fe44211dac Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:35:06 +0100 Subject: [PATCH 13/72] Update GamepadControlsModal.tsx From eeb43b154f1e961b4c2adcbc82409d38bc51c080 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:46:10 +0100 Subject: [PATCH 14/72] Update GamepadEvents.tsx --- .../player/internals/GamepadEvents.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/components/player/internals/GamepadEvents.tsx b/src/components/player/internals/GamepadEvents.tsx index 544199c7c..53cfa1dcb 100644 --- a/src/components/player/internals/GamepadEvents.tsx +++ b/src/components/player/internals/GamepadEvents.tsx @@ -1,13 +1,10 @@ import { useCallback } from "react"; -import { useVolume } from "@/components/player/hooks/useVolume"; import { useCaptions } from "@/components/player/hooks/useCaptions"; +import { useVolume } from "@/components/player/hooks/useVolume"; +import { useGamepadPolling } from "@/hooks/useGamepad"; import { usePlayerStore } from "@/stores/player/store"; import { usePreferencesStore } from "@/stores/preferences"; -import { - useGamepadPolling, - DEFAULT_PLAYER_GAMEPAD_MAPPING, -} from "@/hooks/useGamepad"; export function GamepadEvents() { const display = usePlayerStore((s) => s.display); @@ -23,7 +20,6 @@ export function GamepadEvents() { const handleAction = useCallback( (action: string) => { if (!display) return; - switch (action) { case "play-pause": if (mediaPlaying.isPaused) display.play(); @@ -57,14 +53,21 @@ export function GamepadEvents() { toggleLastUsed(); break; case "back": - // Navigate back window.history.back(); break; default: break; } }, - [display, mediaPlaying, time, duration, setVolume, toggleMute, toggleLastUsed], + [ + display, + mediaPlaying, + time, + duration, + setVolume, + toggleMute, + toggleLastUsed, + ], ); useGamepadPolling({ From 0b3d1c315bb0eb5814b5a8d5587ef34d5a0a6475 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:46:28 +0100 Subject: [PATCH 15/72] Update PauseOverlay.tsx --- src/components/player/overlays/PauseOverlay.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/player/overlays/PauseOverlay.tsx b/src/components/player/overlays/PauseOverlay.tsx index b69917e34..fcfed0b1a 100644 --- a/src/components/player/overlays/PauseOverlay.tsx +++ b/src/components/player/overlays/PauseOverlay.tsx @@ -13,7 +13,6 @@ import { useIsMobile } from "@/hooks/useIsMobile"; import { playerStatus } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; import { usePreferencesStore } from "@/stores/preferences"; -import { durationExceedsHour, formatSeconds } from "@/utils/formatSeconds"; interface PauseDetails { voteAverage: number | null; @@ -26,8 +25,7 @@ export function PauseOverlay() { const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused); const status = usePlayerStore((s) => s.status); const meta = usePlayerStore((s) => s.meta); - const { time, duration, draggingTime } = usePlayerStore((s) => s.progress); - const { isSeeking } = usePlayerStore((s) => s.interface); + const { duration } = usePlayerStore((s) => s.progress); const enablePauseOverlay = usePreferencesStore((s) => s.enablePauseOverlay); const enableImageLogos = usePreferencesStore((s) => s.enableImageLogos); const { isMobile } = useIsMobile(); @@ -105,7 +103,6 @@ export function PauseOverlay() { ? data.vote_average : null; - // Get runtime let runtime: number | null = null; if (isShowWithEpisode) { const epData = await getEpisodeDetails( @@ -121,7 +118,8 @@ export function PauseOverlay() { setDetails({ voteAverage: finalVoteAverage, genres, runtime }); } } catch { - if (mounted) setDetails({ voteAverage: null, genres: [], runtime: null }); + if (mounted) + setDetails({ voteAverage: null, genres: [], runtime: null }); } }; From cc0b2f0d7d9c9923cad045660be11b3dba6cda6f Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:46:47 +0100 Subject: [PATCH 16/72] Update useGamepad.ts --- src/hooks/useGamepad.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/hooks/useGamepad.ts b/src/hooks/useGamepad.ts index e64ae4033..71f0b1597 100644 --- a/src/hooks/useGamepad.ts +++ b/src/hooks/useGamepad.ts @@ -188,7 +188,10 @@ export const GAMEPAD_ACTION_LABELS: Record = { "previous-episode": "Previous Episode", }; -export const GAMEPAD_BUTTON_LABELS: Record = { +export const GAMEPAD_BUTTON_LABELS: Record< + keyof GamepadMapping, + { xbox: string; ps: string } +> = { dpadUp: { xbox: "D-Pad ↑", ps: "D-Pad ↑" }, dpadDown: { xbox: "D-Pad ↓", ps: "D-Pad ↓" }, dpadLeft: { xbox: "D-Pad ←", ps: "D-Pad ←" }, From d6d490f6ff32e114d33e9aa44f109cd90972f132 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:47:25 +0100 Subject: [PATCH 17/72] Update GamepadControlsModal.tsx --- src/components/overlays/GamepadControlsModal.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/overlays/GamepadControlsModal.tsx b/src/components/overlays/GamepadControlsModal.tsx index 089774708..b69f5a157 100644 --- a/src/components/overlays/GamepadControlsModal.tsx +++ b/src/components/overlays/GamepadControlsModal.tsx @@ -7,7 +7,7 @@ import { Modal, ModalCard, useModal } from "@/components/overlays/Modal"; import { Heading2 } from "@/components/utils/Text"; import { GamepadMapping, - DEFAULT_PLAYER_GAMEPAD_MAPPING, + DEFAULT_GAMEPAD_MAPPING, GAMEPAD_ACTION_LABELS, GAMEPAD_BUTTON_LABELS, ALL_GAMEPAD_ACTIONS, @@ -46,7 +46,7 @@ export function GamepadControlsModal({ id }: GamepadControlsModalProps) { const gamepadMapping = usePreferencesStore((s) => s.gamepadMapping); const setGamepadMapping = usePreferencesStore((s) => s.setGamepadMapping); - const defaultMapping = DEFAULT_PLAYER_GAMEPAD_MAPPING; + const defaultMapping = DEFAULT_GAMEPAD_MAPPING; const currentMapping: GamepadMapping = { ...defaultMapping, ...gamepadMapping, @@ -72,7 +72,7 @@ export function GamepadControlsModal({ id }: GamepadControlsModalProps) { }, [currentMapping, modal]); const handleResetAll = useCallback(() => { - setEditingMapping(DEFAULT_PLAYER_GAMEPAD_MAPPING); + setEditingMapping(DEFAULT_GAMEPAD_MAPPING); }, []); const buttonGroups = [ @@ -165,6 +165,7 @@ export function GamepadControlsModal({ id }: GamepadControlsModalProps) { ? label.xbox : label.ps } + hasConflict={hasDuplicate} />
From da94d5203d8a0c460c07b567c61914046f7b0716 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:52:47 +0100 Subject: [PATCH 18/72] Update GamepadControlsModal.tsx --- .../overlays/GamepadControlsModal.tsx | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/src/components/overlays/GamepadControlsModal.tsx b/src/components/overlays/GamepadControlsModal.tsx index b69f5a157..4d030c537 100644 --- a/src/components/overlays/GamepadControlsModal.tsx +++ b/src/components/overlays/GamepadControlsModal.tsx @@ -1,19 +1,19 @@ -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/buttons/Button"; +import { Dropdown } from "@/components/form/Dropdown"; import { Icon, Icons } from "@/components/Icon"; import { Modal, ModalCard, useModal } from "@/components/overlays/Modal"; import { Heading2 } from "@/components/utils/Text"; import { - GamepadMapping, + ALL_GAMEPAD_ACTIONS, DEFAULT_GAMEPAD_MAPPING, GAMEPAD_ACTION_LABELS, GAMEPAD_BUTTON_LABELS, - ALL_GAMEPAD_ACTIONS, + GamepadMapping, } from "@/hooks/useGamepad"; import { usePreferencesStore } from "@/stores/preferences"; -import { Dropdown } from "@/components/form/Dropdown"; interface GamepadControlsModalProps { id: string; @@ -46,11 +46,13 @@ export function GamepadControlsModal({ id }: GamepadControlsModalProps) { const gamepadMapping = usePreferencesStore((s) => s.gamepadMapping); const setGamepadMapping = usePreferencesStore((s) => s.setGamepadMapping); - const defaultMapping = DEFAULT_GAMEPAD_MAPPING; - const currentMapping: GamepadMapping = { - ...defaultMapping, - ...gamepadMapping, - }; + const currentMapping: GamepadMapping = useMemo( + () => ({ + ...DEFAULT_GAMEPAD_MAPPING, + ...gamepadMapping, + }), + [gamepadMapping], + ); const [editingMapping, setEditingMapping] = useState(currentMapping); @@ -78,15 +80,30 @@ export function GamepadControlsModal({ id }: GamepadControlsModalProps) { const buttonGroups = [ { title: "D-Pad", - buttons: ["dpadUp", "dpadDown", "dpadLeft", "dpadRight"] as (keyof GamepadMapping)[], + buttons: [ + "dpadUp", + "dpadDown", + "dpadLeft", + "dpadRight", + ] as (keyof GamepadMapping)[], }, { title: "Face Buttons", - buttons: ["actionSouth", "actionEast", "actionWest", "actionNorth"] as (keyof GamepadMapping)[], + buttons: [ + "actionSouth", + "actionEast", + "actionWest", + "actionNorth", + ] as (keyof GamepadMapping)[], }, { title: "Bumpers & Triggers", - buttons: ["leftBumper", "rightBumper", "leftTrigger", "rightTrigger"] as (keyof GamepadMapping)[], + buttons: [ + "leftBumper", + "rightBumper", + "leftTrigger", + "rightTrigger", + ] as (keyof GamepadMapping)[], }, { title: "System", @@ -137,7 +154,10 @@ export function GamepadControlsModal({ id }: GamepadControlsModalProps) {
@@ -161,9 +181,7 @@ export function GamepadControlsModal({ id }: GamepadControlsModalProps) {
From 585a93ef2772c60bb4a84922f93ea93acfd0a4d4 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:52:57 +0100 Subject: [PATCH 19/72] Update Container.tsx --- src/components/player/base/Container.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/player/base/Container.tsx b/src/components/player/base/Container.tsx index 914a80f9a..7e1c7642f 100644 --- a/src/components/player/base/Container.tsx +++ b/src/components/player/base/Container.tsx @@ -4,8 +4,8 @@ import { OverlayDisplay } from "@/components/overlays/OverlayDisplay"; import { AutoSkipSegments } from "@/components/player/internals/AutoSkipSegments"; import { SkipTracker } from "@/components/player/internals/Backend/SkipTracker"; import { CastingInternal } from "@/components/player/internals/CastingInternal"; -import { HeadUpdater } from "@/components/player/internals/HeadUpdater"; import { GamepadEvents } from "@/components/player/internals/GamepadEvents"; +import { HeadUpdater } from "@/components/player/internals/HeadUpdater"; import { KeyboardEvents } from "@/components/player/internals/KeyboardEvents"; import { MediaSession } from "@/components/player/internals/MediaSession"; import { MetaReporter } from "@/components/player/internals/MetaReporter"; @@ -98,6 +98,7 @@ export function Container(props: PlayerProps) { + From 2d9ef7fd4f5f66d095eef38ea8f6e875d42e7b14 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 12:55:34 +0100 Subject: [PATCH 20/72] Update PreferencesPart.tsx --- src/pages/parts/settings/PreferencesPart.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/pages/parts/settings/PreferencesPart.tsx b/src/pages/parts/settings/PreferencesPart.tsx index 5a55e7630..0c6a5b399 100644 --- a/src/pages/parts/settings/PreferencesPart.tsx +++ b/src/pages/parts/settings/PreferencesPart.tsx @@ -48,8 +48,12 @@ export function PreferencesPart(props: { const { t } = useTranslation(); const { showModal } = useOverlayStack(); const [isSourceListExpanded, setIsSourceListExpanded] = useState(false); - const enableGamepadControls = usePreferencesStore((s) => s.enableGamepadControls); - const setEnableGamepadControls = usePreferencesStore((s) => s.setEnableGamepadControls); + const enableGamepadControls = usePreferencesStore( + (s) => s.enableGamepadControls, + ); + const setEnableGamepadControls = usePreferencesStore( + (s) => s.setEnableGamepadControls, + ); const sorted = sortLangCodes( appLanguageOptions.map((item) => item.code), props.language, @@ -305,7 +309,10 @@ export function PreferencesPart(props: { >

- {t("settings.preferences.enableGamepadControls", "Enable controller support")} + {t( + "settings.preferences.enableGamepadControls", + "Enable controller support", + )}

From 021ac50c86779e1f6fe8adfb18ba5ef9f3fb2a23 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 13:18:20 +0100 Subject: [PATCH 21/72] Update PauseOverlay.tsx --- .../player/overlays/PauseOverlay.tsx | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/components/player/overlays/PauseOverlay.tsx b/src/components/player/overlays/PauseOverlay.tsx index fcfed0b1a..fee1776cb 100644 --- a/src/components/player/overlays/PauseOverlay.tsx +++ b/src/components/player/overlays/PauseOverlay.tsx @@ -1,6 +1,5 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useIdle } from "react-use"; import { getEpisodeDetails, @@ -21,7 +20,6 @@ interface PauseDetails { } export function PauseOverlay() { - const isIdle = useIdle(10e3); // 10 seconds const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused); const status = usePlayerStore((s) => s.status); const meta = usePlayerStore((s) => s.meta); @@ -38,7 +36,34 @@ export function PauseOverlay() { runtime: null, }); - let shouldShow = isPaused && isIdle && enablePauseOverlay; + // Show overlay after 2s of being paused; only hide when playback resumes + const [overlayVisible, setOverlayVisible] = useState(false); + const timerRef = useRef | null>(null); + + useEffect(() => { + if (isPaused) { + // Start 2s timer when paused + timerRef.current = setTimeout(() => { + setOverlayVisible(true); + }, 2000); + } else { + // Clear timer and hide immediately when unpaused + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + setOverlayVisible(false); + } + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [isPaused]); + + let shouldShow = overlayVisible && enablePauseOverlay; if (isMobile && status === playerStatus.SCRAPING) shouldShow = false; if (isMobile && showTargets) shouldShow = false; @@ -142,7 +167,7 @@ export function PauseOverlay() { return (
@@ -175,7 +200,7 @@ export function PauseOverlay() { )} {/* Episode title */} - {meta.type === "show" && meta.episode && ( + {meta.type === "show" && meta.episode?.title && (

{meta.episode.title}

From 86327a1d5eb645042bcac0fa97fbf3ba8937ce3e Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 13:25:06 +0100 Subject: [PATCH 22/72] Update GamepadControlsModal.tsx --- src/components/overlays/GamepadControlsModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/overlays/GamepadControlsModal.tsx b/src/components/overlays/GamepadControlsModal.tsx index 4d030c537..af445f635 100644 --- a/src/components/overlays/GamepadControlsModal.tsx +++ b/src/components/overlays/GamepadControlsModal.tsx @@ -140,14 +140,14 @@ export function GamepadControlsModal({ id }: GamepadControlsModalProps) { onClick={() => setControllerType("xbox")} className="px-4 py-2" > - 🎮 Xbox + Xbox
From 6112ef575c9c0f427e993ce47d5ae71168a1f95e Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 13:25:42 +0100 Subject: [PATCH 23/72] Update PreferencesPart.tsx --- src/pages/parts/settings/PreferencesPart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/parts/settings/PreferencesPart.tsx b/src/pages/parts/settings/PreferencesPart.tsx index 0c6a5b399..cf8c98313 100644 --- a/src/pages/parts/settings/PreferencesPart.tsx +++ b/src/pages/parts/settings/PreferencesPart.tsx @@ -298,7 +298,7 @@ export function PreferencesPart(props: { onClick={() => showModal("gamepad-controls-edit")} className="flex-1" > - 🎮 {t("settings.preferences.gamepadControlsLabel", "Controller")} + {t("settings.preferences.gamepadControlsLabel", "Customizze Controller Keybinds")} From 8d240729679f54e329bceae14b94085a9ed992cd Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 14:12:54 +0100 Subject: [PATCH 24/72] Update config.js --- public/config.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/config.js b/public/config.js index 7c5e57c6e..90cc699a2 100644 --- a/public/config.js +++ b/public/config.js @@ -17,4 +17,6 @@ window.__CONFIG__ = { // A comma separated list of disallowed IDs in the case of a DMCA claim - in the format "series-" and "movie-" VITE_DISALLOWED_IDS: "", + // Allowing FEBBOX API TO BE AENBALED. + VITE_ALLOW_FEBBOX_KEY: "true", }; From 6e7cb05ed8cd2291b5c189d7f5bbf63def6ef9ea Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 14:19:02 +0100 Subject: [PATCH 25/72] Update PauseOverlay.tsx --- .../player/overlays/PauseOverlay.tsx | 55 +++++++++++++------ 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/src/components/player/overlays/PauseOverlay.tsx b/src/components/player/overlays/PauseOverlay.tsx index fee1776cb..78160f0ab 100644 --- a/src/components/player/overlays/PauseOverlay.tsx +++ b/src/components/player/overlays/PauseOverlay.tsx @@ -36,18 +36,37 @@ export function PauseOverlay() { runtime: null, }); - // Show overlay after 2s of being paused; only hide when playback resumes + // Track whether playback has actually started at least once + // so the overlay never appears during source scraping / initial load + const hasPlayedRef = useRef(false); const [overlayVisible, setOverlayVisible] = useState(false); const timerRef = useRef | null>(null); + // Mark that real playback has started the moment isPaused flips to false + // while the player is in a playing state (not scraping/loading) useEffect(() => { - if (isPaused) { - // Start 2s timer when paused + if ( + !isPaused && + status !== playerStatus.SCRAPING && + status !== playerStatus.LOADING + ) { + hasPlayedRef.current = true; + } + }, [isPaused, status]); + + useEffect(() => { + if ( + isPaused && + hasPlayedRef.current && + status !== playerStatus.SCRAPING && + status !== playerStatus.LOADING + ) { + // Show after 2 seconds of being paused timerRef.current = setTimeout(() => { setOverlayVisible(true); }, 2000); } else { - // Clear timer and hide immediately when unpaused + // Hide immediately when unpaused or not yet played if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; @@ -61,7 +80,7 @@ export function PauseOverlay() { timerRef.current = null; } }; - }, [isPaused]); + }, [isPaused, status]); let shouldShow = overlayVisible && enablePauseOverlay; if (isMobile && status === playerStatus.SCRAPING) shouldShow = false; @@ -167,15 +186,15 @@ export function PauseOverlay() { return (
- {/* Main content - left aligned, vertically centered */} -
-
+ {/* Main content - left-center aligned, vertically anchored near bottom */} +
+
{/* "You are watching" label */} -

+

{t("player.pauseOverlay.youAreWatching", "You are watching")}

@@ -184,37 +203,37 @@ export function PauseOverlay() { {meta.title} ) : ( -

+

{meta.title}

)} {/* Season / Episode info */} {meta.type === "show" && meta.season && meta.episode && ( -

+

Season {meta.season.number} · Episode {meta.episode.number}

)} {/* Episode title */} {meta.type === "show" && meta.episode?.title && ( -

+

{meta.episode.title}

)} {/* Description */} {overview && ( -

+

{overview}

)} {/* Rating + Runtime */} -
+
{details.voteAverage !== null && details.voteAverage > 0 && ( <> @@ -241,8 +260,8 @@ export function PauseOverlay() {
- {/* "Paused" indicator - bottom right */} -
+ {/* "Paused" indicator - bottom right, raised up to avoid controls overlap */} +
{t("player.pauseOverlay.paused", "Paused")} From 724ea91601732f99857e57a91c7297ad16474909 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 14:19:22 +0100 Subject: [PATCH 26/72] Update GamepadControlsModal.tsx --- src/components/overlays/GamepadControlsModal.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/overlays/GamepadControlsModal.tsx b/src/components/overlays/GamepadControlsModal.tsx index af445f635..4f247c7e1 100644 --- a/src/components/overlays/GamepadControlsModal.tsx +++ b/src/components/overlays/GamepadControlsModal.tsx @@ -139,15 +139,16 @@ export function GamepadControlsModal({ id }: GamepadControlsModalProps) { theme={controllerType === "xbox" ? "purple" : "secondary"} onClick={() => setControllerType("xbox")} className="px-4 py-2" + > - Xbox + {"🎮 Xbox"}
From 5ec3b93959630d14af7f4fceca58a1afd6f197af Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 14:20:52 +0100 Subject: [PATCH 27/72] Update PreferencesPart.tsx --- src/pages/parts/settings/PreferencesPart.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/parts/settings/PreferencesPart.tsx b/src/pages/parts/settings/PreferencesPart.tsx index cf8c98313..34a5224da 100644 --- a/src/pages/parts/settings/PreferencesPart.tsx +++ b/src/pages/parts/settings/PreferencesPart.tsx @@ -298,7 +298,11 @@ export function PreferencesPart(props: { onClick={() => showModal("gamepad-controls-edit")} className="flex-1" > - {t("settings.preferences.gamepadControlsLabel", "Customizze Controller Keybinds")} + {" "} + {t( + "settings.preferences.gamepadControlsLabel", + "Customizze Controller Keybinds", + )}
From 744b0209c843df5d64ed0d7c2482305a5caf9b6b Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 14:21:15 +0100 Subject: [PATCH 28/72] Update GamepadControlsModal.tsx --- src/components/overlays/GamepadControlsModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/overlays/GamepadControlsModal.tsx b/src/components/overlays/GamepadControlsModal.tsx index 4f247c7e1..5afb31601 100644 --- a/src/components/overlays/GamepadControlsModal.tsx +++ b/src/components/overlays/GamepadControlsModal.tsx @@ -141,14 +141,14 @@ export function GamepadControlsModal({ id }: GamepadControlsModalProps) { className="px-4 py-2" > - {"🎮 Xbox"} + {" Xbox"}
From 9e5de02365851cd1626f7ed90f4305747e7bf9e8 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 14:33:22 +0100 Subject: [PATCH 29/72] Update PauseOverlay.tsx --- src/components/player/overlays/PauseOverlay.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/components/player/overlays/PauseOverlay.tsx b/src/components/player/overlays/PauseOverlay.tsx index 78160f0ab..dd7b06aeb 100644 --- a/src/components/player/overlays/PauseOverlay.tsx +++ b/src/components/player/overlays/PauseOverlay.tsx @@ -42,25 +42,15 @@ export function PauseOverlay() { const [overlayVisible, setOverlayVisible] = useState(false); const timerRef = useRef | null>(null); - // Mark that real playback has started the moment isPaused flips to false - // while the player is in a playing state (not scraping/loading) + // Mark that real playback has started only when the player is actively playing useEffect(() => { - if ( - !isPaused && - status !== playerStatus.SCRAPING && - status !== playerStatus.LOADING - ) { + if (!isPaused && status === playerStatus.PLAYING) { hasPlayedRef.current = true; } }, [isPaused, status]); useEffect(() => { - if ( - isPaused && - hasPlayedRef.current && - status !== playerStatus.SCRAPING && - status !== playerStatus.LOADING - ) { + if (isPaused && hasPlayedRef.current && status === playerStatus.PLAYING) { // Show after 2 seconds of being paused timerRef.current = setTimeout(() => { setOverlayVisible(true); From 89f9e60096bbfda567dbab38b5724069c24cfe20 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Sun, 15 Mar 2026 14:37:09 +0100 Subject: [PATCH 30/72] Update GamepadControlsModal.tsx --- src/components/overlays/GamepadControlsModal.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/overlays/GamepadControlsModal.tsx b/src/components/overlays/GamepadControlsModal.tsx index 5afb31601..c5d7ae2d3 100644 --- a/src/components/overlays/GamepadControlsModal.tsx +++ b/src/components/overlays/GamepadControlsModal.tsx @@ -139,16 +139,15 @@ export function GamepadControlsModal({ id }: GamepadControlsModalProps) { theme={controllerType === "xbox" ? "purple" : "secondary"} onClick={() => setControllerType("xbox")} className="px-4 py-2" - > - {" Xbox"} + {"Xbox"}
From bbf192b4c9529afca48ecf170f04cd1c62ee8e22 Mon Sep 17 00:00:00 2001 From: Vblaze Date: Mon, 16 Mar 2026 12:41:57 +0100 Subject: [PATCH 31/72] Update vite.config.mts --- vite.config.mts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/vite.config.mts b/vite.config.mts index 248457e04..640bec765 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -4,7 +4,7 @@ import loadVersion from "vite-plugin-package-version"; import { VitePWA } from "vite-plugin-pwa"; import checker from "vite-plugin-checker"; import path from "path"; -import million from "million/compiler"; + import { handlebars } from "./plugins/handlebars"; import { PluginOption, loadEnv, splitVendorChunkPlugin } from "vite"; import { visualizer } from "rollup-plugin-visualizer"; @@ -26,7 +26,7 @@ export default defineConfig(({ mode }) => { return { base: env.VITE_BASE_URL || "/", plugins: [ - million.vite({ auto: true, mute: true }), + handlebars({ vars: { opensearchEnabled: env.VITE_OPENSEARCH_ENABLED === "true", @@ -177,5 +177,9 @@ export default defineConfig(({ mode }) => { test: { environment: "jsdom", }, + preview: { + host: true, + port: 80, + allowedHosts: ["pstream.net, pstream-test.vercel.app"] }; }); From 0aec1c7dd9f619cc94ea0822fb7be11a818ad3d7 Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Mon, 16 Mar 2026 13:43:15 +0100 Subject: [PATCH 32/72] Pauseoverlay Completely renewed and made it better --- .../player/overlays/PauseOverlay.tsx | 174 +++++++++++------- 1 file changed, 110 insertions(+), 64 deletions(-) diff --git a/src/components/player/overlays/PauseOverlay.tsx b/src/components/player/overlays/PauseOverlay.tsx index dd7b06aeb..9c8c2c255 100644 --- a/src/components/player/overlays/PauseOverlay.tsx +++ b/src/components/player/overlays/PauseOverlay.tsx @@ -7,11 +7,15 @@ import { getMediaLogo, } from "@/backend/metadata/tmdb"; import { TMDBContentTypes } from "@/backend/metadata/types/tmdb"; +import { Icon, Icons } from "@/components/Icon"; import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls"; import { useIsMobile } from "@/hooks/useIsMobile"; +import { useOverlayStack } from "@/stores/interface/overlayStack"; import { playerStatus } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; import { usePreferencesStore } from "@/stores/preferences"; +import { durationExceedsHour, formatSeconds } from "@/utils/formatSeconds"; +import { uses12HourClock } from "@/utils/uses12HourClock"; interface PauseDetails { voteAverage: number | null; @@ -21,14 +25,19 @@ interface PauseDetails { export function PauseOverlay() { const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused); + const isLoading = usePlayerStore((s) => s.mediaPlaying.isLoading); const status = usePlayerStore((s) => s.status); const meta = usePlayerStore((s) => s.meta); - const { duration } = usePlayerStore((s) => s.progress); + const { time, duration, draggingTime } = usePlayerStore((s) => s.progress); + const { isSeeking } = usePlayerStore((s) => s.interface); + const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate); const enablePauseOverlay = usePreferencesStore((s) => s.enablePauseOverlay); const enableImageLogos = usePreferencesStore((s) => s.enableImageLogos); const { isMobile } = useIsMobile(); const { showTargets } = useShouldShowControls(); const { t } = useTranslation(); + const { showModal } = useOverlayStack(); + const [logoUrl, setLogoUrl] = useState(null); const [details, setDetails] = useState({ voteAverage: null, @@ -36,13 +45,10 @@ export function PauseOverlay() { runtime: null, }); - // Track whether playback has actually started at least once - // so the overlay never appears during source scraping / initial load const hasPlayedRef = useRef(false); const [overlayVisible, setOverlayVisible] = useState(false); const timerRef = useRef | null>(null); - // Mark that real playback has started only when the player is actively playing useEffect(() => { if (!isPaused && status === playerStatus.PLAYING) { hasPlayedRef.current = true; @@ -50,13 +56,23 @@ export function PauseOverlay() { }, [isPaused, status]); useEffect(() => { + if (status === playerStatus.SCRAPING) { + setOverlayVisible(false); + if (timerRef.current) clearTimeout(timerRef.current); + return; + } + + if (isLoading && hasPlayedRef.current) { + setOverlayVisible(true); + if (timerRef.current) clearTimeout(timerRef.current); + return; + } + if (isPaused && hasPlayedRef.current && status === playerStatus.PLAYING) { - // Show after 2 seconds of being paused timerRef.current = setTimeout(() => { setOverlayVisible(true); }, 2000); } else { - // Hide immediately when unpaused or not yet played if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; @@ -65,15 +81,12 @@ export function PauseOverlay() { } return () => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } + if (timerRef.current) clearTimeout(timerRef.current); }; - }, [isPaused, status]); + }, [isPaused, status, isLoading]); let shouldShow = overlayVisible && enablePauseOverlay; - if (isMobile && status === playerStatus.SCRAPING) shouldShow = false; + if (status === playerStatus.SCRAPING) shouldShow = false; if (isMobile && showTargets) shouldShow = false; useEffect(() => { @@ -110,10 +123,10 @@ export function PauseOverlay() { try { const type = meta.type === "movie" ? TMDBContentTypes.MOVIE : TMDBContentTypes.TV; - const isShowWithEpisode = meta.type === "show" && meta.season && meta.episode; let voteAverage: number | null = null; + let runtime: number | null = null; if (isShowWithEpisode) { const episodeData = await getEpisodeDetails( @@ -121,8 +134,9 @@ export function PauseOverlay() { meta.season?.number ?? 0, meta.episode?.number ?? 0, ); - if (mounted && episodeData?.vote_average != null) { - voteAverage = episodeData.vote_average; + if (mounted && episodeData) { + voteAverage = episodeData.vote_average ?? null; + runtime = (episodeData as any).runtime ?? null; } } @@ -137,16 +151,8 @@ export function PauseOverlay() { ? data.vote_average : null; - let runtime: number | null = null; - if (isShowWithEpisode) { - const epData = await getEpisodeDetails( - meta.tmdbId, - meta.season?.number ?? 0, - meta.episode?.number ?? 0, - ); - runtime = (epData as any)?.runtime ?? null; - } else { - runtime = (data as any)?.runtime ?? null; + if (!isShowWithEpisode) { + runtime = (data as any).runtime ?? null; } setDetails({ voteAverage: finalVoteAverage, genres, runtime }); @@ -168,27 +174,60 @@ export function PauseOverlay() { const overview = meta.type === "show" ? meta.episode?.overview : meta.overview; - const formatRuntime = (minutes: number) => { - const h = Math.floor(minutes / 60); - const m = minutes % 60; - return h > 0 ? `${h}h ${m}m` : `${m}m`; + const currentTime = Math.min( + Math.max(isSeeking ? draggingTime : time, 0), + duration, + ); + const secondsRemaining = Math.abs(currentTime - duration); + const secondsRemainingAdjusted = + playbackRate > 0 ? secondsRemaining / playbackRate : secondsRemaining; + + const hasHours = durationExceedsHour(duration); + const timeLeft = formatSeconds( + secondsRemaining, + durationExceedsHour(secondsRemaining), + ); + const timeWatched = formatSeconds(currentTime, hasHours); + const timeFinished = new Date(Date.now() + secondsRemainingAdjusted * 1e3); + const durationFormatted = formatSeconds(duration, hasHours); + + const handleOpenDetails = () => { + showModal("details", { + id: Number(meta.tmdbId), + type: meta.type === "movie" ? "movie" : "show", + }); }; + const timeRemainingText = t("player.time.remaining", { + timeFinished, + timeWatched, + timeLeft, + duration: durationFormatted, + formatParams: { + timeFinished: { + hour: "numeric", + minute: "numeric", + hour12: uses12HourClock(), + }, + }, + }) + .replace(/ • /g, " | ") + .replace(/ · /g, " | "); + return (
- {/* Main content - left-center aligned, vertically anchored near bottom */}
- {/* "You are watching" label */}

{t("player.pauseOverlay.youAreWatching", "You are watching")}

- {/* Title / Logo */} {logoUrl ? ( )} - {/* Season / Episode info */} {meta.type === "show" && meta.season && meta.episode && (

- Season {meta.season.number} · Episode {meta.episode.number} + Season {meta.season.number} | Episode {meta.episode.number}

)} - {/* Episode title */} {meta.type === "show" && meta.episode?.title && (

{meta.episode.title}

)} - {/* Description */} - {overview && ( -

- {overview} -

- )} - - {/* Rating + Runtime */} -
- {details.voteAverage !== null && details.voteAverage > 0 && ( - <> - - {details.voteAverage.toFixed(0)} - +
+ {details.voteAverage !== null && ( + + Rating ⭐: {details.voteAverage.toFixed(1)} + /10 + )} - {details.runtime && details.runtime > 0 && ( + + {details.genres.length > 0 && ( <> - {details.voteAverage !== null && details.voteAverage > 0 && ( - · - )} - {formatRuntime(details.runtime)} + | + {details.genres.slice(0, 3).join(", ")} )} - {duration > 0 && !details.runtime && ( + + {duration > 0 && ( <> - {details.voteAverage !== null && details.voteAverage > 0 && ( - · - )} - {formatRuntime(Math.round(duration / 60))} + | + {timeRemainingText} )}
+ + {overview && ( +
+

+ {overview} +

+ + + +
+ )}
- {/* "Paused" indicator - bottom right, raised up to avoid controls overlap */} -
- +
+ + {t("player.pauseOverlay.paused", "Paused")}
); } + + From cb5a4139806ecc9f7885851c5a40c3c543b8ef12 Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Mon, 16 Mar 2026 13:46:01 +0100 Subject: [PATCH 33/72] fixed vite error --- vite.config.mts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/vite.config.mts b/vite.config.mts index 640bec765..c0f2b074f 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -177,9 +177,10 @@ export default defineConfig(({ mode }) => { test: { environment: "jsdom", }, - preview: { - host: true, - port: 80, - allowedHosts: ["pstream.net, pstream-test.vercel.app"] + preview: { + host: true, + port: 80, + allowedHosts: ["pstream.net", "pstream-test.vercel.app"], + }, }; }); From 211c1544aef07889ba4ccc010dc9acafb48589c0 Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Mon, 16 Mar 2026 13:49:08 +0100 Subject: [PATCH 34/72] GamepodControlsmodal fixed error --- src/components/overlays/GamepadControlsModal.tsx | 4 ++-- src/components/player/overlays/PauseOverlay.tsx | 2 -- src/pages/parts/settings/ConnectionsPart.tsx | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/overlays/GamepadControlsModal.tsx b/src/components/overlays/GamepadControlsModal.tsx index c5d7ae2d3..ab3eef598 100644 --- a/src/components/overlays/GamepadControlsModal.tsx +++ b/src/components/overlays/GamepadControlsModal.tsx @@ -140,14 +140,14 @@ export function GamepadControlsModal({ id }: GamepadControlsModalProps) { onClick={() => setControllerType("xbox")} className="px-4 py-2" > - {"Xbox"} + Xbox
diff --git a/src/components/player/overlays/PauseOverlay.tsx b/src/components/player/overlays/PauseOverlay.tsx index 9c8c2c255..d35723688 100644 --- a/src/components/player/overlays/PauseOverlay.tsx +++ b/src/components/player/overlays/PauseOverlay.tsx @@ -303,5 +303,3 @@ export function PauseOverlay() {
); } - - diff --git a/src/pages/parts/settings/ConnectionsPart.tsx b/src/pages/parts/settings/ConnectionsPart.tsx index 7b8130cc6..819b87468 100644 --- a/src/pages/parts/settings/ConnectionsPart.tsx +++ b/src/pages/parts/settings/ConnectionsPart.tsx @@ -37,7 +37,7 @@ import { useAuthStore } from "@/stores/auth"; import { usePreferencesStore } from "@/stores/preferences"; import { useTraktStore } from "@/stores/trakt/store"; -import { RegionSelectorPart } from "./RegionSelectorPart"; + interface ProxyEditProps { proxyUrls: string[] | null; From 28275d087d24678f650078432860ae9488a45d18 Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Mon, 16 Mar 2026 13:54:34 +0100 Subject: [PATCH 35/72] Removed errors --- src/pages/parts/settings/ConnectionsPart.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/parts/settings/ConnectionsPart.tsx b/src/pages/parts/settings/ConnectionsPart.tsx index 819b87468..46126cf2a 100644 --- a/src/pages/parts/settings/ConnectionsPart.tsx +++ b/src/pages/parts/settings/ConnectionsPart.tsx @@ -37,8 +37,6 @@ import { useAuthStore } from "@/stores/auth"; import { usePreferencesStore } from "@/stores/preferences"; import { useTraktStore } from "@/stores/trakt/store"; - - interface ProxyEditProps { proxyUrls: string[] | null; setProxyUrls: Dispatch>; From 128651c64079fa19462c64c0c778d0f43f16e097 Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Mon, 16 Mar 2026 15:42:03 +0100 Subject: [PATCH 36/72] PauseOverlay updated to say Finishes. --- src/assets/locales/en.json | 2 +- .../player/overlays/PauseOverlay.tsx | 51 +++++++++++-------- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 4c4918511..fc0501044 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -983,7 +983,7 @@ }, "time": { "regular": "{{timeWatched}} / {{duration}}", - "remaining": "{{timeLeft}} left • Finish at {{timeFinished, datetime}}", + "remaining": "{{timeLeft}} left • Finishes at {{timeFinished, datetime}}", "shortRegular": "{{timeWatched}}", "shortRemaining": "-{{timeLeft}}" }, diff --git a/src/components/player/overlays/PauseOverlay.tsx b/src/components/player/overlays/PauseOverlay.tsx index d35723688..959d62cff 100644 --- a/src/components/player/overlays/PauseOverlay.tsx +++ b/src/components/player/overlays/PauseOverlay.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { @@ -24,15 +24,15 @@ interface PauseDetails { } export function PauseOverlay() { - const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused); - const isLoading = usePlayerStore((s) => s.mediaPlaying.isLoading); - const status = usePlayerStore((s) => s.status); - const meta = usePlayerStore((s) => s.meta); - const { time, duration, draggingTime } = usePlayerStore((s) => s.progress); - const { isSeeking } = usePlayerStore((s) => s.interface); - const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate); - const enablePauseOverlay = usePreferencesStore((s) => s.enablePauseOverlay); - const enableImageLogos = usePreferencesStore((s) => s.enableImageLogos); + const isPaused = usePlayerStore((s: any) => s.mediaPlaying.isPaused); + const isLoading = usePlayerStore((s: any) => s.mediaPlaying.isLoading); + const status = usePlayerStore((s: any) => s.status); + const meta = usePlayerStore((s: any) => s.meta); + const { time, duration, draggingTime } = usePlayerStore((s: any) => s.progress); + const { isSeeking } = usePlayerStore((s: any) => s.interface); + const playbackRate = usePlayerStore((s: any) => s.mediaPlaying.playbackRate); + const enablePauseOverlay = usePreferencesStore((s: any) => s.enablePauseOverlay); + const enableImageLogos = usePreferencesStore((s: any) => s.enableImageLogos); const { isMobile } = useIsMobile(); const { showTargets } = useShouldShowControls(); const { t } = useTranslation(); @@ -198,7 +198,7 @@ export function PauseOverlay() { }); }; - const timeRemainingText = t("player.time.remaining", { + const timeRemainingParts = t("player.time.remaining", { timeFinished, timeWatched, timeLeft, @@ -210,17 +210,18 @@ export function PauseOverlay() { hour12: uses12HourClock(), }, }, - }) - .replace(/ • /g, " | ") - .replace(/ · /g, " | "); + }).split(/[•·]| \| /); + + const play = usePlayerStore((s) => s.play); return (
play()} >
@@ -267,18 +268,24 @@ export function PauseOverlay() { )} - {duration > 0 && ( - <> - | - {timeRemainingText} - - )} + {duration > 0 && + timeRemainingParts.map((part: string, i: number) => ( + + {(i > 0 || details.genres.length > 0 || details.voteAverage !== null) && ( + | + )} + {part.trim()} + + ))}
{overview && (
{ + e.stopPropagation(); + handleOpenDetails(); + }} >

{overview} From b8a7bccdd5def3a8cdaa72f00fa3e8f0c98b9ace Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Mon, 16 Mar 2026 15:48:09 +0100 Subject: [PATCH 37/72] PauseOverlay Final Update. --- .../player/overlays/PauseOverlay.tsx | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/components/player/overlays/PauseOverlay.tsx b/src/components/player/overlays/PauseOverlay.tsx index 959d62cff..7ed0f73ff 100644 --- a/src/components/player/overlays/PauseOverlay.tsx +++ b/src/components/player/overlays/PauseOverlay.tsx @@ -28,10 +28,19 @@ export function PauseOverlay() { const isLoading = usePlayerStore((s: any) => s.mediaPlaying.isLoading); const status = usePlayerStore((s: any) => s.status); const meta = usePlayerStore((s: any) => s.meta); - const { time, duration, draggingTime } = usePlayerStore((s: any) => s.progress); + const { + time, + duration, + draggingTime, + } = usePlayerStore( + (s: any) => s.progress, + ); const { isSeeking } = usePlayerStore((s: any) => s.interface); const playbackRate = usePlayerStore((s: any) => s.mediaPlaying.playbackRate); - const enablePauseOverlay = usePreferencesStore((s: any) => s.enablePauseOverlay); + const play = usePlayerStore((s: any) => s.play); + const enablePauseOverlay = usePreferencesStore( + (s: any) => s.enablePauseOverlay, + ); const enableImageLogos = usePreferencesStore((s: any) => s.enableImageLogos); const { isMobile } = useIsMobile(); const { showTargets } = useShouldShowControls(); @@ -212,7 +221,6 @@ export function PauseOverlay() { }, }).split(/[•·]| \| /); - const play = usePlayerStore((s) => s.play); return (

0 && - timeRemainingParts.map((part: string, i: number) => ( - - {(i > 0 || details.genres.length > 0 || details.voteAverage !== null) && ( + timeRemainingParts.map((part: string) => ( + + {(part.trim() !== timeRemainingParts[0].trim() || + details.genres.length > 0 || + details.voteAverage !== null) && ( | )} {part.trim()} From 8d00d21858364f2448b0bf4b88d6a331d1e3840a Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Mon, 16 Mar 2026 16:32:50 +0100 Subject: [PATCH 38/72] PauseOverlay Revamped --- src/components/player/overlays/PauseOverlay.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/components/player/overlays/PauseOverlay.tsx b/src/components/player/overlays/PauseOverlay.tsx index 7ed0f73ff..f01e6d067 100644 --- a/src/components/player/overlays/PauseOverlay.tsx +++ b/src/components/player/overlays/PauseOverlay.tsx @@ -28,11 +28,7 @@ export function PauseOverlay() { const isLoading = usePlayerStore((s: any) => s.mediaPlaying.isLoading); const status = usePlayerStore((s: any) => s.status); const meta = usePlayerStore((s: any) => s.meta); - const { - time, - duration, - draggingTime, - } = usePlayerStore( + const { time, duration, draggingTime } = usePlayerStore( (s: any) => s.progress, ); const { isSeeking } = usePlayerStore((s: any) => s.interface); @@ -221,7 +217,6 @@ export function PauseOverlay() { }, }).split(/[•·]| \| /); - return (
Date: Mon, 16 Mar 2026 17:58:49 +0100 Subject: [PATCH 39/72] Remade Controll Support & AFixed Pauseoverlay bug --- src/assets/css/index.css | 21 ++ .../gamepad/GamepadGlobalListener.tsx | 67 ++++++ .../player/overlays/PauseOverlay.tsx | 2 +- src/hooks/useSpatialNavigation.ts | 89 ++++++++ src/pages/GamepadSetup.tsx | 208 ++++++++++++++++++ src/pages/parts/settings/PreferencesPart.tsx | 120 +++++++--- src/setup/App.tsx | 10 + src/stores/player/slices/playing.ts | 6 +- src/stores/preferences/index.tsx | 16 ++ 9 files changed, 500 insertions(+), 39 deletions(-) create mode 100644 src/components/gamepad/GamepadGlobalListener.tsx create mode 100644 src/hooks/useSpatialNavigation.ts create mode 100644 src/pages/GamepadSetup.tsx diff --git a/src/assets/css/index.css b/src/assets/css/index.css index f41da5c11..d0ce4215e 100644 --- a/src/assets/css/index.css +++ b/src/assets/css/index.css @@ -272,6 +272,27 @@ input[type="range"].styled-slider.slider-progress::-ms-fill-lower { box-shadow: 0 0 10px theme("colors.themePreview.secondary"); } +/* Gamepad specific focus - Premium TV Experience */ +.gamepad-active *:focus-visible { + outline: 4px solid #ffffff !important; + outline-offset: 2px !important; + box-shadow: 0 0 20px rgba(255, 255, 255, 0.5) !important; + border-radius: 4px; + z-index: 50; + transition: + outline 0.2s ease, + outline-offset 0.2s ease, + box-shadow 0.2s ease; +} + +/* Ensure cards and buttons look good when focused */ +.gamepad-active .group:focus-visible, +.gamepad-active button:focus-visible, +.gamepad-active a:focus-visible { + transform: scale(1.02); + transition: transform 0.2s ease; +} + [dir="rtl"] .transform { /* Invert horizontal X offset on transform (Tailwind RTL plugin does the rest) */ transform: translate(calc(var(--tw-translate-x) * -1), var(--tw-translate-y)) diff --git a/src/components/gamepad/GamepadGlobalListener.tsx b/src/components/gamepad/GamepadGlobalListener.tsx new file mode 100644 index 000000000..84162bfb7 --- /dev/null +++ b/src/components/gamepad/GamepadGlobalListener.tsx @@ -0,0 +1,67 @@ +import React, { useCallback, useEffect } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { useGamepadPolling } from "@/hooks/useGamepad"; +import { useSpatialNavigation } from "@/hooks/useSpatialNavigation"; +import { usePreferencesStore } from "@/stores/preferences"; + +export function GamepadGlobalListener() { + const { navigate: navigateSpatial } = useSpatialNavigation(); + const navigate = useNavigate(); + const location = useLocation(); + const enableGamepadControls = usePreferencesStore( + (s: any) => s.enableGamepadControls, + ); + + const gamepadInputMode = usePreferencesStore((s: any) => s.gamepadInputMode); + + const handleAction = useCallback( + (action: string) => { + if (gamepadInputMode === "kbm") return; + + // Add gamepad-active class to body to show custom focus outlines + document.body.classList.add("gamepad-active"); + + // Don't intercept if we're in the player (it has its own listener) + if (location.pathname.startsWith("/media/")) return; + + switch (action) { + case "navigate-up": + navigateSpatial("up"); + break; + case "navigate-down": + navigateSpatial("down"); + break; + case "navigate-left": + navigateSpatial("left"); + break; + case "navigate-right": + navigateSpatial("right"); + break; + case "confirm": + (document.activeElement as HTMLElement)?.click(); + break; + case "back": + navigate(-1); + break; + } + }, + [navigateSpatial, navigate, location.pathname], + ); + + useGamepadPolling({ + onAction: handleAction, + enabled: enableGamepadControls, + }); + + // Remove gamepad-active class on mouse move (optional, but nice for hybrid use) + useEffect(() => { + const handleMouseMove = () => { + document.body.classList.remove("gamepad-active"); + }; + window.addEventListener("mousemove", handleMouseMove); + return () => window.removeEventListener("mousemove", handleMouseMove); + }, []); + + return null; +} diff --git a/src/components/player/overlays/PauseOverlay.tsx b/src/components/player/overlays/PauseOverlay.tsx index f01e6d067..b59ce703b 100644 --- a/src/components/player/overlays/PauseOverlay.tsx +++ b/src/components/player/overlays/PauseOverlay.tsx @@ -221,7 +221,7 @@ export function PauseOverlay() {
play()} diff --git a/src/hooks/useSpatialNavigation.ts b/src/hooks/useSpatialNavigation.ts new file mode 100644 index 000000000..f93832646 --- /dev/null +++ b/src/hooks/useSpatialNavigation.ts @@ -0,0 +1,89 @@ +import React, { useCallback } from "react"; + +export type NavigationDirection = "up" | "down" | "left" | "right"; + +const FOCUSABLE_SELECTOR = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + +export function useSpatialNavigation() { + const getFocusableElements = useCallback(() => { + const all = Array.from( + document.querySelectorAll(FOCUSABLE_SELECTOR), + ); + return all.filter((el) => { + const style = window.getComputedStyle(el); + return ( + style.display !== "none" && + style.visibility !== "hidden" && + el.offsetWidth > 0 && + el.offsetHeight > 0 && + !(el as any).disabled + ); + }); + }, []); + + const navigate = useCallback( + (direction: NavigationDirection) => { + const activeElement = document.activeElement as HTMLElement; + if (!activeElement) return; + + const elements = getFocusableElements(); + if (elements.length === 0) return; + + const currentRect = activeElement.getBoundingClientRect(); + const currentCenter = { + x: currentRect.left + currentRect.width / 2, + y: currentRect.top + currentRect.height / 2, + }; + + let bestElement: HTMLElement | null = null; + let minDistance = Infinity; + + for (const el of elements) { + if (el === activeElement) continue; + + const elRect = el.getBoundingClientRect(); + const elCenter = { + x: elRect.left + elRect.width / 2, + y: elRect.top + elRect.height / 2, + }; + + const dx = elCenter.x - currentCenter.x; + const dy = elCenter.y - currentCenter.y; + + let isCorrectDirection = false; + switch (direction) { + case "up": + isCorrectDirection = dy < 0 && Math.abs(dx) < Math.abs(dy) * 1.5; + break; + case "down": + isCorrectDirection = dy > 0 && Math.abs(dx) < Math.abs(dy) * 1.5; + break; + case "left": + isCorrectDirection = dx < 0 && Math.abs(dy) < Math.abs(dx) * 1.5; + break; + case "right": + isCorrectDirection = dx > 0 && Math.abs(dy) < Math.abs(dx) * 1.5; + break; + } + + if (isCorrectDirection) { + // Weighted distance: prioritize primary direction + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance < minDistance) { + minDistance = distance; + bestElement = el; + } + } + } + + if (bestElement) { + bestElement.focus(); + bestElement.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + }, + [getFocusableElements], + ); + + return { navigate }; +} diff --git a/src/pages/GamepadSetup.tsx b/src/pages/GamepadSetup.tsx new file mode 100644 index 000000000..b8ed4d98f --- /dev/null +++ b/src/pages/GamepadSetup.tsx @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; + +import { Icon, Icons } from "@/components/Icon"; +import { Stepper } from "@/components/layout/Stepper"; +import { BiggerCenterContainer } from "@/components/layout/ThinContainer"; +import { Heading2, Paragraph } from "@/components/utils/Text"; +import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout"; +import { Card, CardContent, Link } from "@/pages/migration/utils"; +import { PageTitle } from "@/pages/parts/util/PageTitle"; +import { usePreferencesStore } from "@/stores/preferences"; + +type SetupStep = "start" | "auto" | "manual" | "success"; + +const MANUAL_BUTTONS = [ + { id: 0, name: "A / Cross", icon: Icons.CIRCLE_CHECK }, + { id: 1, name: "B / Circle", icon: Icons.ARROW_LEFT }, + { id: 12, name: "D-Pad Up", icon: Icons.CHEVRON_UP }, + { id: 13, name: "D-Pad Down", icon: Icons.CHEVRON_DOWN }, +]; + +export function GamepadSetupPage() { + const navigate = useNavigate(); + const { t } = useTranslation(); + const [step, setStep] = useState("start"); + const [currentManualButton, setCurrentManualButton] = useState(0); + const [detectedGamepad, setDetectedGamepad] = useState(null); + const [isListening, setIsListening] = useState(false); + + const setGamepadSetupComplete = usePreferencesStore( + (s: any) => s.setGamepadSetupComplete, + ); + const setEnableGamepadControls = usePreferencesStore( + (s: any) => s.setEnableGamepadControls, + ); + + useEffect(() => { + if (!isListening) return; + + const poll = () => { + const gamepads = navigator.getGamepads(); + for (const gp of gamepads) { + if (!gp) continue; + + if (step === "auto") { + setDetectedGamepad(gp.id); + setTimeout(() => { + setStep("success"); + setIsListening(false); + }, 1500); + return; + } + + if (step === "manual") { + const targetButton = MANUAL_BUTTONS[currentManualButton]; + if (gp.buttons[targetButton.id]?.pressed) { + if (currentManualButton < MANUAL_BUTTONS.length - 1) { + setCurrentManualButton((s) => s + 1); + } else { + setStep("success"); + setIsListening(false); + } + return; + } + } + } + requestAnimationFrame(poll); + }; + + const handle = requestAnimationFrame(poll); + return () => cancelAnimationFrame(handle); + }, [step, currentManualButton, isListening]); + + const finishSetup = useCallback(() => { + setGamepadSetupComplete(true); + setEnableGamepadControls(true); + navigate("/settings#settings-preferences"); + }, [navigate, setGamepadSetupComplete, setEnableGamepadControls]); + + return ( + + + + + + {step === "start" && ( + <> + + Setup Controller Support + + + Connect your Xbox or PlayStation controller to navigate P-Stream + with ease. Choose a setup mode below. + + +
+ { + setStep("auto"); + setIsListening(true); + }} + className="flex-1" + > + + Start Auto Setup + + + + { + setStep("manual"); + setIsListening(true); + }} + className="flex-1" + > + + Start Manual Setup + + +
+ + )} + + {step === "auto" && ( +
+
+
+
+ 🎮 +
+
+ + {detectedGamepad ? "Controller Detected!" : "Waiting for Controller..."} + + + {detectedGamepad + ? detectedGamepad + : "Please press any button on your controller to identify it."} + +
+ )} + + {step === "manual" && ( +
+ Verify Button Presses +
+
+ +
+
+ + Press {MANUAL_BUTTONS[currentManualButton].name} + + + Step {currentManualButton + 1} of {MANUAL_BUTTONS.length} + +
+
+
+
+
+
+ )} + + {step === "success" && ( +
+
+ +
+ Setup Complete! + + Your controller is now ready to use. You can navigate the entire + site using the D-Pad and Select button. + + +
+ )} + + + ); +} diff --git a/src/pages/parts/settings/PreferencesPart.tsx b/src/pages/parts/settings/PreferencesPart.tsx index 34a5224da..81d36b477 100644 --- a/src/pages/parts/settings/PreferencesPart.tsx +++ b/src/pages/parts/settings/PreferencesPart.tsx @@ -276,48 +276,98 @@ export function PreferencesPart(props: {
- {/* Keyboard Shortcuts Preference */} + {/* Gamepad Setup & Controls */}

- {t("settings.preferences.keyboardShortcuts")} + {t("settings.preferences.gamepadTitle", "Controller Support")}

-

- {t("settings.preferences.keyboardShortcutsDescription")} -

-
-
- - -
+

- {/* Gamepad Enable Toggle */} -
setEnableGamepadControls(!enableGamepadControls)} - className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg" - > - -

- {t( - "settings.preferences.enableGamepadControls", - "Enable controller support", +

+ {!enableGamepadControls || !usePreferencesStore.getState().gamepadSetupComplete ? ( + + ) : ( + <> +
+ setEnableGamepadControls(!enableGamepadControls) + } + className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none cursor-pointer space-x-3 flex items-center py-3 px-4 rounded-lg border border-white/5" + > + +

+ {t( + "settings.preferences.enableGamepadControls", + "Enabled", + )} +

+
+ +
+ + +
+ +
+

+ Input Mode +

+ + i.id === + usePreferencesStore.getState().gamepadInputMode, + ) || { id: "both", name: "Both (Recommended)" } + } + setSelectedItem={(opt) => + usePreferencesStore + .getState() + .setGamepadInputMode( + opt.id as "controller" | "kbm" | "both", + ) + } + /> +
+ )} -

+
diff --git a/src/setup/App.tsx b/src/setup/App.tsx index aa3cd373b..d88edbbea 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -15,6 +15,7 @@ import { DetailsModal } from "@/components/overlays/detailsModal"; import { GamepadControlsModal } from "@/components/overlays/GamepadControlsModal"; import { KeyboardCommandsEditModal } from "@/components/overlays/KeyboardCommandsEditModal"; import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsModal"; +import { GamepadGlobalListener } from "@/components/gamepad/GamepadGlobalListener"; import { NotificationModal } from "@/components/overlays/notificationsModal"; import { SupportInfoModal } from "@/components/overlays/SupportInfoModal"; import { TraktAuthHandler } from "@/components/TraktAuthHandler"; @@ -51,11 +52,18 @@ import { LanguageProvider } from "@/stores/language"; const DeveloperPage = lazy(() => import("@/pages/DeveloperPage")); const TestView = lazy(() => import("@/pages/developer/TestView")); +const GamepadSetupPage = lazyWithPreload( + () => import("@/pages/GamepadSetup"), + { + factory: (m) => m.GamepadSetupPage, + }, +); const PlayerView = lazyWithPreload(() => import("@/pages/PlayerView")); const SettingsPage = lazyWithPreload(() => import("@/pages/Settings")); PlayerView.preload(); SettingsPage.preload(); +GamepadSetupPage.preload(); function LegacyUrlView({ children }: { children: ReactElement }) { const location = useLocation(); @@ -129,6 +137,7 @@ function App() { return ( + @@ -168,6 +177,7 @@ function App() { /> } /> } /> + } /> } /> } /> } /> diff --git a/src/stores/player/slices/playing.ts b/src/stores/player/slices/playing.ts index 268cd8731..9d9184df4 100644 --- a/src/stores/player/slices/playing.ts +++ b/src/stores/player/slices/playing.ts @@ -27,15 +27,15 @@ export const createPlayingSlice: MakeSlice = (set) => ({ playbackRate: 1, }, play() { - set((state) => { + set((state: any) => { state.mediaPlaying.isPlaying = true; state.mediaPlaying.isPaused = false; }); }, pause() { - set((state) => { + set((state: any) => { state.mediaPlaying.isPlaying = false; - state.mediaPlaying.isPaused = false; + state.mediaPlaying.isPaused = true; }); }, }); diff --git a/src/stores/preferences/index.tsx b/src/stores/preferences/index.tsx index 1c744df18..c7aa96ba9 100644 --- a/src/stores/preferences/index.tsx +++ b/src/stores/preferences/index.tsx @@ -41,6 +41,8 @@ export interface PreferencesStore { enableNumberKeySeeking: boolean; enablePauseOverlay: boolean; enableGamepadControls: boolean; + gamepadSetupComplete: boolean; + gamepadInputMode: "controller" | "kbm" | "both"; gamepadMapping: Record; keyboardShortcuts: KeyboardShortcuts; @@ -77,6 +79,8 @@ export interface PreferencesStore { setEnableNumberKeySeeking(v: boolean): void; setEnablePauseOverlay(v: boolean): void; setEnableGamepadControls(v: boolean): void; + setGamepadSetupComplete(v: boolean): void; + setGamepadInputMode(v: "controller" | "kbm" | "both"): void; setGamepadMapping(v: Record): void; setKeyboardShortcuts(v: KeyboardShortcuts): void; } @@ -117,6 +121,8 @@ export const usePreferencesStore = create( enableNumberKeySeeking: true, enablePauseOverlay: false, enableGamepadControls: false, + gamepadSetupComplete: false, + gamepadInputMode: "both", gamepadMapping: {}, keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, setEnableThumbnails(v) { @@ -289,6 +295,16 @@ export const usePreferencesStore = create( s.enableGamepadControls = v; }); }, + setGamepadSetupComplete(v) { + set((s) => { + s.gamepadSetupComplete = v; + }); + }, + setGamepadInputMode(v) { + set((s) => { + s.gamepadInputMode = v; + }); + }, setGamepadMapping(v) { set((s) => { s.gamepadMapping = v; From 7bfeb20c5ec5f650d7945e8e97094dd2a4413460 Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Mon, 16 Mar 2026 18:09:22 +0100 Subject: [PATCH 40/72] Fixed issues Controller supportt --- src/components/gamepad/GamepadGlobalListener.tsx | 9 ++++++--- src/hooks/useSpatialNavigation.ts | 4 +++- src/pages/GamepadSetup.tsx | 14 +++++++++----- src/pages/parts/settings/PreferencesPart.tsx | 5 +++-- src/setup/App.tsx | 11 ++++------- 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/components/gamepad/GamepadGlobalListener.tsx b/src/components/gamepad/GamepadGlobalListener.tsx index 84162bfb7..b9aa174e1 100644 --- a/src/components/gamepad/GamepadGlobalListener.tsx +++ b/src/components/gamepad/GamepadGlobalListener.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from "react"; +import { useCallback, useEffect } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { useGamepadPolling } from "@/hooks/useGamepad"; @@ -42,11 +42,14 @@ export function GamepadGlobalListener() { (document.activeElement as HTMLElement)?.click(); break; case "back": - navigate(-1); + window.history.back(); + break; + default: + // Do nothing for unknown actions break; } }, - [navigateSpatial, navigate, location.pathname], + [navigateSpatial, navigate, location.pathname, gamepadInputMode], ); useGamepadPolling({ diff --git a/src/hooks/useSpatialNavigation.ts b/src/hooks/useSpatialNavigation.ts index f93832646..d960685ff 100644 --- a/src/hooks/useSpatialNavigation.ts +++ b/src/hooks/useSpatialNavigation.ts @@ -1,4 +1,4 @@ -import React, { useCallback } from "react"; +import { useCallback } from "react"; export type NavigationDirection = "up" | "down" | "left" | "right"; @@ -65,6 +65,8 @@ export function useSpatialNavigation() { case "right": isCorrectDirection = dx > 0 && Math.abs(dy) < Math.abs(dx) * 1.5; break; + default: + break; } if (isCorrectDirection) { diff --git a/src/pages/GamepadSetup.tsx b/src/pages/GamepadSetup.tsx index b8ed4d98f..eb5e698c2 100644 --- a/src/pages/GamepadSetup.tsx +++ b/src/pages/GamepadSetup.tsx @@ -147,19 +147,22 @@ export function GamepadSetupPage() {
- {detectedGamepad ? "Controller Detected!" : "Waiting for Controller..."} + {detectedGamepad + ? "Controller Detected!" + : "Waiting for Controller..."} - {detectedGamepad - ? detectedGamepad - : "Please press any button on your controller to identify it."} + {detectedGamepad || + "Please press any button on your controller to identify it."}
)} {step === "manual" && (
- Verify Button Presses + + Verify Button Presses +
@@ -195,6 +198,7 @@ export function GamepadSetupPage() { site using the D-Pad and Select button.
- {/* Right/Bottom Section - Colors */} -
-
-
-

- {t("settings.appearance.customParts.primary", "Primary")} Color -

-
-
- {primaryOptions.map((opt) => ( - setPrimary(opt.id)} - colorKey1="--colors-type-logo" - /> - ))} + {/* Right Section - Color Pickers */} +
+ {/* Primary */} +
+
+

Primary Color

+
+ {useCustomPrimary ? ( + + ) : ( +
+ {primaryOptions.map((opt) => ( + setPrimary(opt.id)} + colorKey1="--colors-type-logo" + /> + ))} +
+ )}
-
-
-

- {t("settings.appearance.customParts.secondary", "Secondary")}{" "} - Color -

-
-
- {secondaryOptions.map((opt) => ( - setSecondary(opt.id)} - colorKey1="--colors-type-text" - colorKey2="--colors-buttons-secondary" - /> - ))} + {/* Secondary */} +
+
+

Secondary Color

+
+ {useCustomSecondary ? ( + + ) : ( +
+ {secondaryOptions.map((opt) => ( + setSecondary(opt.id)} + colorKey1="--colors-type-text" + colorKey2="--colors-buttons-secondary" + /> + ))} +
+ )}
-
-
-

- {t("settings.appearance.customParts.tertiary", "Tertiary")}{" "} - Color -

-
-
- {tertiaryOptions.map((opt) => ( - setTertiary(opt.id)} - colorKey1="--colors-themePreview-primary" - colorKey2="--colors-themePreview-secondary" - /> - ))} + {/* Tertiary */} +
+
+

Tertiary Color

+
+ {useCustomTertiary ? ( + + ) : ( +
+ {tertiaryOptions.map((opt) => ( + setTertiary(opt.id)} + colorKey1="--colors-themePreview-primary" + colorKey2="--colors-themePreview-secondary" + /> + ))} +
+ )}
From 6be1c4f6e333a04efe8403b1cf1381c1d7fb545b Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Mon, 16 Mar 2026 21:03:25 +0100 Subject: [PATCH 44/72] fixed the erroor in customthememodal --- lint_output.txt | Bin 0 -> 1480 bytes lint_report_final.txt | Bin 0 -> 426 bytes src/components/overlays/CustomThemeModal.tsx | 16 ++++++++++++---- 3 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 lint_output.txt create mode 100644 lint_report_final.txt diff --git a/lint_output.txt b/lint_output.txt new file mode 100644 index 0000000000000000000000000000000000000000..a1deb908a5f77e67e697d8b4df366257487b35ee GIT binary patch literal 1480 zcmb7^?P}CO6o$`l!Fvd0v5IbrA4S+7ez0w!P>NPW#1+}irZKw7hTT-HKfFC}z`O7s z)aO03)1-nF37I)F^PaDF&Y9o8uG=+xWQjeqp}nxyR+ih;DtltPmfC&(hny~~w3^ez z_H1MmYe27i=%L-UXesBU!w*5`_QiEm#x?RCtV&AwSC+Y^v};@19CYd$Ip-t(ZC0Jd zZFWl6jT(wdQDe*&iA+hZ+_mU&_)*b=LXK$sdt#oTP96luNTaAf!F0a0c4 z9-S4wcHPo#_TqN~X$#%J*T6H#+0pry4Q%Eb%3n;BSlPKizr(Zi&R{^y?rfq^v=ytF zM;Ui3ZX>e~;ADN^5f!Y2?nm^^uKJ{L73T8!|7i#v`B7wJV%4n{WV_;i4q@OFIbuRh zbgL+-=8nO&^h1Mw1|O&VI*&fYs&F^|S)Ho6PD9pG|1Q^Azs90}`B1%$HvJfC<`&l8 z*jx4!swwBD`gqyTZJ*xCm>X6l8Ppf?3M)@nysGbsUhn$2y zJa#@y`-F!vXX=eELv@?_L{@@k;7a4td0c^*dCeuPg((=-6|oP6ow*m)e&VdFMpMt@ zhkdu7*gqns1Lm>4=08FTC%W-|$4Y-nezKq(J|nkoSMQNz^;qbjgx>3Wc|{kWPzBL) zf}bVm@M<*7nSEp|JUVrU*bXm=UMIb7x~Ul~%TMgozk4qsd*RoNdKsjnepS5Z*gRvd V@DbjymL0u*wR=!33w!L``~e!#^LGFM literal 0 HcmV?d00001 diff --git a/lint_report_final.txt b/lint_report_final.txt new file mode 100644 index 0000000000000000000000000000000000000000..ca8fa3f4ad157d330bc99be0c7ba1f53c98c8b72 GIT binary patch literal 426 zcmYk2TS@~#6hzOr;2wGb&Oi_m{P2Mf5Q2z)h#*KbV~l2IB$J@7&<(f?_n@BJ8Y4sZ zRQIi0Rky!BS6N}3#3=>m%$QO!pk|j1O4ePQRws;UtR@cF;)*BP-9L6gkA+fYWyvpO zR=nii(!CR}sTwG`rrhSv=ziyo4-V;b%#r_nE?s@mPbGP3%~qSRVQBxIXslb6PtW2R z_uhwN;G}wMtrGQgUAkPb#~CMo%1NJA_CU4dd;{^L`^ess2a~pXE|g>T&N&h{%(GAP t>vL_oaGd8j literal 0 HcmV?d00001 diff --git a/src/components/overlays/CustomThemeModal.tsx b/src/components/overlays/CustomThemeModal.tsx index 21b0cbcac..5d6ab0a6a 100644 --- a/src/components/overlays/CustomThemeModal.tsx +++ b/src/components/overlays/CustomThemeModal.tsx @@ -277,7 +277,9 @@ export function CustomThemeModal(props: { {/* Live Preview */}
-

Live Preview

+

+ Live Preview +

@@ -303,7 +305,9 @@ export function CustomThemeModal(props: { {/* Primary */}
-

Primary Color

+

+ Primary Color +

+
+
+ )} + + {/* Left Section - Identity & Detailed Preview */} +
+
+

+ Create +
+ Your Own +
+ Theme

+
+ +
-
+
setName(e.target.value)} @@ -275,23 +437,46 @@ export function CustomThemeModal(props: { />
- {/* Live Preview */} -
-

- Live Preview -

- + {/* Scale Control & High-Fidelity Preview Container */} +
+
+
+

+ Live Concept Preview +

+
+ + setScale(parseFloat(e.target.value))} + className="w-24 h-1 bg-white/10 rounded-full appearance-none cursor-pointer hover:bg-white/20 transition-all [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full" + /> + + {Math.round(scale * 100)}% + +
+
+ +
+ +
+
-
+ {/* Footer Actions */} +
- {/* Right Section - Color Pickers */} -
- {/* Primary */} -
+ {/* Right Section - Advanced Color Configuration */} +
+ {/* Section: Primary */} +
-

- Primary Color -

+
+

+ Primary Theme +

+

+ Branding & Interaction +

+
{useCustomPrimary ? ( - +
+ +
) : (
{primaryOptions.map((opt) => ( @@ -343,32 +535,47 @@ export function CustomThemeModal(props: { )}
- {/* Secondary */} -
+
+ + {/* Section: Secondary */} +
-

- Secondary Color -

+
+

+ Secondary Details +

+

+ Typography & Elements +

+
{useCustomSecondary ? ( - +
+ +
+ +
) : (
{secondaryOptions.map((opt) => ( @@ -385,32 +592,47 @@ export function CustomThemeModal(props: { )}
- {/* Tertiary */} -
+
+ + {/* Section: Tertiary */} +
-

- Tertiary Color -

+
+

+ Tertiary Base +

+

+ Surfaces & Accents +

+
{useCustomTertiary ? ( - +
+ +
+ +
) : (
{tertiaryOptions.map((opt) => ( From b40c2600d167eb391c7304a26115ea96ff462d3f Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Mon, 16 Mar 2026 22:26:49 +0100 Subject: [PATCH 47/72] Fixed issues i hope for gamepad +live preview --- .../gamepad/GamepadGlobalListener.tsx | 1 - src/components/overlays/CustomThemeModal.tsx | 29 +++++++++---------- src/setup/App.tsx | 2 +- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/components/gamepad/GamepadGlobalListener.tsx b/src/components/gamepad/GamepadGlobalListener.tsx index 3215a10a2..1e457de9e 100644 --- a/src/components/gamepad/GamepadGlobalListener.tsx +++ b/src/components/gamepad/GamepadGlobalListener.tsx @@ -44,7 +44,6 @@ export function GamepadGlobalListener() { window.history.back(); break; default: - // Do nothing for unknown actions break; } }, diff --git a/src/components/overlays/CustomThemeModal.tsx b/src/components/overlays/CustomThemeModal.tsx index 3ee4979c9..2fed8c48f 100644 --- a/src/components/overlays/CustomThemeModal.tsx +++ b/src/components/overlays/CustomThemeModal.tsx @@ -4,17 +4,13 @@ import { useTranslation } from "react-i18next"; import { Button } from "@/components/buttons/Button"; import { Icon, Icons } from "@/components/Icon"; -import { - SavedCustomTheme, - usePreviewThemeStore, - useThemeStore, -} from "@/stores/theme"; +import { SavedCustomTheme, useThemeStore } from "@/stores/theme"; +import { colorToRgbString } from "@/utils/color"; import { primaryOptions, secondaryOptions, tertiaryOptions, } from "@themes/custom"; -import { colorToRgbString } from "@/utils/color"; import { OverlayPortal } from "./OverlayDisplay"; @@ -47,13 +43,13 @@ function DetailedPreview({
{[...Array(20)].map((_, i) => (
))} @@ -144,7 +140,7 @@ function DetailedPreview({
{[...Array(4)].map((_, i) => ( -
+
{/* Micro menu icon mock */} @@ -330,9 +326,8 @@ export function CustomThemeModal(props: { // 3. Tertiary if (useCustomTertiary) { vars["--colors-background-main"] = colorToRgbString(customTertiaryBg); - vars["--colors-themePreview-secondary"] = colorToRgbString( - customTertiaryAccent, - ); + vars["--colors-themePreview-secondary"] = + colorToRgbString(customTertiaryAccent); } else { const opt = tertiaryOptions.find((o) => o.id === tertiary); if (opt) Object.assign(vars, opt.colors); @@ -359,7 +354,9 @@ export function CustomThemeModal(props: { const handleSave = () => { const themeName = name.trim() || "Untitled Theme"; - const id = props.themeToEdit ? props.themeToEdit.id : `custom-${Date.now()}`; + const id = props.themeToEdit + ? props.themeToEdit.id + : `custom-${Date.now()}`; const newTheme: SavedCustomTheme = { id, name: themeName, diff --git a/src/setup/App.tsx b/src/setup/App.tsx index 81728edd5..a9cf90f91 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -53,7 +53,7 @@ import { LanguageProvider } from "@/stores/language"; const DeveloperPage = lazy(() => import("@/pages/DeveloperPage")); const TestView = lazy(() => import("@/pages/developer/TestView")); const GamepadSetupPage = lazyWithPreload(() => - import("@/pages/GamepadSetup").then((m: any) => ({ + import("@/pages/GamepadSetup").then((m: { GamepadSetupPage: any }) => ({ default: m.GamepadSetupPage, })), ); From d4f7abff0efa584e04eaf1a15b5bfba940e98162 Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Mon, 16 Mar 2026 22:34:00 +0100 Subject: [PATCH 48/72] Fixed issue plss --- src/components/overlays/CustomThemeModal.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/overlays/CustomThemeModal.tsx b/src/components/overlays/CustomThemeModal.tsx index 2fed8c48f..64106fb36 100644 --- a/src/components/overlays/CustomThemeModal.tsx +++ b/src/components/overlays/CustomThemeModal.tsx @@ -14,6 +14,10 @@ import { import { OverlayPortal } from "./OverlayDisplay"; +// Stable identifiers for mock UI elements to satisfy linter key unique requirements +const MOCK_STARS = Array.from({ length: 20 }, (_, i) => `star-${i}`); +const MOCK_CARDS = Array.from({ length: 4 }, (_, i) => `card-${i}`); + /** * High-fidelity Detailed Preview Component */ @@ -41,9 +45,9 @@ function DetailedPreview({
{/* Particles / Stars Mock */}
- {[...Array(20)].map((_, i) => ( + {MOCK_STARS.map((id) => (
- {[...Array(4)].map((_, i) => ( -
+ {MOCK_CARDS.map((id) => ( +
{/* Micro menu icon mock */} From d57306892acaa09fa7265c9c49752aa747ca16a7 Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Mon, 16 Mar 2026 23:09:25 +0100 Subject: [PATCH 49/72] Made Live preview better implementing new things --- src/components/overlays/CustomThemeModal.tsx | 201 +++++++++++++++---- src/stores/theme/index.tsx | 52 +++-- 2 files changed, 197 insertions(+), 56 deletions(-) diff --git a/src/components/overlays/CustomThemeModal.tsx b/src/components/overlays/CustomThemeModal.tsx index 64106fb36..b855735fb 100644 --- a/src/components/overlays/CustomThemeModal.tsx +++ b/src/components/overlays/CustomThemeModal.tsx @@ -19,7 +19,67 @@ const MOCK_STARS = Array.from({ length: 20 }, (_, i) => `star-${i}`); const MOCK_CARDS = Array.from({ length: 4 }, (_, i) => `card-${i}`); /** - * High-fidelity Detailed Preview Component + * Symbolic "Card" Preview Component (Lovable style) + */ +function CardPreview({ + vars, + scale = 1, +}: { + vars: Record; + scale?: number; +}) { + return ( +
+
+
+
+ + Primary Color + +
+ + Branding & Interaction + +
+ +
+
+
+ + Secondary Color + +
+ + Typography & Elements + +
+ +
+
+
+ + Tertiary Color + +
+ + Surfaces & Accents + +
+
+ ); +} + +/** + * High-fidelity Detailed Preview Component (P-Stream Home) */ function DetailedPreview({ vars, @@ -61,43 +121,56 @@ function DetailedPreview({
{/* Navbar Mock */} -
-
+
+
{/* Logo Symbol */} -
-
- +
+
+
P-Stream
- {/* Nav Icons */} -
+ {/* Nav Icons mimicking Picture 4 */} +
+
+ +
-
-
- +
+
+
+
+
+ +
{/* Hero Section */} -
+
{/* Top floating icons mock (Discord etc) */} -
+
+ + -
-
-

+

What would you like to
watch tonight? @@ -105,28 +178,28 @@ function DetailedPreview({

{/* Search Bar Mock */} -
-
+
+
-
+
What do you want to watch?
{/* Category Tabs */} -
+
{["Movies", "TV Shows", "Editor Picks"].map((tab, i) => (
{tab} {i === 0 && ( -
+
)}
))} @@ -134,26 +207,32 @@ function DetailedPreview({
{/* Content Grid Mock */} -
-
-

+
+
+

Most Popular - +
+ View more + +

-
+
{MOCK_CARDS.map((id) => ( -
-
-
+
+
+
{/* Micro menu icon mock */} -
- +
+ +
+ {/* Poster Mock Art */} +
+
+
-
-
))}
@@ -276,6 +355,7 @@ export function CustomThemeModal(props: { const [customTertiaryAccent, setCustomTertiaryAccent] = useState("#1A1A1E"); const [wasShown, setWasShown] = useState(false); + const [previewMode, setPreviewMode] = useState<"detailed" | "card">("detailed"); useEffect(() => { if (props.isShown && !wasShown) { @@ -295,6 +375,7 @@ export function CustomThemeModal(props: { setUseCustomSecondary(false); setUseCustomTertiary(false); setIsFullPreview(false); + setPreviewMode("detailed"); setScale(0.8); } else if (!props.isShown && wasShown) { setWasShown(false); @@ -388,13 +469,17 @@ export function CustomThemeModal(props: {
{/* Full Modal Live Preview Overlay */} {isFullPreview && ( -
-
- +
+
+ {previewMode === "detailed" ? ( + + ) : ( + + )}

+
+ + +
-
- +
+ {previewMode === "detailed" ? ( + + ) : ( + + )}
diff --git a/src/stores/theme/index.tsx b/src/stores/theme/index.tsx index 70ee57e3a..6d9b7d538 100644 --- a/src/stores/theme/index.tsx +++ b/src/stores/theme/index.tsx @@ -10,6 +10,8 @@ import { tertiaryOptions, } from "@themes/custom"; +import { colorToRgbString } from "@/utils/color"; + export interface SavedCustomTheme { id: string; name: string; @@ -139,15 +141,34 @@ export function ThemeProvider(props: { const themeToDisplay = previewTheme ?? theme; const themeSelector = themeToDisplay ? `theme-${themeToDisplay}` : undefined; + const parseCustomColor = ( + colorStr: string, + keys: string[], + ): Record => { + if (!colorStr.startsWith("custom:")) return {}; + const values = colorStr.replace("custom:", "").split(","); + const vars: Record = {}; + keys.forEach((key, i) => { + if (values[i]) { + vars[key] = colorToRgbString(values[i]); + } + }); + return vars; + }; + let styleContent = ""; if (themeToDisplay === "custom" && customTheme) { - const primary = - primaryOptions.find((o) => o.id === customTheme.primary)?.colors || {}; - const secondary = - secondaryOptions.find((o) => o.id === customTheme.secondary)?.colors || - {}; - const tertiary = - tertiaryOptions.find((o) => o.id === customTheme.tertiary)?.colors || {}; + const primary = customTheme.primary.startsWith("custom:") + ? parseCustomColor(customTheme.primary, ["--colors-buttons-primary", "--colors-type-logo", "--colors-themePreview-primary"]) + : primaryOptions.find((o) => o.id === customTheme.primary)?.colors || {}; + + const secondary = customTheme.secondary.startsWith("custom:") + ? parseCustomColor(customTheme.secondary, ["--colors-type-text", "--colors-buttons-secondary"]) + : secondaryOptions.find((o) => o.id === customTheme.secondary)?.colors || {}; + + const tertiary = customTheme.tertiary.startsWith("custom:") + ? parseCustomColor(customTheme.tertiary, ["--colors-background-main", "--colors-themePreview-secondary"]) + : tertiaryOptions.find((o) => o.id === customTheme.tertiary)?.colors || {}; const vars = { ...primary, ...secondary, ...tertiary }; const cssVars = Object.entries(vars) @@ -159,12 +180,17 @@ export function ThemeProvider(props: { // Inject CSS rules for all saved custom themes so their previews render correctly savedCustomThemes.forEach((savedTheme) => { - const primary = - primaryOptions.find((o) => o.id === savedTheme.primary)?.colors || {}; - const secondary = - secondaryOptions.find((o) => o.id === savedTheme.secondary)?.colors || {}; - const tertiary = - tertiaryOptions.find((o) => o.id === savedTheme.tertiary)?.colors || {}; + const primary = savedTheme.primary.startsWith("custom:") + ? parseCustomColor(savedTheme.primary, ["--colors-buttons-primary", "--colors-type-logo", "--colors-themePreview-primary"]) + : primaryOptions.find((o) => o.id === savedTheme.primary)?.colors || {}; + + const secondary = savedTheme.secondary.startsWith("custom:") + ? parseCustomColor(savedTheme.secondary, ["--colors-type-text", "--colors-buttons-secondary"]) + : secondaryOptions.find((o) => o.id === savedTheme.secondary)?.colors || {}; + + const tertiary = savedTheme.tertiary.startsWith("custom:") + ? parseCustomColor(savedTheme.tertiary, ["--colors-background-main", "--colors-themePreview-secondary"]) + : tertiaryOptions.find((o) => o.id === savedTheme.tertiary)?.colors || {}; const vars = { ...primary, ...secondary, ...tertiary }; const cssVars = Object.entries(vars) From 4c9e570d3cc79a5436fd2f2739fe73c2876458db Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Mon, 16 Mar 2026 23:50:13 +0100 Subject: [PATCH 50/72] Issues fixed LP --- src/components/overlays/CustomThemeModal.tsx | 26 +++++----- src/stores/theme/index.tsx | 52 ++++++++++++++------ 2 files changed, 49 insertions(+), 29 deletions(-) diff --git a/src/components/overlays/CustomThemeModal.tsx b/src/components/overlays/CustomThemeModal.tsx index b855735fb..6dd7f0138 100644 --- a/src/components/overlays/CustomThemeModal.tsx +++ b/src/components/overlays/CustomThemeModal.tsx @@ -143,7 +143,7 @@ function DetailedPreview({
-
+
@@ -156,18 +156,12 @@ function DetailedPreview({
{/* Top floating icons mock (Discord etc) */}
- - - + +

@@ -194,7 +188,9 @@ function DetailedPreview({ key={tab} className={classNames( "text-sm font-black transition-all relative pb-4 tracking-tight", - i === 0 ? "text-themePreview-primary scale-110" : "text-type-text/30", + i === 0 + ? "text-themePreview-primary scale-110" + : "text-type-text/30", )} > {tab} @@ -229,8 +225,8 @@ function DetailedPreview({

{/* Poster Mock Art */}
-
-
+
+
@@ -355,7 +351,9 @@ export function CustomThemeModal(props: { const [customTertiaryAccent, setCustomTertiaryAccent] = useState("#1A1A1E"); const [wasShown, setWasShown] = useState(false); - const [previewMode, setPreviewMode] = useState<"detailed" | "card">("detailed"); + const [previewMode, setPreviewMode] = useState<"detailed" | "card">( + "detailed", + ); useEffect(() => { if (props.isShown && !wasShown) { diff --git a/src/stores/theme/index.tsx b/src/stores/theme/index.tsx index 6d9b7d538..4a5fa2654 100644 --- a/src/stores/theme/index.tsx +++ b/src/stores/theme/index.tsx @@ -4,14 +4,13 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { immer } from "zustand/middleware/immer"; +import { colorToRgbString } from "@/utils/color"; import { primaryOptions, secondaryOptions, tertiaryOptions, } from "@themes/custom"; -import { colorToRgbString } from "@/utils/color"; - export interface SavedCustomTheme { id: string; name: string; @@ -159,16 +158,28 @@ export function ThemeProvider(props: { let styleContent = ""; if (themeToDisplay === "custom" && customTheme) { const primary = customTheme.primary.startsWith("custom:") - ? parseCustomColor(customTheme.primary, ["--colors-buttons-primary", "--colors-type-logo", "--colors-themePreview-primary"]) + ? parseCustomColor(customTheme.primary, [ + "--colors-buttons-primary", + "--colors-type-logo", + "--colors-themePreview-primary", + ]) : primaryOptions.find((o) => o.id === customTheme.primary)?.colors || {}; - + const secondary = customTheme.secondary.startsWith("custom:") - ? parseCustomColor(customTheme.secondary, ["--colors-type-text", "--colors-buttons-secondary"]) - : secondaryOptions.find((o) => o.id === customTheme.secondary)?.colors || {}; - + ? parseCustomColor(customTheme.secondary, [ + "--colors-type-text", + "--colors-buttons-secondary", + ]) + : secondaryOptions.find((o) => o.id === customTheme.secondary)?.colors || + {}; + const tertiary = customTheme.tertiary.startsWith("custom:") - ? parseCustomColor(customTheme.tertiary, ["--colors-background-main", "--colors-themePreview-secondary"]) - : tertiaryOptions.find((o) => o.id === customTheme.tertiary)?.colors || {}; + ? parseCustomColor(customTheme.tertiary, [ + "--colors-background-main", + "--colors-themePreview-secondary", + ]) + : tertiaryOptions.find((o) => o.id === customTheme.tertiary)?.colors || + {}; const vars = { ...primary, ...secondary, ...tertiary }; const cssVars = Object.entries(vars) @@ -181,15 +192,26 @@ export function ThemeProvider(props: { // Inject CSS rules for all saved custom themes so their previews render correctly savedCustomThemes.forEach((savedTheme) => { const primary = savedTheme.primary.startsWith("custom:") - ? parseCustomColor(savedTheme.primary, ["--colors-buttons-primary", "--colors-type-logo", "--colors-themePreview-primary"]) + ? parseCustomColor(savedTheme.primary, [ + "--colors-buttons-primary", + "--colors-type-logo", + "--colors-themePreview-primary", + ]) : primaryOptions.find((o) => o.id === savedTheme.primary)?.colors || {}; - + const secondary = savedTheme.secondary.startsWith("custom:") - ? parseCustomColor(savedTheme.secondary, ["--colors-type-text", "--colors-buttons-secondary"]) - : secondaryOptions.find((o) => o.id === savedTheme.secondary)?.colors || {}; - + ? parseCustomColor(savedTheme.secondary, [ + "--colors-type-text", + "--colors-buttons-secondary", + ]) + : secondaryOptions.find((o) => o.id === savedTheme.secondary)?.colors || + {}; + const tertiary = savedTheme.tertiary.startsWith("custom:") - ? parseCustomColor(savedTheme.tertiary, ["--colors-background-main", "--colors-themePreview-secondary"]) + ? parseCustomColor(savedTheme.tertiary, [ + "--colors-background-main", + "--colors-themePreview-secondary", + ]) : tertiaryOptions.find((o) => o.id === savedTheme.tertiary)?.colors || {}; const vars = { ...primary, ...secondary, ...tertiary }; From e31f8c39ae679ccf61a8af2ae1e699ff4aa581ec Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Tue, 17 Mar 2026 14:44:13 +0100 Subject: [PATCH 51/72] Old version was bad new live preview --- src/components/overlays/CustomThemeModal.tsx | 12 +----------- src/stores/theme/index.tsx | 5 +++-- themes/custom.ts | 2 +- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/src/components/overlays/CustomThemeModal.tsx b/src/components/overlays/CustomThemeModal.tsx index 6dd7f0138..99957ea25 100644 --- a/src/components/overlays/CustomThemeModal.tsx +++ b/src/components/overlays/CustomThemeModal.tsx @@ -154,16 +154,6 @@ function DetailedPreview({ {/* Hero Section */}
- {/* Top floating icons mock (Discord etc) */} -
- - - -
-

What would you like to
@@ -464,7 +454,7 @@ export function CustomThemeModal(props: { return ( -
+
{/* Full Modal Live Preview Overlay */} {isFullPreview && (
diff --git a/src/stores/theme/index.tsx b/src/stores/theme/index.tsx index 4a5fa2654..360e8a3e4 100644 --- a/src/stores/theme/index.tsx +++ b/src/stores/theme/index.tsx @@ -148,8 +148,9 @@ export function ThemeProvider(props: { const values = colorStr.replace("custom:", "").split(","); const vars: Record = {}; keys.forEach((key, i) => { - if (values[i]) { - vars[key] = colorToRgbString(values[i]); + const val = values[i] || values[0]; + if (val) { + vars[key] = colorToRgbString(val); } }); return vars; diff --git a/themes/custom.ts b/themes/custom.ts index 6e7cdb61c..ebaca0418 100644 --- a/themes/custom.ts +++ b/themes/custom.ts @@ -70,6 +70,7 @@ const parts = { "type.linkHover", "largeCard.icon", "mediaCard.barFillColor", + "themePreview.primary", ], secondary: [ "type.text", @@ -173,7 +174,6 @@ const parts = { "onboarding.card", "onboarding.cardHover", "errors.card", - "themePreview.primary", "themePreview.secondary", "themePreview.ghost", "video.scraping.card", From 2515b41e4c29b4356441433cbeec429694181085 Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Tue, 17 Mar 2026 15:03:11 +0100 Subject: [PATCH 52/72] Tertiary fix --- src/components/overlays/CustomThemeModal.tsx | 75 +++++++++++++++++--- src/utils/color.ts | 11 +++ 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/components/overlays/CustomThemeModal.tsx b/src/components/overlays/CustomThemeModal.tsx index 99957ea25..6228ad96c 100644 --- a/src/components/overlays/CustomThemeModal.tsx +++ b/src/components/overlays/CustomThemeModal.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { Button } from "@/components/buttons/Button"; import { Icon, Icons } from "@/components/Icon"; import { SavedCustomTheme, useThemeStore } from "@/stores/theme"; -import { colorToRgbString } from "@/utils/color"; +import { colorToRgbString, rgbStringToHex } from "@/utils/color"; import { primaryOptions, secondaryOptions, @@ -339,6 +339,8 @@ export function CustomThemeModal(props: { const [customSecondaryButton, setCustomSecondaryButton] = useState("#1A1A1E"); const [customTertiaryBg, setCustomTertiaryBg] = useState("#0C0C0F"); const [customTertiaryAccent, setCustomTertiaryAccent] = useState("#1A1A1E"); + + const [unlockMainBg, setUnlockMainBg] = useState(false); const [wasShown, setWasShown] = useState(false); const [previewMode, setPreviewMode] = useState<"detailed" | "card">( @@ -362,6 +364,7 @@ export function CustomThemeModal(props: { setUseCustomPrimary(false); setUseCustomSecondary(false); setUseCustomTertiary(false); + setUnlockMainBg(false); setIsFullPreview(false); setPreviewMode("detailed"); setScale(0.8); @@ -604,7 +607,17 @@ export function CustomThemeModal(props: {

{useCustomTertiary ? (
- + {!unlockMainBg ? ( +
+ +

+ Main Background Locked +
+ Prevents entire website color change +

+ +
+ ) : ( + + )}
= 3) { + const r = parseInt(parts[0], 10).toString(16).padStart(2, "0"); + const g = parseInt(parts[1], 10).toString(16).padStart(2, "0"); + const b = parseInt(parts[2], 10).toString(16).padStart(2, "0"); + return `#${r}${g}${b}`; + } + return "#000000"; // fallback +} From 60512048fa9cfbad2972711f4a58e8e88042711a Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Tue, 17 Mar 2026 16:19:21 +0100 Subject: [PATCH 53/72] Made changes on Live preview + New changes on pauseoverlay --- src/components/overlays/CustomThemeModal.tsx | 39 +++++++--- .../player/overlays/PauseOverlay.tsx | 66 ++++++++++++---- src/hooks/useSettingsState.ts | 42 ++++++++++ src/pages/Settings.tsx | 6 ++ src/pages/parts/settings/AppearancePart.tsx | 78 ++++++++++++++++++- src/stores/preferences/index.tsx | 24 ++++++ 6 files changed, 230 insertions(+), 25 deletions(-) diff --git a/src/components/overlays/CustomThemeModal.tsx b/src/components/overlays/CustomThemeModal.tsx index 6228ad96c..1f142296f 100644 --- a/src/components/overlays/CustomThemeModal.tsx +++ b/src/components/overlays/CustomThemeModal.tsx @@ -339,7 +339,7 @@ export function CustomThemeModal(props: { const [customSecondaryButton, setCustomSecondaryButton] = useState("#1A1A1E"); const [customTertiaryBg, setCustomTertiaryBg] = useState("#0C0C0F"); const [customTertiaryAccent, setCustomTertiaryAccent] = useState("#1A1A1E"); - + const [unlockMainBg, setUnlockMainBg] = useState(false); const [wasShown, setWasShown] = useState(false); @@ -612,7 +612,10 @@ export function CustomThemeModal(props: { const opt = primaryOptions.find((o) => o.id === primary); if (opt) { setCustomPrimary( - rgbStringToHex(opt.colors["--colors-themePreview-primary"] || "0 0 0"), + rgbStringToHex( + opt.colors["--colors-themePreview-primary"] || + "0 0 0", + ), ); } } @@ -669,13 +672,19 @@ export function CustomThemeModal(props: { type="button" onClick={() => { if (!useCustomSecondary) { - const opt = secondaryOptions.find((o) => o.id === secondary); + const opt = secondaryOptions.find( + (o) => o.id === secondary, + ); if (opt) { setCustomSecondaryText( - rgbStringToHex(opt.colors["--colors-type-text"] || "0 0 0"), + rgbStringToHex( + opt.colors["--colors-type-text"] || "0 0 0", + ), ); setCustomSecondaryButton( - rgbStringToHex(opt.colors["--colors-buttons-secondary"] || "0 0 0"), + rgbStringToHex( + opt.colors["--colors-buttons-secondary"] || "0 0 0", + ), ); } } @@ -742,10 +751,15 @@ export function CustomThemeModal(props: { const opt = tertiaryOptions.find((o) => o.id === tertiary); if (opt) { setCustomTertiaryBg( - rgbStringToHex(opt.colors["--colors-background-main"] || "0 0 0"), + rgbStringToHex( + opt.colors["--colors-background-main"] || "0 0 0", + ), ); setCustomTertiaryAccent( - rgbStringToHex(opt.colors["--colors-themePreview-secondary"] || "0 0 0"), + rgbStringToHex( + opt.colors["--colors-themePreview-secondary"] || + "0 0 0", + ), ); } } @@ -766,11 +780,16 @@ export function CustomThemeModal(props: {
{!unlockMainBg ? (
- +

Main Background Locked -
- Prevents entire website color change +
+ + Prevents entire website color change +

- -
-
-<<<<<<< HEAD {/* Right Section - Advanced Color Configuration */}
{/* Section: Primary */} @@ -787,36 +640,6 @@ export function CustomThemeModal(props: { onChange={setCustomPrimary} />
-======= - {/* Right Section - Color Pickers */} -
- {/* Primary */} -
-
-

- {t("settings.appearance.customTheme.primaryColor")} -

- -
- {useCustomPrimary ? ( - ->>>>>>> 2b486a55be0d924065597078eed63478fc07278a ) : (
{primaryOptions.map((opt) => ( @@ -832,7 +655,6 @@ export function CustomThemeModal(props: { )}
-<<<<<<< HEAD
{/* Section: Secondary */} @@ -893,34 +715,6 @@ export function CustomThemeModal(props: { onChange={setCustomSecondaryButton} />
-======= - {/* Secondary */} -
-
-

- {t("settings.appearance.customTheme.secondaryColor")} -

- -
- {useCustomSecondary ? ( - ->>>>>>> 2b486a55be0d924065597078eed63478fc07278a ) : (
{secondaryOptions.map((opt) => ( @@ -937,7 +731,6 @@ export function CustomThemeModal(props: { )}
-<<<<<<< HEAD
{/* Section: Tertiary */} @@ -1020,34 +813,6 @@ export function CustomThemeModal(props: { onChange={setCustomTertiaryAccent} />
-======= - {/* Tertiary */} -
-
-

- {t("settings.appearance.customTheme.tertiaryColor")} -

- -
- {useCustomTertiary ? ( - ->>>>>>> 2b486a55be0d924065597078eed63478fc07278a ) : (
{tertiaryOptions.map((opt) => ( @@ -1056,13 +821,8 @@ export function CustomThemeModal(props: { opt={opt} selected={tertiary === opt.id} onClick={() => setTertiary(opt.id)} -<<<<<<< HEAD colorKey1="--colors-themePreview-primary" colorKey2="--colors-themePreview-secondary" -======= - colorKey1="--colors-background-main" - colorKey2="--colors-modal-background" ->>>>>>> 2b486a55be0d924065597078eed63478fc07278a /> ))}
diff --git a/src/pages/parts/settings/PreferencesPart.tsx b/src/pages/parts/settings/PreferencesPart.tsx index a7d148009..fdf35cb4e 100644 --- a/src/pages/parts/settings/PreferencesPart.tsx +++ b/src/pages/parts/settings/PreferencesPart.tsx @@ -370,43 +370,6 @@ export function PreferencesPart(props: { )}
-<<<<<<< HEAD -======= -
- - -
- - {/* Gamepad Enable Toggle */} -
setEnableGamepadControls(!enableGamepadControls)} - className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg" - > - -

- {t( - "settings.preferences.enableGamepadControls", - "Enable controller support", - )} -

-
->>>>>>> 2b486a55be0d924065597078eed63478fc07278a
{/* Column */} diff --git a/src/stores/preferences/index.tsx b/src/stores/preferences/index.tsx index cd99e934d..b6a5e0992 100644 --- a/src/stores/preferences/index.tsx +++ b/src/stores/preferences/index.tsx @@ -40,16 +40,12 @@ export interface PreferencesStore { enableAutoResumeOnPlaybackError: boolean; enableNumberKeySeeking: boolean; enablePauseOverlay: boolean; -<<<<<<< HEAD pauseOverlayInactivityTime: number; enablePauseOverlayHoverHide: boolean; timeFormat12Hour: boolean | null; enableGamepadControls: boolean; gamepadSetupComplete: boolean; gamepadInputMode: "controller" | "kbm" | "both"; -======= - enableGamepadControls: boolean; ->>>>>>> 2b486a55be0d924065597078eed63478fc07278a gamepadMapping: Record; keyboardShortcuts: KeyboardShortcuts; @@ -85,16 +81,12 @@ export interface PreferencesStore { setEnableAutoResumeOnPlaybackError(v: boolean): void; setEnableNumberKeySeeking(v: boolean): void; setEnablePauseOverlay(v: boolean): void; -<<<<<<< HEAD setPauseOverlayInactivityTime(v: number): void; setEnablePauseOverlayHoverHide(v: boolean): void; setTimeFormat12Hour(v: boolean | null): void; setEnableGamepadControls(v: boolean): void; setGamepadSetupComplete(v: boolean): void; setGamepadInputMode(v: "controller" | "kbm" | "both"): void; -======= - setEnableGamepadControls(v: boolean): void; ->>>>>>> 2b486a55be0d924065597078eed63478fc07278a setGamepadMapping(v: Record): void; setKeyboardShortcuts(v: KeyboardShortcuts): void; } @@ -134,16 +126,12 @@ export const usePreferencesStore = create( enableAutoResumeOnPlaybackError: true, enableNumberKeySeeking: true, enablePauseOverlay: false, -<<<<<<< HEAD pauseOverlayInactivityTime: 2, enablePauseOverlayHoverHide: false, timeFormat12Hour: null, enableGamepadControls: false, gamepadSetupComplete: false, gamepadInputMode: "both", -======= - enableGamepadControls: false, ->>>>>>> 2b486a55be0d924065597078eed63478fc07278a gamepadMapping: {}, keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, setEnableThumbnails(v) { @@ -311,7 +299,6 @@ export const usePreferencesStore = create( s.enablePauseOverlay = v; }); }, -<<<<<<< HEAD setPauseOverlayInactivityTime(v) { set((s) => { s.pauseOverlayInactivityTime = v; @@ -327,14 +314,11 @@ export const usePreferencesStore = create( s.timeFormat12Hour = v; }); }, -======= ->>>>>>> 2b486a55be0d924065597078eed63478fc07278a setEnableGamepadControls(v) { set((s) => { s.enableGamepadControls = v; }); }, -<<<<<<< HEAD setGamepadSetupComplete(v) { set((s) => { s.gamepadSetupComplete = v; @@ -345,8 +329,6 @@ export const usePreferencesStore = create( s.gamepadInputMode = v; }); }, -======= ->>>>>>> 2b486a55be0d924065597078eed63478fc07278a setGamepadMapping(v) { set((s) => { s.gamepadMapping = v; diff --git a/vite.config.mts b/vite.config.mts index f316dbb0d..412480254 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -4,10 +4,6 @@ import loadVersion from "vite-plugin-package-version"; import { VitePWA } from "vite-plugin-pwa"; import checker from "vite-plugin-checker"; import path from "path"; -<<<<<<< HEAD - -======= ->>>>>>> 2b486a55be0d924065597078eed63478fc07278a import { handlebars } from "./plugins/handlebars"; import { PluginOption, loadEnv, splitVendorChunkPlugin } from "vite"; import { visualizer } from "rollup-plugin-visualizer"; @@ -29,10 +25,6 @@ export default defineConfig(({ mode }) => { return { base: env.VITE_BASE_URL || "/", plugins: [ -<<<<<<< HEAD - -======= ->>>>>>> 2b486a55be0d924065597078eed63478fc07278a handlebars({ vars: { opensearchEnabled: env.VITE_OPENSEARCH_ENABLED === "true", From be3c47abdbb05cee3d1d7e0916931c20dcad06b4 Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Sat, 21 Mar 2026 21:38:55 +0100 Subject: [PATCH 63/72] errors fixed --- src/stores/player/slices/source.ts | 5 ++--- src/utils/autoplay.ts | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index 3738796cc..cd852c354 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/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, ); } From 0e558390b9a8478c1ec55ca0ed066d320f40f7f2 Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Sat, 21 Mar 2026 21:51:32 +0100 Subject: [PATCH 64/72] Removed duplicates because of merge :/ --- src/components/player/overlays/PauseOverlay.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/player/overlays/PauseOverlay.tsx b/src/components/player/overlays/PauseOverlay.tsx index 551d920a7..212fb14d8 100644 --- a/src/components/player/overlays/PauseOverlay.tsx +++ b/src/components/player/overlays/PauseOverlay.tsx @@ -20,7 +20,6 @@ interface PauseDetails { voteAverage: number | null; genres: string[]; runtime: number | null; - runtime: number | null; } export function PauseOverlay() { @@ -165,7 +164,6 @@ export function PauseOverlay() { let mounted = true; const fetchDetails = async () => { if (!meta?.tmdbId) { - setDetails({ voteAverage: null, genres: [], runtime: null }); setDetails({ voteAverage: null, genres: [], runtime: null }); return; } @@ -209,8 +207,6 @@ export function PauseOverlay() { } catch { if (mounted) setDetails({ voteAverage: null, genres: [], runtime: null }); - if (mounted) - setDetails({ voteAverage: null, genres: [], runtime: null }); } }; From 3aa76f0370f2310a2d49df13f582aa590c5c8d4f Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Sat, 21 Mar 2026 22:13:42 +0100 Subject: [PATCH 65/72] Added Keyboard Shortcuts In prefrences --- src/pages/parts/settings/PreferencesPart.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/pages/parts/settings/PreferencesPart.tsx b/src/pages/parts/settings/PreferencesPart.tsx index fdf35cb4e..a148fb64f 100644 --- a/src/pages/parts/settings/PreferencesPart.tsx +++ b/src/pages/parts/settings/PreferencesPart.tsx @@ -276,6 +276,23 @@ export function PreferencesPart(props: {
+ {/* Keyboard Shortcuts Preference */} +
+

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

+

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

+ +
+ {/* Gamepad Setup & Controls */}

From 4d515023d7924d2f470917216c61b335a99d7266 Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Sat, 21 Mar 2026 22:20:18 +0100 Subject: [PATCH 66/72] Kbm button too wide - fixed it --- src/pages/parts/settings/PreferencesPart.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/pages/parts/settings/PreferencesPart.tsx b/src/pages/parts/settings/PreferencesPart.tsx index a148fb64f..c2f02fa79 100644 --- a/src/pages/parts/settings/PreferencesPart.tsx +++ b/src/pages/parts/settings/PreferencesPart.tsx @@ -284,14 +284,13 @@ export function PreferencesPart(props: {

{t("settings.preferences.keyboardShortcutsDescription")}

-
+ {/* Gamepad Setup & Controls */}
From dbab3222cd731d7537365b48030705e57fdf2330 Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Sun, 22 Mar 2026 00:38:40 +0100 Subject: [PATCH 67/72] Test Controller Support 1 --- src/assets/css/index.css | 2 +- src/components/buttons/Toggle.tsx | 2 +- .../gamepad/GamepadGlobalListener.tsx | 21 ++- src/components/media/MediaCard.tsx | 25 +++- .../overlays/MediaControllerMenu.tsx | 129 ++++++++++++++++++ src/components/overlays/Modal.tsx | 10 +- src/hooks/useSpatialNavigation.ts | 60 ++++++-- src/pages/GamepadSetup.tsx | 12 +- src/pages/onboarding/utils.tsx | 8 +- src/setup/App.tsx | 2 + 10 files changed, 241 insertions(+), 30 deletions(-) create mode 100644 src/components/overlays/MediaControllerMenu.tsx diff --git a/src/assets/css/index.css b/src/assets/css/index.css index d0ce4215e..7e25d60e5 100644 --- a/src/assets/css/index.css +++ b/src/assets/css/index.css @@ -273,7 +273,7 @@ input[type="range"].styled-slider.slider-progress::-ms-fill-lower { } /* Gamepad specific focus - Premium TV Experience */ -.gamepad-active *:focus-visible { +.gamepad-active *:focus { outline: 4px solid #ffffff !important; outline-offset: 2px !important; box-shadow: 0 0 20px rgba(255, 255, 255, 0.5) !important; diff --git a/src/components/buttons/Toggle.tsx b/src/components/buttons/Toggle.tsx index 70c765536..c3483a5cb 100644 --- a/src/components/buttons/Toggle.tsx +++ b/src/components/buttons/Toggle.tsx @@ -11,7 +11,7 @@ export function Toggle(props: { onClick={props.disabled ? undefined : props.onClick} disabled={props.disabled} className={classNames( - "w-11 h-6 p-1 rounded-full grid transition-colors duration-100 group/toggle tabbable", + "w-11 h-6 p-1 rounded-full grid transition-colors duration-100 group/toggle tabbable focus:outline-none", props.enabled ? "bg-buttons-toggle" : "bg-buttons-toggleDisabled", props.disabled ? "opacity-50 cursor-not-allowed" : null, )} diff --git a/src/components/gamepad/GamepadGlobalListener.tsx b/src/components/gamepad/GamepadGlobalListener.tsx index 1e457de9e..78bd6cf6d 100644 --- a/src/components/gamepad/GamepadGlobalListener.tsx +++ b/src/components/gamepad/GamepadGlobalListener.tsx @@ -3,11 +3,14 @@ import { useLocation } from "react-router-dom"; import { useGamepadPolling } from "@/hooks/useGamepad"; import { useSpatialNavigation } from "@/hooks/useSpatialNavigation"; +import { useOverlayStack } from "@/stores/interface/overlayStack"; import { usePreferencesStore } from "@/stores/preferences"; export function GamepadGlobalListener() { const { navigate: navigateSpatial } = useSpatialNavigation(); const location = useLocation(); + const { hideModal, getTopModal } = useOverlayStack(); + const enableGamepadControls = usePreferencesStore( (s: any) => s.enableGamepadControls, ); @@ -40,14 +43,26 @@ export function GamepadGlobalListener() { case "confirm": (document.activeElement as HTMLElement)?.click(); break; - case "back": - window.history.back(); + case "back": { + const topModal = getTopModal(); + if (topModal) { + hideModal(topModal); + } else { + window.history.back(); + } break; + } default: break; } }, - [navigateSpatial, location.pathname, gamepadInputMode], + [ + navigateSpatial, + location.pathname, + gamepadInputMode, + hideModal, + getTopModal, + ], ); useGamepadPolling({ diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx index 6ee141f07..14dd812e0 100644 --- a/src/components/media/MediaCard.tsx +++ b/src/components/media/MediaCard.tsx @@ -187,10 +187,9 @@ function MediaCardContent({ return (
}> e.key === "Enter" && e.currentTarget.click()} > state.enableDetailsModal, ); + const enableGamepadControls = usePreferencesStore( + (state: any) => state.enableGamepadControls, + ); const isReleased = useCallback( () => checkReleased(props.media), @@ -454,6 +456,19 @@ export function MediaCard(props: MediaCardProps) { }; const handleCardClick = (e: React.MouseEvent) => { + if ( + document.body.classList.contains("gamepad-active") && + enableGamepadControls + ) { + e.preventDefault(); + showModal("media-controller-menu", { + id: Number(media.id), + type: media.type === "movie" ? "movie" : "show", + media, + series: props.series, + }); + return; + } if (enableDetailsModal && canLink) { e.preventDefault(); handleShowDetails(); @@ -605,9 +620,9 @@ export function MediaCard(props: MediaCardProps) { return ( { + let link = `/media/${encodeURIComponent(mediaItemToId(media))}`; + // Handle series episode linking if data available (simple version for now) + const series = data?.series; + if (series) { + if (series.season === 0 && !series.episodeId) { + link += `/${encodeURIComponent(series.seasonId)}`; + } else { + link += `/${encodeURIComponent(series.seasonId)}/${encodeURIComponent( + series.episodeId, + )}`; + } + } + + hideModal(modal.id); + navigate(link); + }; + + const handleShowDetails = () => { + hideModal(modal.id); + // Open the actual details modal + useOverlayStack.getState().showModal("details", { + id: Number(media.id), + type: media.type === "movie" ? "movie" : "show", + }); + }; + + const handleToggleBookmark = () => { + if (isBookmarked) { + removeBookmark(media.id); + } else { + addBookmarkWithGroups( + { + type: media.type, + title: media.title, + tmdbId: media.id, + releaseYear: media.year || 0, + poster: media.poster, + }, + [], + ); + } + }; + + return ( + + +
+
+ {media.title} + + {media.year} • {t(`media.types.${media.type}`)} + + +
+ + + + + + + +
+
+ + + ); +} diff --git a/src/components/overlays/Modal.tsx b/src/components/overlays/Modal.tsx index 37fbab732..abd7aef43 100644 --- a/src/components/overlays/Modal.tsx +++ b/src/components/overlays/Modal.tsx @@ -55,7 +55,10 @@ export function Modal(props: { id: string; children?: ReactNode }) { -
+
{props.children}
@@ -92,7 +95,10 @@ export function FancyModal(props: { -
+
{ + const topModalId = useOverlayStack.getState().getTopModal(); const all = Array.from( document.querySelectorAll(FOCUSABLE_SELECTOR), ); - return all.filter((el) => { + + let filtered = all.filter((el) => { const style = window.getComputedStyle(el); return ( style.display !== "none" && @@ -20,16 +24,35 @@ export function useSpatialNavigation() { !(el as any).disabled ); }); + + if (topModalId) { + const modalElement = document.getElementById(`modal-${topModalId}`); + if (modalElement) { + filtered = filtered.filter((el) => modalElement.contains(el)); + } + } + + return filtered; }, []); const navigate = useCallback( (direction: NavigationDirection) => { const activeElement = document.activeElement as HTMLElement; - if (!activeElement) return; const elements = getFocusableElements(); if (elements.length === 0) return; + // If nothing focused or focus is lost, focus the first available element + if ( + !activeElement || + activeElement === document.body || + !elements.includes(activeElement) + ) { + elements[0].focus(); + elements[0].scrollIntoView({ block: "nearest", behavior: "smooth" }); + return; + } + const currentRect = activeElement.getBoundingClientRect(); const currentCenter = { x: currentRect.left + currentRect.width / 2, @@ -37,7 +60,7 @@ export function useSpatialNavigation() { }; let bestElement: HTMLElement | null = null; - let minDistance = Infinity; + let minScore = Infinity; for (const el of elements) { if (el === activeElement) continue; @@ -54,26 +77,39 @@ export function useSpatialNavigation() { let isCorrectDirection = false; switch (direction) { case "up": - isCorrectDirection = dy < 0 && Math.abs(dx) < Math.abs(dy) * 1.5; + isCorrectDirection = dy < 0; break; case "down": - isCorrectDirection = dy > 0 && Math.abs(dx) < Math.abs(dy) * 1.5; + isCorrectDirection = dy > 0; break; case "left": - isCorrectDirection = dx < 0 && Math.abs(dy) < Math.abs(dx) * 1.5; + isCorrectDirection = dx < 0; break; case "right": - isCorrectDirection = dx > 0 && Math.abs(dy) < Math.abs(dx) * 1.5; + isCorrectDirection = dx > 0; break; default: break; } if (isCorrectDirection) { - // Weighted distance: prioritize primary direction - const distance = Math.sqrt(dx * dx + dy * dy); - if (distance < minDistance) { - minDistance = distance; + // Weighted distance calculation + // We prioritize elements that are aligned with the direction + // Score = straight-line distance + (orthogonal distance * factor) + const primaryDist = + direction === "up" || direction === "down" + ? Math.abs(dy) + : Math.abs(dx); + const secondaryDist = + direction === "up" || direction === "down" + ? Math.abs(dx) + : Math.abs(dy); + + // Penalty for being off-axis + const score = primaryDist + secondaryDist * 2; + + if (score < minScore) { + minScore = score; bestElement = el; } } diff --git a/src/pages/GamepadSetup.tsx b/src/pages/GamepadSetup.tsx index 121ea4abb..005be58cf 100644 --- a/src/pages/GamepadSetup.tsx +++ b/src/pages/GamepadSetup.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Icon, Icons } from "@/components/Icon"; @@ -25,6 +25,7 @@ export function GamepadSetupPage() { const [currentManualButton, setCurrentManualButton] = useState(0); const [detectedGamepad, setDetectedGamepad] = useState(null); const [isListening, setIsListening] = useState(false); + const prevButtonStates = useRef>({}); const setGamepadSetupComplete = usePreferencesStore( (s: any) => s.setGamepadSetupComplete, @@ -52,15 +53,18 @@ export function GamepadSetupPage() { if (step === "manual") { const targetButton = MANUAL_BUTTONS[currentManualButton]; - if (gp.buttons[targetButton.id]?.pressed) { + const isPressed = !!gp.buttons[targetButton.id]?.pressed; + const wasPressed = prevButtonStates.current[targetButton.id] ?? false; + + if (isPressed && !wasPressed) { if (currentManualButton < MANUAL_BUTTONS.length - 1) { setCurrentManualButton((s) => s + 1); } else { setStep("success"); setIsListening(false); } - return; } + prevButtonStates.current[targetButton.id] = isPressed; } } requestAnimationFrame(poll); @@ -198,7 +202,7 @@ export function GamepadSetupPage() { diff --git a/src/pages/onboarding/utils.tsx b/src/pages/onboarding/utils.tsx index 7863b4bff..bc10a40cb 100644 --- a/src/pages/onboarding/utils.tsx +++ b/src/pages/onboarding/utils.tsx @@ -14,12 +14,14 @@ export function Card(props: {
e.key === "Enter" && props.onClick?.()} onClick={props.onClick} > {props.children} @@ -112,9 +114,11 @@ export function Link(props: { href={props.href} target={props.target} className={classNames( - "text-onboarding-link cursor-pointer inline-flex gap-2 items-center group hover:opacity-75 transition-opacity", + "text-onboarding-link cursor-pointer inline-flex gap-2 items-center group hover:opacity-75 transition-opacity tabbable rounded focus:outline-none", props.className, )} + tabIndex={0} + onKeyDown={(e) => e.key === "Enter" && props.to && navigate(props.to)} rel="noreferrer" > {props.children} diff --git a/src/setup/App.tsx b/src/setup/App.tsx index a9cf90f91..671d0e95d 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -16,6 +16,7 @@ import { DetailsModal } from "@/components/overlays/detailsModal"; import { GamepadControlsModal } from "@/components/overlays/GamepadControlsModal"; import { KeyboardCommandsEditModal } from "@/components/overlays/KeyboardCommandsEditModal"; import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsModal"; +import { MediaControllerMenuModal } from "@/components/overlays/MediaControllerMenu"; import { NotificationModal } from "@/components/overlays/notificationsModal"; import { SupportInfoModal } from "@/components/overlays/SupportInfoModal"; import { TraktAuthHandler } from "@/components/TraktAuthHandler"; @@ -147,6 +148,7 @@ function App() { + {!showDowntime && ( {/* functional routes */} From 1418a232be2ff4154b282140e889887fdba98be1 Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Sun, 22 Mar 2026 13:06:21 +0100 Subject: [PATCH 68/72] Controller Support Bugs V1.0 fixed --- src/assets/locales/en.json | 4 +- src/backend/metadata/tmdb.ts | 3 + src/backend/metadata/types/tmdb.ts | 1 + src/components/buttons/IconPatch.tsx | 2 + src/components/layout/Navigation.tsx | 1 + src/components/media/MediaBookmark.tsx | 8 +- src/components/media/MediaCard.tsx | 48 ++++++++--- .../overlays/MediaControllerMenu.tsx | 83 +++++++++++-------- src/components/text/DotList.tsx | 24 ++++-- src/hooks/useSpatialNavigation.ts | 9 ++ src/pages/HomePage.tsx | 2 +- src/pages/parts/search/SearchListPart.tsx | 2 +- src/stores/preferences/index.tsx | 8 ++ src/utils/mediaTypes.ts | 1 + 14 files changed, 140 insertions(+), 56 deletions(-) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index fefe852bf..f7cc7ad57 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -154,7 +154,9 @@ "editDetails": "Edit Details", "createFolder": "Create Folder", "folderNamePlaceholder": "Folder name...", - "limitReached": "Folder limit reached ({{max}} max)" + "limitReached": "Folder limit reached ({{max}} max)", + "add": "Add to folder", + "remove": "Remove from folder" } }, "auth": { diff --git a/src/backend/metadata/tmdb.ts b/src/backend/metadata/tmdb.ts index b36bb8f28..2293b854f 100644 --- a/src/backend/metadata/tmdb.ts +++ b/src/backend/metadata/tmdb.ts @@ -123,6 +123,7 @@ export function formatTMDBMetaToMediaItem(media: TMDBMediaResult): MediaItem { release_date: media.original_release_date, poster: media.poster, type, + rating: media.vote_average, }; } @@ -589,6 +590,7 @@ export function formatTMDBSearchResult( id: show.id, original_release_date: new Date(show.first_air_date), object_type: mediatype, + vote_average: show.vote_average, }; } @@ -600,6 +602,7 @@ export function formatTMDBSearchResult( id: movie.id, original_release_date: new Date(movie.release_date), object_type: mediatype, + vote_average: movie.vote_average, }; } diff --git a/src/backend/metadata/types/tmdb.ts b/src/backend/metadata/types/tmdb.ts index 71a6c089d..c03fad406 100644 --- a/src/backend/metadata/types/tmdb.ts +++ b/src/backend/metadata/types/tmdb.ts @@ -26,6 +26,7 @@ export type TMDBMediaResult = { object_type: TMDBContentTypes; seasons?: TMDBSeasonShort[]; overview?: string; + vote_average?: number; }; export type TMDBSeasonMetaResult = { diff --git a/src/components/buttons/IconPatch.tsx b/src/components/buttons/IconPatch.tsx index f73efc251..ff24474b5 100644 --- a/src/components/buttons/IconPatch.tsx +++ b/src/components/buttons/IconPatch.tsx @@ -9,6 +9,7 @@ export interface IconPatchProps { transparent?: boolean; downsized?: boolean; navigation?: boolean; + tabIndex?: number; } export function IconPatch(props: IconPatchProps) { @@ -29,6 +30,7 @@ export function IconPatch(props: IconPatchProps) { return (
diff --git a/src/components/layout/Navigation.tsx b/src/components/layout/Navigation.tsx index f2303e8f9..7e3ab27bc 100644 --- a/src/components/layout/Navigation.tsx +++ b/src/components/layout/Navigation.tsx @@ -128,6 +128,7 @@ export function Navigation(props: NavigationProps) { {/* content */}
s.addBookmark); const addBookmarkWithGroups = useBookmarkStore( (s) => s.addBookmarkWithGroups, @@ -59,6 +64,7 @@ export function MediaBookmarkButton({ media, group }: MediaBookmarkProps) { >
diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx index 14dd812e0..6c6fc5711 100644 --- a/src/components/media/MediaCard.tsx +++ b/src/components/media/MediaCard.tsx @@ -1,7 +1,14 @@ // I'm sorry this is so confusing 😭 import classNames from "classnames"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; @@ -120,6 +127,7 @@ export interface MediaCardProps { forceSkeleton?: boolean; editable?: boolean; onEdit?: (e?: React.MouseEvent) => void; + nestedTabIndex?: number; } function checkReleased(media: MediaItem): boolean { @@ -147,6 +155,7 @@ function MediaCardContent({ forceSkeleton, editable, onEdit, + nestedTabIndex, }: MediaCardProps) { const { t } = useTranslation(); const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`; @@ -154,9 +163,6 @@ function MediaCardContent({ const isReleased = useCallback(() => checkReleased(media), [media]); const canLink = linkable && !closable && isReleased(); - - const dotListContent = [t(`media.types.${media.type}`)]; - const [searchQuery] = useSearchQuery(); const enableMinimalCards = usePreferencesStore((s) => s.enableMinimalCards); @@ -176,21 +182,31 @@ function MediaCardContent({ ); } - if (isReleased() && media.year) { - dotListContent.push(media.year.toFixed()); - } + const dotListContent: ReactNode[] = [t(`media.types.${media.type}`)]; + if (media.year) dotListContent.push(media.year.toString()); if (!isReleased()) { dotListContent.push(t("media.unreleased")); } + if (media.rating && media.rating > 0) { + dotListContent.push( + + + {media.rating.toFixed(1)} + , + ); + } + return (
}> e.key === "Enter" && e.currentTarget.click()} + onKeyUp={(e) => + e.key === "Enter" && (e.currentTarget as HTMLElement).click() + } > e.preventDefault()} > - +
)} {searchQuery.length > 0 && !closable ? (
e.preventDefault()}> - +
) : null} @@ -288,6 +304,7 @@ function MediaCardContent({ > closable && onClose?.()} icon={Icons.X} @@ -309,6 +326,7 @@ function MediaCardContent({ - +
+ - + +
diff --git a/src/components/text/DotList.tsx b/src/components/text/DotList.tsx index bb328a6d3..63569cc82 100644 --- a/src/components/text/DotList.tsx +++ b/src/components/text/DotList.tsx @@ -1,19 +1,25 @@ +import { ReactNode } from "react"; + export interface DotListProps { - content: string[]; + content: ReactNode[]; className?: string; } export function DotList(props: DotListProps) { return (

- {props.content.map((item, index) => ( - - {index !== 0 ? ( - - ) : null} - {item} - - ))} + {props.content.map((item, index) => { + const key = + typeof item === "string" || typeof item === "number" ? item : index; + return ( + + {index !== 0 ? ( + + ) : null} + {item} + + ); + })}

); } diff --git a/src/hooks/useSpatialNavigation.ts b/src/hooks/useSpatialNavigation.ts index 61119f543..d8e7e9dea 100644 --- a/src/hooks/useSpatialNavigation.ts +++ b/src/hooks/useSpatialNavigation.ts @@ -1,6 +1,7 @@ import { useCallback } from "react"; import { useOverlayStack } from "@/stores/interface/overlayStack"; +import { usePreferencesStore } from "@/stores/preferences"; export type NavigationDirection = "up" | "down" | "left" | "right"; @@ -30,6 +31,14 @@ export function useSpatialNavigation() { if (modalElement) { filtered = filtered.filter((el) => modalElement.contains(el)); } + } else { + const ignoreHeader = usePreferencesStore.getState().ignoreHeader; + if (ignoreHeader) { + const header = document.getElementById("mw-header"); + if (header) { + filtered = filtered.filter((el) => !header.contains(el)); + } + } } return filtered; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 5fde539db..a61be9e91 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -3,6 +3,7 @@ import { Helmet } from "react-helmet-async"; import { useTranslation } from "react-i18next"; import { To, useNavigate } from "react-router-dom"; +import { Button } from "@/components/buttons/Button"; import { WideContainer } from "@/components/layout/WideContainer"; import { useDebounce } from "@/hooks/useDebounce"; import { useRandomTranslation } from "@/hooks/useRandomTranslation"; @@ -23,7 +24,6 @@ import { useOverlayStack } from "@/stores/interface/overlayStack"; import { usePreferencesStore } from "@/stores/preferences"; import { MediaItem } from "@/utils/mediaTypes"; -import { Button } from "./About"; import { AdsPart } from "./parts/home/AdsPart"; import { RevivalAnnouncementModal } from "./parts/home/RevivalAnnouncementModal"; import { SupportBar } from "./parts/home/SupportBar"; diff --git a/src/pages/parts/search/SearchListPart.tsx b/src/pages/parts/search/SearchListPart.tsx index 9052b4f67..3eb518dfd 100644 --- a/src/pages/parts/search/SearchListPart.tsx +++ b/src/pages/parts/search/SearchListPart.tsx @@ -4,13 +4,13 @@ import { useNavigate } from "react-router-dom"; import { searchForMedia } from "@/backend/metadata/search"; import { MWQuery } from "@/backend/metadata/types/mw"; +import { Button } from "@/components/buttons/Button"; import { IconPatch } from "@/components/buttons/IconPatch"; import { Icons } from "@/components/Icon"; import { SectionHeading } from "@/components/layout/SectionHeading"; import { MediaGrid } from "@/components/media/MediaGrid"; import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; import { useDebounce } from "@/hooks/useDebounce"; -import { Button } from "@/pages/About"; import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart"; import { MediaItem } from "@/utils/mediaTypes"; diff --git a/src/stores/preferences/index.tsx b/src/stores/preferences/index.tsx index b6a5e0992..0672e828e 100644 --- a/src/stores/preferences/index.tsx +++ b/src/stores/preferences/index.tsx @@ -48,6 +48,7 @@ export interface PreferencesStore { gamepadInputMode: "controller" | "kbm" | "both"; gamepadMapping: Record; keyboardShortcuts: KeyboardShortcuts; + ignoreHeader: boolean; setEnableThumbnails(v: boolean): void; setEnableAutoplay(v: boolean): void; @@ -89,6 +90,7 @@ export interface PreferencesStore { setGamepadInputMode(v: "controller" | "kbm" | "both"): void; setGamepadMapping(v: Record): void; setKeyboardShortcuts(v: KeyboardShortcuts): void; + setIgnoreHeader(v: boolean): void; } export const usePreferencesStore = create( @@ -134,6 +136,7 @@ export const usePreferencesStore = create( gamepadInputMode: "both", gamepadMapping: {}, keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, + ignoreHeader: false, setEnableThumbnails(v) { set((s) => { s.enableThumbnails = v; @@ -339,6 +342,11 @@ export const usePreferencesStore = create( s.keyboardShortcuts = v; }); }, + setIgnoreHeader(v) { + set((s) => { + s.ignoreHeader = v; + }); + }, })), { name: "__MW::preferences", diff --git a/src/utils/mediaTypes.ts b/src/utils/mediaTypes.ts index 5826c54f0..c15128205 100644 --- a/src/utils/mediaTypes.ts +++ b/src/utils/mediaTypes.ts @@ -5,6 +5,7 @@ export interface MediaItem { release_date?: Date; poster?: string; type: "show" | "movie"; + rating?: number; onHoverInfoEnter?: () => void; onHoverInfoLeave?: () => void; } From 1bff5104a3e20594cce2944bd7dfc09e575ae2e9 Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Sun, 22 Mar 2026 13:22:16 +0100 Subject: [PATCH 69/72] =?UTF-8?q?Controller=20Support=20Bugs=20V1.2=20?= =?UTF-8?q?=F0=9F=98=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/css/index.css | 8 +++++- src/assets/locales/en.json | 4 ++- .../gamepad/GamepadGlobalListener.tsx | 4 +++ src/components/media/MediaCard.tsx | 27 ++++++++----------- src/pages/parts/settings/PreferencesPart.tsx | 25 +++++++++++++++++ src/stores/preferences/index.tsx | 8 ++++++ 6 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/assets/css/index.css b/src/assets/css/index.css index 7e25d60e5..9be4ba793 100644 --- a/src/assets/css/index.css +++ b/src/assets/css/index.css @@ -277,7 +277,7 @@ input[type="range"].styled-slider.slider-progress::-ms-fill-lower { outline: 4px solid #ffffff !important; outline-offset: 2px !important; box-shadow: 0 0 20px rgba(255, 255, 255, 0.5) !important; - border-radius: 4px; + border-radius: 12px; z-index: 50; transition: outline 0.2s ease, @@ -285,6 +285,12 @@ input[type="range"].styled-slider.slider-progress::-ms-fill-lower { box-shadow 0.2s ease; } +/* Specific fix for elements with custom focus rings */ +.gamepad-active .gamepad-focus-ring-parent:focus { + outline: none !important; + box-shadow: none !important; +} + /* Ensure cards and buttons look good when focused */ .gamepad-active .group:focus-visible, .gamepad-active button:focus-visible, diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index f7cc7ad57..7824237a5 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -1406,13 +1406,15 @@ "showMore": "Show more", "embedOrder": "Reordering embeds", "embedOrderDescription": "Drag and drop to reorder embeds. This will determine the order in which embeds are checked for the media you are trying to watch.

(The default order is best for most users)", + "autoResumeOnPlaybackErrorLabel": "Automatically resume", + "gamepadIgnoreHeaderTitle": "Ignore Header", + "gamepadIgnoreHeaderDescription": "Skip the navigation header when scrolling with a controller.", "embedOrderEnableLabel": "Custom embed order", "manualSource": "Manual source selection", "manualSourceDescription": "Require picking a source before scraping. Disables automatic source selection and opens the source picker when starting playback.", "manualSourceLabel": "Manual source selection", "autoResumeOnPlaybackError": "Auto resume on playback error", "autoResumeOnPlaybackErrorDescription": "Automatically continue searching for other sources when the current source fails during playback. If disabled, you'll see an error screen with a manual resume option.", - "autoResumeOnPlaybackErrorLabel": "Auto resume on playback error", "lastSuccessfulSource": "Last used source", "lastSuccessfulSourceDescription": "Automatically prioritize the source that successfully provided content for the previous episode. This helps ensure continuity when watching series.", "lastSuccessfulSourceEnableLabel": "Last used source" diff --git a/src/components/gamepad/GamepadGlobalListener.tsx b/src/components/gamepad/GamepadGlobalListener.tsx index 78bd6cf6d..c3a868654 100644 --- a/src/components/gamepad/GamepadGlobalListener.tsx +++ b/src/components/gamepad/GamepadGlobalListener.tsx @@ -23,6 +23,7 @@ export function GamepadGlobalListener() { // Add gamepad-active class to body to show custom focus outlines document.body.classList.add("gamepad-active"); + usePreferencesStore.getState().setGamepadActive(true); // Don't intercept if we're in the player (it has its own listener) if (location.pathname.startsWith("/media/")) return; @@ -74,6 +75,9 @@ export function GamepadGlobalListener() { useEffect(() => { const handleMouseMove = () => { document.body.classList.remove("gamepad-active"); + if (usePreferencesStore.getState().isGamepadActive) { + usePreferencesStore.getState().setGamepadActive(false); + } }; window.addEventListener("mousemove", handleMouseMove); return () => window.removeEventListener("mousemove", handleMouseMove); diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx index 6c6fc5711..1abc4ff29 100644 --- a/src/components/media/MediaCard.tsx +++ b/src/components/media/MediaCard.tsx @@ -189,15 +189,6 @@ function MediaCardContent({ dotListContent.push(t("media.unreleased")); } - if (media.rating && media.rating > 0) { - dotListContent.push( - - - {media.rating.toFixed(1)} - , - ); - } - return (
}>
)} {editable && closable && ( -
+
+ +
+ usePreferencesStore + .getState() + .setIgnoreHeader( + !usePreferencesStore.getState().ignoreHeader, + ) + } + className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none cursor-pointer space-x-3 flex items-center py-3 px-4 rounded-lg border border-white/5" + > + +
+

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

+

+ {t( + "settings.preferences.gamepadIgnoreHeaderDescription", + )} +

+
+
)}
diff --git a/src/stores/preferences/index.tsx b/src/stores/preferences/index.tsx index 0672e828e..e11387d1f 100644 --- a/src/stores/preferences/index.tsx +++ b/src/stores/preferences/index.tsx @@ -49,6 +49,7 @@ export interface PreferencesStore { gamepadMapping: Record; keyboardShortcuts: KeyboardShortcuts; ignoreHeader: boolean; + isGamepadActive: boolean; setEnableThumbnails(v: boolean): void; setEnableAutoplay(v: boolean): void; @@ -91,6 +92,7 @@ export interface PreferencesStore { setGamepadMapping(v: Record): void; setKeyboardShortcuts(v: KeyboardShortcuts): void; setIgnoreHeader(v: boolean): void; + setGamepadActive(v: boolean): void; } export const usePreferencesStore = create( @@ -137,6 +139,7 @@ export const usePreferencesStore = create( gamepadMapping: {}, keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, ignoreHeader: false, + isGamepadActive: false, setEnableThumbnails(v) { set((s) => { s.enableThumbnails = v; @@ -347,6 +350,11 @@ export const usePreferencesStore = create( s.ignoreHeader = v; }); }, + setGamepadActive(v) { + set((s) => { + s.isGamepadActive = v; + }); + }, })), { name: "__MW::preferences", From 0cfd253e7cb823d75edda867a71c7efa5eeab2c2 Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Sun, 22 Mar 2026 13:52:01 +0100 Subject: [PATCH 70/72] Controller Support Bug V1.3 Fixed --- src/assets/locales/en.json | 11 +++-- .../overlays/MediaControllerMenu.tsx | 3 +- src/hooks/useSpatialNavigation.ts | 20 +++++++-- src/pages/parts/settings/PreferencesPart.tsx | 43 +++++++++---------- 4 files changed, 47 insertions(+), 30 deletions(-) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 7824237a5..b472b9dfe 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -149,15 +149,16 @@ "title": "Folders", "counter": "{{count}} / {{max}}", "empty": "No folders yet", - "moreInfo": "More Info", + "moreInfo": "Info", "removeFromFolder": "Remove from folder", "editDetails": "Edit Details", "createFolder": "Create Folder", "folderNamePlaceholder": "Folder name...", "limitReached": "Folder limit reached ({{max}} max)", - "add": "Add to folder", - "remove": "Remove from folder" + "add": "Bookmark", + "remove": "Remove" } + }, "auth": { "createAccount": "Don't have an account yet 😬 <0>Create an account.", @@ -1409,6 +1410,9 @@ "autoResumeOnPlaybackErrorLabel": "Automatically resume", "gamepadIgnoreHeaderTitle": "Ignore Header", "gamepadIgnoreHeaderDescription": "Skip the navigation header when scrolling with a controller.", + "keybinds": "Keybinds", + "redoSetup": "Redo Setup", + "embedOrderEnableLabel": "Custom embed order", "manualSource": "Manual source selection", "manualSourceDescription": "Require picking a source before scraping. Disables automatic source selection and opens the source picker when starting playback.", @@ -1419,6 +1423,7 @@ "lastSuccessfulSourceDescription": "Automatically prioritize the source that successfully provided content for the previous episode. This helps ensure continuity when watching series.", "lastSuccessfulSourceEnableLabel": "Last used source" }, + "reset": "Reset", "save": "Save", "sidebar": { diff --git a/src/components/overlays/MediaControllerMenu.tsx b/src/components/overlays/MediaControllerMenu.tsx index 3e99ed40c..2eac2508f 100644 --- a/src/components/overlays/MediaControllerMenu.tsx +++ b/src/components/overlays/MediaControllerMenu.tsx @@ -103,9 +103,10 @@ export function MediaControllerMenuModal() { onClick={handlePlay} > - {t("player.play", "Play")} + {t("details.play", "Play")} +
- usePreferencesStore - .getState() - .setIgnoreHeader( - !usePreferencesStore.getState().ignoreHeader, - ) - } + onClick={() => setIgnoreHeader(!ignoreHeader)} className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none cursor-pointer space-x-3 flex items-center py-3 px-4 rounded-lg border border-white/5" > - +

{t("settings.preferences.gamepadIgnoreHeaderTitle")} @@ -407,6 +405,7 @@ export function PreferencesPart(props: {

+ )}
From 16cfcba2d1a013a221890f650ffe3f05f6a1daaa Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Sun, 22 Mar 2026 13:56:09 +0100 Subject: [PATCH 71/72] Error Fixed --- src/assets/locales/en.json | 1 - src/components/overlays/MediaControllerMenu.tsx | 1 - src/hooks/useSpatialNavigation.ts | 13 +++---------- src/pages/parts/settings/PreferencesPart.tsx | 3 --- 4 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index b472b9dfe..30d5ad544 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -158,7 +158,6 @@ "add": "Bookmark", "remove": "Remove" } - }, "auth": { "createAccount": "Don't have an account yet 😬 <0>Create an account.", diff --git a/src/components/overlays/MediaControllerMenu.tsx b/src/components/overlays/MediaControllerMenu.tsx index 2eac2508f..3ab4b59e6 100644 --- a/src/components/overlays/MediaControllerMenu.tsx +++ b/src/components/overlays/MediaControllerMenu.tsx @@ -106,7 +106,6 @@ export function MediaControllerMenuModal() { {t("details.play", "Play")} -
@@ -405,7 +403,6 @@ export function PreferencesPart(props: {

- )}
From a024432173296ca3be71e0fda0acc48073a9f4b1 Mon Sep 17 00:00:00 2001 From: TrendyOfficial Date: Thu, 26 Mar 2026 20:13:27 +0100 Subject: [PATCH 72/72] CS New implementation + Fixed --- src/assets/css/index.css | 13 +++++++-- .../gamepad/GamepadGlobalListener.tsx | 28 ++++++++++++++++++- src/components/media/MediaCard.tsx | 7 +++-- src/hooks/useGamepad.ts | 3 +- src/hooks/useSpatialNavigation.ts | 11 ++++++-- 5 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/assets/css/index.css b/src/assets/css/index.css index 9be4ba793..59bc9ce58 100644 --- a/src/assets/css/index.css +++ b/src/assets/css/index.css @@ -292,11 +292,18 @@ input[type="range"].styled-slider.slider-progress::-ms-fill-lower { } /* Ensure cards and buttons look good when focused */ -.gamepad-active .group:focus-visible, +.gamepad-active .gamepad-focus-ring-parent:focus-within, .gamepad-active button:focus-visible, .gamepad-active a:focus-visible { - transform: scale(1.02); - transition: transform 0.2s ease; + transform: scale(1.05); + transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); + z-index: 100 !important; +} + +.gamepad-focus-ring { + box-shadow: 0 0 25px rgba(255, 255, 255, 0.6), inset 0 0 10px rgba(255, 255, 255, 0.2); + border-width: 3px; + filter: drop-shadow(0 0 5px rgba(0, 0, 0, 0.5)); } [dir="rtl"] .transform { diff --git a/src/components/gamepad/GamepadGlobalListener.tsx b/src/components/gamepad/GamepadGlobalListener.tsx index c3a868654..39d37f089 100644 --- a/src/components/gamepad/GamepadGlobalListener.tsx +++ b/src/components/gamepad/GamepadGlobalListener.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect } from "react"; -import { useLocation } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { useGamepadPolling } from "@/hooks/useGamepad"; import { useSpatialNavigation } from "@/hooks/useSpatialNavigation"; @@ -9,6 +9,7 @@ import { usePreferencesStore } from "@/stores/preferences"; export function GamepadGlobalListener() { const { navigate: navigateSpatial } = useSpatialNavigation(); const location = useLocation(); + const navigate = useNavigate(); const { hideModal, getTopModal } = useOverlayStack(); const enableGamepadControls = usePreferencesStore( @@ -53,6 +54,11 @@ export function GamepadGlobalListener() { } break; } + case "go-home": { + window.scrollTo(0, 0); + navigate("/"); + break; + } default: break; } @@ -63,6 +69,7 @@ export function GamepadGlobalListener() { gamepadInputMode, hideModal, getTopModal, + navigate, ], ); @@ -83,5 +90,24 @@ export function GamepadGlobalListener() { return () => window.removeEventListener("mousemove", handleMouseMove); }, []); + // Browser lock: prevent arrow keys from reaching the browser chrome when gamepad is active + useEffect(() => { + if (!enableGamepadControls) return; + + const handleKeyDown = (e: KeyboardEvent) => { + const navKeys = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", " "]; + if ( + navKeys.includes(e.key) && + document.body.classList.contains("gamepad-active") + ) { + // Prevent the browser from scrolling the page or moving focus to browser chrome + e.preventDefault(); + } + }; + window.addEventListener("keydown", handleKeyDown, { capture: true }); + return () => + window.removeEventListener("keydown", handleKeyDown, { capture: true }); + }, [enableGamepadControls]); + return null; } diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx index 1abc4ff29..cfdfbe075 100644 --- a/src/components/media/MediaCard.tsx +++ b/src/components/media/MediaCard.tsx @@ -352,9 +352,6 @@ function MediaCardContent({ )} )} - - {/* Gamepad Focus Ring */} -
@@ -635,6 +632,8 @@ export function MediaCard(props: MediaCardProps) { onContextMenu={handleCardContextMenu} > {content} + {/* Gamepad Focus Ring - Placed here to avoid negative margin clipping */} +
{contextMenuEl} ); @@ -652,6 +651,8 @@ export function MediaCard(props: MediaCardProps) { onContextMenu={handleCardContextMenu} > {content} + {/* Gamepad Focus Ring - Placed here to avoid negative margin clipping */} +
{contextMenuEl} ); diff --git a/src/hooks/useGamepad.ts b/src/hooks/useGamepad.ts index 71f0b1597..74b6d6e6e 100644 --- a/src/hooks/useGamepad.ts +++ b/src/hooks/useGamepad.ts @@ -32,7 +32,7 @@ export const DEFAULT_GAMEPAD_MAPPING: GamepadMapping = { actionNorth: "toggle-captions", leftBumper: "skip-backward", rightBumper: "skip-forward", - leftTrigger: "volume-down", + leftTrigger: "go-home", rightTrigger: "volume-up", start: "play-pause", select: "mute", @@ -186,6 +186,7 @@ export const GAMEPAD_ACTION_LABELS: Record = { "toggle-captions": "Toggle Captions", "next-episode": "Next Episode", "previous-episode": "Previous Episode", + "go-home": "Go to Home Page", }; export const GAMEPAD_BUTTON_LABELS: Record< diff --git a/src/hooks/useSpatialNavigation.ts b/src/hooks/useSpatialNavigation.ts index 6065c047a..76a312a0f 100644 --- a/src/hooks/useSpatialNavigation.ts +++ b/src/hooks/useSpatialNavigation.ts @@ -120,8 +120,15 @@ export function useSpatialNavigation() { ? Math.abs(dx) : Math.abs(dy); - const axisWeights = - direction === "left" || direction === "right" ? 1.5 : 2; + let axisWeights = + direction === "left" || direction === "right" ? 1.5 : 1; + + // Special case for header elements to make horizontal navigation smoother + const header = document.getElementById("mw-header"); + if (header && header.contains(el) && header.contains(activeElement)) { + axisWeights = 0.5; // Lower weight for vertical misalignment within header + } + const score = primaryDist + secondaryDist * axisWeights; if (score < minScore) { minScore = score;