feat(studio): fullscreen preview mode (F key)

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
This commit is contained in:
Miguel Ángel
2026-05-21 11:11:28 -04:00
parent 114b83bbf6
commit 9e7eb10edd
3 changed files with 118 additions and 6 deletions
@@ -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<HTMLDivElement>(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 */}
<div className="flex-1 min-h-0 flex flex-col">
@@ -326,20 +355,26 @@ export const NLELayout = memo(function NLELayout({
refreshKey={refreshKey}
suppressLoadingOverlay={hasLoadedOnceRef.current}
/>
{previewOverlay}
{!isFullscreen && previewOverlay}
</div>
<div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
{compositionStack.length > 1 && (
{!isFullscreen && compositionStack.length > 1 && (
<CompositionBreadcrumb
stack={compositionStack}
onNavigate={handleNavigateComposition}
/>
)}
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} disabled={timelineDisabled} />
<PlayerControls
onTogglePlay={togglePlay}
onSeek={seek}
disabled={timelineDisabled}
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
/>
</div>
</div>
{isTimelineVisible ? (
{!isFullscreen && isTimelineVisible ? (
<>
{/* Resize divider */}
<div
@@ -396,7 +431,7 @@ export const NLELayout = memo(function NLELayout({
)}
</div>
</>
) : onToggleTimeline ? (
) : !isFullscreen && onToggleTimeline ? (
<div className="flex-shrink-0 border-t border-neutral-800/50 bg-neutral-950/96">
<div className="flex h-10 items-center justify-between px-3">
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
@@ -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<HTMLElement>("[data-studio-fullscreen-target]")?.requestFullscreen();
}
return;
}
// Delete / Backspace — remove selected element (timeline clip or preview selection)
if (
(event.key === "Delete" || event.key === "Backspace") &&
@@ -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({
</svg>
</button>
{/* Fullscreen toggle */}
{onToggleFullscreen && (
<button
type="button"
onClick={() => {
trackStudioEvent("playback", { action: "fullscreen_toggle", active: !isFullscreen });
onToggleFullscreen();
}}
className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors ${
isFullscreen
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
}`}
title={isFullscreen ? "Exit fullscreen (F)" : "Enter fullscreen (F)"}
aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
>
{isFullscreen ? (
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
</svg>
) : (
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
</svg>
)}
</button>
)}
{/* Keyboard shortcuts + frame jump + work area — click to open panel */}
<div ref={shortcutsPanelRef} className="relative flex-shrink-0">
<button