mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
Merge pull request #997 from heygen-com/feat/studio-fullscreen-preview
feat(studio): fullscreen preview mode (F key)
This commit is contained in:
@@ -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 { useMountEffect } from "../../hooks/useMountEffect";
|
||||||
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
|
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
|
||||||
import type { TimelineElement } from "../../player";
|
import type { TimelineElement } from "../../player";
|
||||||
@@ -71,6 +79,15 @@ const MIN_TIMELINE_H = 100;
|
|||||||
const DEFAULT_TIMELINE_H = 220;
|
const DEFAULT_TIMELINE_H = 220;
|
||||||
const MIN_PREVIEW_H = 120;
|
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 {
|
export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
|
||||||
return compositionLoading;
|
return compositionLoading;
|
||||||
}
|
}
|
||||||
@@ -248,9 +265,20 @@ export const NLELayout = memo(function NLELayout({
|
|||||||
onCompositionLoadingChangeParent?.(compositionLoading);
|
onCompositionLoadingChangeParent?.(compositionLoading);
|
||||||
}, [compositionLoading, onCompositionLoadingChangeParent]);
|
}, [compositionLoading, onCompositionLoadingChangeParent]);
|
||||||
|
|
||||||
|
const fullscreenElement = useSyncExternalStore(subscribeFullscreen, getFullscreenElement);
|
||||||
const isTimelineVisible = timelineVisible ?? true;
|
const isTimelineVisible = timelineVisible ?? true;
|
||||||
const isDragging = useRef(false);
|
const isDragging = useRef(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
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 currentLevel = compositionStack[compositionStack.length - 1];
|
||||||
const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
|
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"
|
className="flex flex-col h-full min-h-0 bg-neutral-950"
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
data-studio-fullscreen-target=""
|
||||||
>
|
>
|
||||||
{/* Preview + player controls */}
|
{/* Preview + player controls */}
|
||||||
<div className="flex-1 min-h-0 flex flex-col">
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
@@ -326,20 +355,26 @@ export const NLELayout = memo(function NLELayout({
|
|||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
suppressLoadingOverlay={hasLoadedOnceRef.current}
|
suppressLoadingOverlay={hasLoadedOnceRef.current}
|
||||||
/>
|
/>
|
||||||
{previewOverlay}
|
{!isFullscreen && previewOverlay}
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
|
<div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
|
||||||
{compositionStack.length > 1 && (
|
{!isFullscreen && compositionStack.length > 1 && (
|
||||||
<CompositionBreadcrumb
|
<CompositionBreadcrumb
|
||||||
stack={compositionStack}
|
stack={compositionStack}
|
||||||
onNavigate={handleNavigateComposition}
|
onNavigate={handleNavigateComposition}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} disabled={timelineDisabled} />
|
<PlayerControls
|
||||||
|
onTogglePlay={togglePlay}
|
||||||
|
onSeek={seek}
|
||||||
|
disabled={timelineDisabled}
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
onToggleFullscreen={toggleFullscreen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isTimelineVisible ? (
|
{!isFullscreen && isTimelineVisible ? (
|
||||||
<>
|
<>
|
||||||
{/* Resize divider */}
|
{/* Resize divider */}
|
||||||
<div
|
<div
|
||||||
@@ -396,7 +431,7 @@ export const NLELayout = memo(function NLELayout({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : onToggleTimeline ? (
|
) : !isFullscreen && onToggleTimeline ? (
|
||||||
<div className="flex-shrink-0 border-t border-neutral-800/50 bg-neutral-950/96">
|
<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="flex h-10 items-center justify-between px-3">
|
||||||
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
<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)
|
// Delete / Backspace — remove selected element (timeline clip or preview selection)
|
||||||
if (
|
if (
|
||||||
(event.key === "Delete" || event.key === "Backspace") &&
|
(event.key === "Delete" || event.key === "Backspace") &&
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const SHORTCUT_SECTIONS = [
|
|||||||
{ key: "⇧L", label: "Toggle loop" },
|
{ key: "⇧L", label: "Toggle loop" },
|
||||||
{ key: "←/→", label: "Step 1 frame" },
|
{ key: "←/→", label: "Step 1 frame" },
|
||||||
{ key: "⇧←/⇧→", label: "Step 10 frames" },
|
{ key: "⇧←/⇧→", label: "Step 10 frames" },
|
||||||
|
{ key: "F", label: "Toggle fullscreen" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -49,12 +50,16 @@ interface PlayerControlsProps {
|
|||||||
onTogglePlay: () => void;
|
onTogglePlay: () => void;
|
||||||
onSeek: (time: number) => void;
|
onSeek: (time: number) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
isFullscreen?: boolean;
|
||||||
|
onToggleFullscreen?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayerControls = memo(function PlayerControls({
|
export const PlayerControls = memo(function PlayerControls({
|
||||||
onTogglePlay,
|
onTogglePlay,
|
||||||
onSeek,
|
onSeek,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
isFullscreen = false,
|
||||||
|
onToggleFullscreen,
|
||||||
}: PlayerControlsProps) {
|
}: PlayerControlsProps) {
|
||||||
// Subscribe to only the fields we render — each selector prevents cascading re-renders
|
// Subscribe to only the fields we render — each selector prevents cascading re-renders
|
||||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||||
@@ -595,6 +600,60 @@ export const PlayerControls = memo(function PlayerControls({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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 */}
|
{/* Keyboard shortcuts + frame jump + work area — click to open panel */}
|
||||||
<div ref={shortcutsPanelRef} className="relative flex-shrink-0">
|
<div ref={shortcutsPanelRef} className="relative flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user