From bf8f356e370e0f736a5daecd0e8b6b4105e81a1a Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Mon, 25 May 2026 10:06:40 +0800 Subject: [PATCH] feat: 16:9 landscape canvas + F-key presentation mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - image prompt: vertical 9:16 → landscape 16:9 cinematic, scene fills canvas with bottom dialogue band and horizontal choice row - image-client: pass size=1792x1024 hint (provider honors it → output is now exact 16:9 instead of the model's default 1.75:1) - PlayCanvas: drop 560px cap, use object-contain into available space, add fullViewport prop for chrome-less presentation rendering - play page: F / Esc shortcuts + Fullscreen API + fullscreenchange sync; chrome-less black-letterbox overlay (bg-black) suited for screen recording Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/app/play/page.tsx | 80 ++++++++++++++++++++++++++++-- apps/web/components/PlayCanvas.tsx | 65 ++++++++++++++++-------- packages/ai-client/src/image.ts | 1 + packages/engine/src/prompts.ts | 21 ++++---- 4 files changed, 130 insertions(+), 37 deletions(-) diff --git a/apps/web/app/play/page.tsx b/apps/web/app/play/page.tsx index 42a0ae2..4db467f 100644 --- a/apps/web/app/play/page.tsx +++ b/apps/web/app/play/page.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; -import { Suspense, useEffect, useRef, useState } from "react"; +import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import { PlayCanvas, type Phase } from "@/components/PlayCanvas"; import { PRESETS } from "@/lib/presets"; import type { @@ -29,11 +29,59 @@ function PlayInner() { } | null>(null); const [turnNum, setTurnNum] = useState(0); const [error, setError] = useState(null); + const [presentation, setPresentation] = useState(false); const startedRef = useRef(false); const prefetchAbortRef = useRef(null); const prefetchRef = useRef>>({}); + const togglePresentation = useCallback(async () => { + const entering = !presentation; + if (entering) { + try { + if (!document.fullscreenElement) { + await document.documentElement.requestFullscreen(); + } + } catch { + // Browser may refuse fullscreen — still enter chrome-less mode + } + setPresentation(true); + } else { + try { + if (document.fullscreenElement) { + await document.exitFullscreen(); + } + } catch { + // ignore + } + setPresentation(false); + } + }, [presentation]); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === "f" || e.key === "F") { + if (e.metaKey || e.ctrlKey || e.altKey) return; + e.preventDefault(); + void togglePresentation(); + } else if (e.key === "Escape" && presentation) { + setPresentation(false); + } + } + function onFullscreenChange() { + // Sync if user exited browser fullscreen via Esc / system gesture + if (!document.fullscreenElement && presentation) { + setPresentation(false); + } + } + window.addEventListener("keydown", onKey); + document.addEventListener("fullscreenchange", onFullscreenChange); + return () => { + window.removeEventListener("keydown", onKey); + document.removeEventListener("fullscreenchange", onFullscreenChange); + }; + }, [togglePresentation, presentation]); + useEffect(() => { if (startedRef.current) return; startedRef.current = true; @@ -241,6 +289,20 @@ function PlayInner() { ); } + if (presentation) { + return ( +
+ +
+ ); + } + return (
@@ -300,10 +362,18 @@ function PlayInner() {
-