feat(web,engine): portrait-orientation scene images for mobile full-bleed
Thread orientation (portrait|landscape) from client through API, engine, and image gen. Portrait devices render 1024x1792 (9:16) full-bleed scenes; desktop/landscape keeps 1792x1024 (16:9). Adds cover-aware click→image coordinate mapping, session-locked orientation, a shared coerceOrientation helper, and a choices overflow cap in portrait. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+59
-1
@@ -24,6 +24,7 @@ import type {
|
||||
Character,
|
||||
CharacterVoice,
|
||||
InsertBeatResponse,
|
||||
Orientation,
|
||||
Scene,
|
||||
SceneExit,
|
||||
SceneResponse,
|
||||
@@ -58,6 +59,17 @@ function getByoHeaders(): Record<string, string> {
|
||||
// it, low enough to catch a scene that's clearly being rate-limited.
|
||||
const SILENCE_NUDGE_THRESHOLD = 3;
|
||||
|
||||
// Mobile-portrait users get a 9:16 scene image painted for them; everyone else
|
||||
// (desktop, tablet, mobile-landscape) keeps the 16:9 landscape image. Only a
|
||||
// touch device (coarse pointer) held upright counts as "portrait" — a mouse
|
||||
// device is always landscape. Detected once and locked for the whole session.
|
||||
function detectOrientation(): Orientation {
|
||||
if (typeof window === "undefined") return "landscape";
|
||||
const portrait = window.matchMedia("(orientation: portrait)").matches;
|
||||
const coarse = window.matchMedia("(pointer: coarse)").matches;
|
||||
return portrait && coarse ? "portrait" : "landscape";
|
||||
}
|
||||
|
||||
// Cap how long we wait for the browser to download + decode a scene image
|
||||
// before giving up and rendering anyway. Runware's CDN is usually <2s for a
|
||||
// 1792×1024 PNG, but over slow links / VPN / strict corp networks the same
|
||||
@@ -457,6 +469,9 @@ function PlayInner() {
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [presentation, setPresentation] = useState(false);
|
||||
// Session-locked image orientation (see detectOrientation). "portrait" makes
|
||||
// the whole play surface render full-bleed vertical on phones.
|
||||
const [orientation, setOrientation] = useState<Orientation>("landscape");
|
||||
const [lastExitLabel, setLastExitLabel] = useState<string | null>(null);
|
||||
// Consecutive server-side TTS misses (null audio / failed /api/beat-audio).
|
||||
// Climbs when the shared server key is rate-limited by MiMo — the exact pain
|
||||
@@ -801,6 +816,7 @@ function PlayInner() {
|
||||
worldSetting: string;
|
||||
styleGuide: string;
|
||||
styleReferenceImage?: string;
|
||||
orientation?: Orientation;
|
||||
} | null = null;
|
||||
if (!cardName) {
|
||||
if (presetId) {
|
||||
@@ -829,6 +845,15 @@ function PlayInner() {
|
||||
}
|
||||
}
|
||||
|
||||
// Lock orientation for the whole session. Prebaked cards (decision C) are
|
||||
// landscape-baked, so they stay landscape regardless of device; only the
|
||||
// live /api/start path requests a portrait paint when the phone is upright.
|
||||
const sessionOrientation: Orientation = cardName
|
||||
? "landscape"
|
||||
: detectOrientation();
|
||||
setOrientation(sessionOrientation);
|
||||
if (livePayload) livePayload.orientation = sessionOrientation;
|
||||
|
||||
if (!cardName && !livePayload) {
|
||||
router.replace("/");
|
||||
return;
|
||||
@@ -903,6 +928,7 @@ function PlayInner() {
|
||||
characters: data.characters,
|
||||
storyState: data.storyState,
|
||||
styleReferenceImage: data.styleReferenceImage,
|
||||
orientation: data.scene.orientation ?? sessionOrientation,
|
||||
};
|
||||
visitedBeatsRef.current = [data.scene.entryBeatId];
|
||||
setSession(initial);
|
||||
@@ -1290,7 +1316,13 @@ function PlayInner() {
|
||||
);
|
||||
}
|
||||
|
||||
if (presentation) {
|
||||
// Mobile portrait renders full-bleed by default — it sidesteps the iOS
|
||||
// Safari Fullscreen API (unsupported on iPhone) with a CSS full-viewport
|
||||
// layout instead. Desktop "presentation" mode shares the same immersive
|
||||
// canvas, toggled via the F key.
|
||||
const immersive = presentation || orientation === "portrait";
|
||||
|
||||
if (immersive) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black flex items-center justify-center z-50">
|
||||
<PlayCanvas
|
||||
@@ -1304,8 +1336,33 @@ function PlayInner() {
|
||||
onBackgroundClick={onBackgroundClick}
|
||||
onAdvance={onAdvance}
|
||||
onSelectChoice={onSelectChoice}
|
||||
orientation={orientation}
|
||||
fullViewport
|
||||
/>
|
||||
{orientation === "portrait" && (
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 z-10 flex items-center justify-between px-4 pointer-events-none"
|
||||
style={{ paddingTop: "max(0.5rem, env(safe-area-inset-top))" }}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="pointer-events-auto flex h-9 w-9 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-sm transition-colors hover:text-white"
|
||||
aria-label="返回"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left text-[13px]" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMuted}
|
||||
className="pointer-events-auto flex h-9 w-9 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-sm transition-colors hover:text-white"
|
||||
aria-label={muted ? "取消静音" : "静音"}
|
||||
>
|
||||
<i
|
||||
className={`fa-solid ${muted ? "fa-volume-xmark" : "fa-volume-high"} text-[13px]`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1354,6 +1411,7 @@ function PlayInner() {
|
||||
onBackgroundClick={onBackgroundClick}
|
||||
onAdvance={onAdvance}
|
||||
onSelectChoice={onSelectChoice}
|
||||
orientation={orientation}
|
||||
aboveCanvas={
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user