import { Gamepad2, XCircle } from "lucide-react"; import type { FC } from "react"; import type { ActiveGame } from "@/api/gen/model/activeGame"; import type { GameEntry } from "@/api/gen/model/gameEntry"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { m } from "@/paraglide/messages"; /** * What the host is running, and — when a client has vanished — how long its game has left. * * The `grace` rows are the reason this card exists at all. A game whose session is gone is on a * countdown to being closed, which costs unsaved progress; that has to be visible somewhere, with a * way to say "yes, do it now" or (by reconnecting) "no". The live rows come almost free alongside. */ export const RunningGames: FC<{ games: ActiveGame[]; /** The catalog, for box art — matched by `app_id`. Absent while `/library` is still loading. */ library?: GameEntry[]; onEnd: (game: ActiveGame) => void; isEnding: boolean; }> = ({ games, library, onEnd, isEnding }) => { if (games.length === 0) return null; return ( {m.games_title()} {games.map((g) => ( onEnd(g)} isEnding={isEnding} /> ))} ); }; const GameRow: FC<{ game: ActiveGame; art?: string; onEnd: () => void; isEnding: boolean; }> = ({ game, art, onEnd, isEnding }) => { const waiting = game.state === "grace"; return (
{/* Fixed slot so rows line up whether or not a title has a cover. Plenty won't: an operator-typed command has no catalog entry behind it, a custom entry may carry no art, and nothing does until `/library` has loaded — an empty box reads as broken, so the placeholder says "game" instead of nothing. */}
{art ? ( ) : ( )}
{game.title} {stateLabel(game.state)}

{waiting ? m.games_closing_in({ time: formatCountdown(game.grace_remaining_s ?? 0), }) : [game.client, planeLabel(game.plane)].filter(Boolean).join(" · ")}

); }; /** * Box art for a running game, matched against the already-fetched catalog by store-qualified id — * so this card costs no extra request. `undefined` for an operator-typed GameStream command (no * catalog entry behind it) or a title whose entry carries no art. */ function coverFor(game: ActiveGame, library?: GameEntry[]): string | undefined { if (!game.app_id || !library) return undefined; const entry = library.find((e) => e.id === game.app_id); return entry?.art.portrait ?? entry?.art.header ?? undefined; } /** `mm:ss` — the countdown reads as a duration, not a number of seconds. */ function formatCountdown(seconds: number): string { const s = Math.max(0, Math.floor(seconds)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; } function stateLabel(state: string): string { switch (state) { case "launching": return m.games_state_launching(); case "running": return m.games_state_running(); case "exited": return m.games_state_exited(); case "grace": return m.games_state_grace(); default: return state; } } function stateVariant(state: string): "success" | "secondary" | "outline" { if (state === "running") return "success"; if (state === "launching") return "secondary"; return "outline"; } /** The compat plane is worth naming: it is why a row may show an IP instead of a device name. */ function planeLabel(plane: string): string { return plane === "gamestream" ? "GameStream" : ""; }