Files
game-scoreboard/src/components/scoreboard/on-screen.tsx
T
joshuaandClaude Opus 5.5 825eb14f2e Phase 13: questions, sounds and videos on the scoreboard
A game can now carry a list of items the host puts on the scoreboard one at
a time: a question (shown above the standings, with an optional picture and
an answer to reveal), a sound (played through the scoreboard's speakers) or
a video such as a karaoke track (takes over the screen).

- Media is a YouTube link, an https link to a file, or an upload to the
  private game-media bucket. The scoreboard API signs a URL only for the
  item that is on screen.
- What is on screen is state on the event, like the timer, so the ping
  still carries no data. An unrevealed answer and a sound's or video's name
  never reach the scoreboard payload.
- Control room: click an item to show it, or "Random" to have one picked
  (prefers items not played yet). Show/hide answer, pause, take off screen;
  shortcuts Q / A / X.
- Duplicating a game or event copies its items and files; deleting removes
  the files.

Needs supabase/migrations/0006_screen_items.sql applied before deploying.
database.types.ts was edited by hand to match; re-run gen:types after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:54:08 +02:00

180 lines
6.6 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { OnScreen, ScreenMedia } from "@/lib/screen";
// What the host has put on the scoreboard: a question above the standings, a sound, or a
// video that takes over the screen.
//
// Each of these is mounted with a key of item + shownAt. That restarts it when the host shows
// it again, and lets it keep the media URL it started with: an upload's signed URL is
// different in every payload, and following it would restart the file on every refetch.
type Question = Extract<OnScreen, { kind: "question" }>;
type Sound = Extract<OnScreen, { kind: "sound" }>;
type Video = Extract<OnScreen, { kind: "video" }>;
export const onScreenKey = (item: OnScreen) => `${item.id}:${item.shownAt}`;
const SOUND_OFF_NOTE = "Sound is off — click “Turn sound on”";
/** Long questions get smaller type so they still fit above the standings. */
function questionSize(length: number): string {
if (length <= 60) return "text-[clamp(1.5rem,8vh,7rem)]";
if (length <= 140) return "text-[clamp(1.25rem,6vh,5rem)]";
if (length <= 280) return "text-[clamp(1.125rem,4.5vh,3.5rem)]";
return "text-[clamp(1rem,3.4vh,2.5rem)]";
}
export function QuestionPanel({ question }: { question: Question }) {
const [imageUrl] = useState(question.imageUrl);
return (
<section
role="status"
className="buzz-pop flex max-h-[62vh] min-h-0 shrink-0 flex-col items-center justify-center gap-[1.5vh] overflow-hidden rounded-[1vh] border-[0.4vh] border-white/25 bg-white/5 px-[3vw] py-[2vh] text-center"
>
{imageUrl && (
// A plain <img>: the picture comes from any host or a signed storage URL, which
// next/image would need configuring for and can't cache anyway.
// eslint-disable-next-line @next/next/no-img-element
<img src={imageUrl} alt="" className="max-h-[30vh] min-h-0 rounded-[1vh] object-contain" />
)}
<p className={`font-display leading-[1.1] font-bold text-balance ${questionSize(question.text.length)}`}>
{question.text}
</p>
{question.answer && <Answer text={question.answer} />}
</section>
);
}
function Answer({ text }: { text: string }) {
return (
<p className="buzz-pop max-w-full rounded-[1vh] bg-floodlight px-[2.5vw] py-[1vh] font-display text-[clamp(1.25rem,6vh,5rem)] leading-[1.1] font-bold text-ink">
{text}
</p>
);
}
/** A sound has nothing to look at: a slim banner says something is playing. */
export function SoundBanner({ sound, audible }: { sound: Sound; audible: boolean }) {
return (
<section
role="status"
className="buzz-pop relative flex shrink-0 flex-col items-center gap-[1vh] overflow-hidden rounded-[1vh] border-[0.4vh] border-white/25 bg-white/5 px-[3vw] py-[1.5vh] text-center"
>
<p className="font-display text-[clamp(1.25rem,6vh,5rem)] leading-none font-bold">
<span aria-hidden>♪ </span>
{sound.paused ? "Paused" : "Listen…"}
</p>
{!audible && <p className="text-[clamp(0.875rem,2.4vh,1.75rem)] text-floodlight">{SOUND_OFF_NOTE}</p>}
{sound.answer && <Answer text={sound.answer} />}
{/* A YouTube link used as a sound: the player is there, but nobody sees the video. */}
<div aria-hidden className="pointer-events-none absolute top-0 left-0 size-[200px] opacity-0">
<Player media={sound.media} paused={sound.paused} audible={audible} audioOnly />
</div>
</section>
);
}
/** A video (karaoke, a clip) replaces the standings and fills the screen. */
export function VideoStage({ video, audible }: { video: Video; audible: boolean }) {
return (
<section aria-label="Video" className="relative min-h-0 flex-1 overflow-hidden rounded-[1vh] bg-black">
<Player media={video.media} paused={video.paused} audible={audible} />
{!audible && (
<p className="absolute top-[2vh] left-1/2 -translate-x-1/2 rounded-full bg-stage/90 px-[2vw] py-[1vh] text-[clamp(0.875rem,2.4vh,1.75rem)] font-bold text-floodlight">
{SOUND_OFF_NOTE}
</p>
)}
</section>
);
}
type PlayerProps = {
media: ScreenMedia;
paused: boolean;
/** False until someone has clicked the page: browsers only allow muted playback before that. */
audible: boolean;
audioOnly?: boolean;
};
function Player(props: PlayerProps) {
// Pinned: see the note at the top of this file.
const [media] = useState(props.media);
return media.type === "youtube" ? (
<YouTubePlayer {...props} videoId={media.videoId} startSeconds={media.startSeconds} />
) : (
<FilePlayer {...props} url={media.url} />
);
}
function FilePlayer({ url, paused, audible, audioOnly }: PlayerProps & { url: string }) {
const element = useRef<HTMLVideoElement & HTMLAudioElement>(null);
useEffect(() => {
const media = element.current;
if (!media) return;
// Set here, not as a prop: React doesn't keep the `muted` attribute in sync.
media.muted = !audible;
if (paused) media.pause();
// Rejected if the browser still blocks playback; the next change tries again.
else void media.play().catch(() => {});
}, [paused, audible]);
return audioOnly ? (
<audio ref={element} src={url} preload="auto" />
) : (
<video ref={element} src={url} preload="auto" playsInline className="size-full object-contain" />
);
}
const YOUTUBE_ORIGIN = "https://www.youtube-nocookie.com";
function YouTubePlayer({
videoId,
startSeconds,
paused,
audible,
}: PlayerProps & { videoId: string; startSeconds: number }) {
const frame = useRef<HTMLIFrameElement>(null);
// The URL carries the state the player starts in; later changes are sent as commands.
// Changing the URL instead would reload the video from the start.
const [src] = useState(() => {
const params = new URLSearchParams({
autoplay: paused ? "0" : "1",
mute: audible ? "0" : "1",
start: String(startSeconds),
enablejsapi: "1",
playsinline: "1",
rel: "0",
});
return `${YOUTUBE_ORIGIN}/embed/${videoId}?${params}`;
});
// YouTube's embed takes player commands as postMessage JSON once enablejsapi=1 is set.
const sync = useCallback(() => {
const send = (func: string) =>
frame.current?.contentWindow?.postMessage(JSON.stringify({ event: "command", func, args: [] }), YOUTUBE_ORIGIN);
send(audible ? "unMute" : "mute");
send(paused ? "pauseVideo" : "playVideo");
}, [audible, paused]);
useEffect(() => {
sync();
}, [sync]);
return (
<iframe
ref={frame}
src={src}
title="Video"
onLoad={sync}
allow="autoplay; encrypted-media; fullscreen"
referrerPolicy="strict-origin-when-cross-origin"
className="size-full border-0"
/>
);
}