From 9e7eb10edd2ee07bae7494506d887240d7fe8b14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 21 May 2026 11:11:28 -0400 Subject: [PATCH] feat(studio): fullscreen preview mode (F key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add distraction-free fullscreen mode using the HTML5 Fullscreen API. Press F to enter fullscreen (Esc to exit). When active, the composition fills the screen — timeline, sidebars, and editing overlays are hidden while all playback shortcuts (Space, J/K/L, arrows, etc.) remain functional. A fullscreen toggle button is also added to player controls. Closes #995 --- .../studio/src/components/nle/NLELayout.tsx | 47 +++++++++++++-- packages/studio/src/hooks/useAppHotkeys.ts | 18 ++++++ .../src/player/components/PlayerControls.tsx | 59 +++++++++++++++++++ 3 files changed, 118 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index e23e225742..23da8b1056 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -1,4 +1,12 @@ -import { useState, useCallback, useRef, useEffect, memo, type ReactNode } from "react"; +import { + useState, + useCallback, + useRef, + useEffect, + useSyncExternalStore, + memo, + type ReactNode, +} from "react"; import { useMountEffect } from "../../hooks/useMountEffect"; import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player"; import type { TimelineElement } from "../../player"; @@ -71,6 +79,15 @@ const MIN_TIMELINE_H = 100; const DEFAULT_TIMELINE_H = 220; const MIN_PREVIEW_H = 120; +function subscribeFullscreen(cb: () => void) { + document.addEventListener("fullscreenchange", cb); + return () => document.removeEventListener("fullscreenchange", cb); +} + +function getFullscreenElement() { + return document.fullscreenElement; +} + export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean { return compositionLoading; } @@ -248,9 +265,20 @@ export const NLELayout = memo(function NLELayout({ onCompositionLoadingChangeParent?.(compositionLoading); }, [compositionLoading, onCompositionLoadingChangeParent]); + const fullscreenElement = useSyncExternalStore(subscribeFullscreen, getFullscreenElement); const isTimelineVisible = timelineVisible ?? true; const isDragging = useRef(false); const containerRef = useRef(null); + const isFullscreen = fullscreenElement === containerRef.current && fullscreenElement != null; + + const toggleFullscreen = useCallback(() => { + if (!containerRef.current) return; + if (document.fullscreenElement) { + void document.exitFullscreen(); + } else { + void containerRef.current.requestFullscreen(); + } + }, []); const currentLevel = compositionStack[compositionStack.length - 1]; const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined; @@ -312,6 +340,7 @@ export const NLELayout = memo(function NLELayout({ className="flex flex-col h-full min-h-0 bg-neutral-950" onKeyDown={handleKeyDown} tabIndex={-1} + data-studio-fullscreen-target="" > {/* Preview + player controls */}
@@ -326,20 +355,26 @@ export const NLELayout = memo(function NLELayout({ refreshKey={refreshKey} suppressLoadingOverlay={hasLoadedOnceRef.current} /> - {previewOverlay} + {!isFullscreen && previewOverlay}
- {compositionStack.length > 1 && ( + {!isFullscreen && compositionStack.length > 1 && ( )} - +
- {isTimelineVisible ? ( + {!isFullscreen && isTimelineVisible ? ( <> {/* Resize divider */}
- ) : onToggleTimeline ? ( + ) : !isFullscreen && onToggleTimeline ? (
diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 31ebac4a70..dc66ec0ac2 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -248,6 +248,24 @@ export function useAppHotkeys({ } } + // F — toggle fullscreen preview + if ( + event.key.toLowerCase() === "f" && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + !isEditableTarget(event.target) + ) { + event.preventDefault(); + if (document.fullscreenElement) { + void document.exitFullscreen(); + } else { + document.querySelector("[data-studio-fullscreen-target]")?.requestFullscreen(); + } + return; + } + // Delete / Backspace — remove selected element (timeline clip or preview selection) if ( (event.key === "Delete" || event.key === "Backspace") && diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index 739d4da993..9d33bb9e2c 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -20,6 +20,7 @@ const SHORTCUT_SECTIONS = [ { key: "⇧L", label: "Toggle loop" }, { key: "←/→", label: "Step 1 frame" }, { key: "⇧←/⇧→", label: "Step 10 frames" }, + { key: "F", label: "Toggle fullscreen" }, ], }, { @@ -49,12 +50,16 @@ interface PlayerControlsProps { onTogglePlay: () => void; onSeek: (time: number) => void; disabled?: boolean; + isFullscreen?: boolean; + onToggleFullscreen?: () => void; } export const PlayerControls = memo(function PlayerControls({ onTogglePlay, onSeek, disabled = false, + isFullscreen = false, + onToggleFullscreen, }: PlayerControlsProps) { // Subscribe to only the fields we render — each selector prevents cascading re-renders const isPlaying = usePlayerStore((s) => s.isPlaying); @@ -595,6 +600,60 @@ export const PlayerControls = memo(function PlayerControls({ + {/* Fullscreen toggle */} + {onToggleFullscreen && ( + + )} + {/* Keyboard shortcuts + frame jump + work area — click to open panel */}