feat(mgmt/web): show the running game, and who decides when it ends

The host knows what it launched and, after a disconnect, that it is counting
down to closing it. None of that was visible: the console showed a stream with
no game, and a game on its way to being closed was something you found out about
afterwards.

The Dashboard gains a running-game card above the session card — box art matched
against the catalog it already fetches, so no new endpoint and no request on the
2 s status poll. "End now" means the two different things the row's state
implies: a live game ends by stopping its session (what then happens to the game
follows the policy — stopping a session is not licence to close a game), while
one already waiting out its reconnect window has no session left to stop and is
ended directly.

The settings card sits next to the display keep-alive policy because that is the
same question one step out: keep-alive decides how long a *display* outlives a
disconnect, this decides whether the *game* does. The copy says plainly what
`always` costs, that a drop is not someone pressing Stop, and that a display kept
forever is unaffected either way — the precedence rule that would otherwise
surprise someone. Where a build enforces nothing (macOS, no launch path) the
controls are shown disabled rather than hidden: "does nothing here" is
information.

Also fixes the story fixtures, which had gone stale against the `games[]` field
Phase 1 added, and adds a story for the state the card exists for — a game whose
client walked away.

web: build + tsc clean, biome-formatted; en/de messages complete.
This commit is contained in:
2026-07-26 18:28:13 +02:00
parent 110eabf281
commit 6a2c127ce9
9 changed files with 495 additions and 5 deletions
+24 -1
View File
@@ -376,5 +376,28 @@
"store_phase_rolling_back": "Wird zurückgerollt", "store_phase_rolling_back": "Wird zurückgerollt",
"store_phase_recording": "Herkunft wird vermerkt", "store_phase_recording": "Herkunft wird vermerkt",
"store_phase_restarting": "Plugin-Runner startet neu", "store_phase_restarting": "Plugin-Runner startet neu",
"store_phase_done": "Fertig" "store_phase_done": "Fertig",
"games_title": "Laufende Spiele",
"games_state_launching": "Startet",
"games_state_running": "Läuft",
"games_state_exited": "Beendet",
"games_state_grace": "Wartet auf Client",
"games_closing_in": "Client ist weg wird in {time} geschlossen, falls er nicht zurückkommt",
"games_end_now": "Jetzt beenden",
"session_game_title": "Wenn ein Spiel oder eine Sitzung endet",
"session_game_help": "Eine Streaming-Sitzung und das Spiel, das sie gestartet hat, können ihr Schicksal teilen. Diese Einstellungen betreffen das Spiel; das Offenhalten oben betrifft die Anzeige, und beide haben eigene Zeitfenster.",
"session_game_on_exit": "Wenn das Spiel endet",
"session_game_on_exit_help": "Wer das Spiel beendet, landet wieder in der eigenen Bibliothek statt auf deinem Desktop. Schalte es aus, wenn du den Desktop streamst und das Spiel nebensächlich ist.",
"session_game_on_exit_end": "Sitzung beenden",
"session_game_on_exit_keep": "Weiter streamen",
"session_game_end_game": "Wenn die Sitzung endet",
"session_game_end_game_help": "Ob das Stoppen (oder der Verlust) einer Sitzung auch das gestartete Spiel schließt. Gilt nie für ein Spiel, das du selbst gestartet hast nur für eines, das dieser Host für die Sitzung gestartet hat.",
"session_game_end_keep": "Weiterlaufen lassen",
"session_game_end_on_quit": "Beim Stoppen schließen",
"session_game_end_always": "Immer schließen",
"session_game_always_warning": "Ein Spiel zu schließen kostet alles, was es nicht gespeichert hat. Der Host bittet es zuerst höflich und erzwingt es nur, wenn es sich weigert aber ein Verbindungsabbruch ist kein bewusstes Stoppen, deshalb bekommt ein abgerissener Client erst das Zeitfenster unten. Eine dauerhaft offen gehaltene Anzeige bleibt davon unberührt: Diese Einstellung regelt das Spiel, nicht den Bildschirm.",
"session_game_grace": "Zeitfenster für die Rückkehr",
"session_game_grace_help": "Wie lange ein verschwundener Client Zeit hat zurückzukommen, bevor sein Spiel geschlossen wird. Die Konsole zeigt den Countdown; eine neue Verbindung bricht ihn ab.",
"session_game_saved": "Sitzungs- und Spieleinstellungen gespeichert",
"session_game_inert": "Dieser Host kann keine Spiele starten hier bewirken diese Einstellungen nichts"
} }
+24 -1
View File
@@ -376,5 +376,28 @@
"store_phase_rolling_back": "Rolling back", "store_phase_rolling_back": "Rolling back",
"store_phase_recording": "Recording provenance", "store_phase_recording": "Recording provenance",
"store_phase_restarting": "Restarting the plugin runner", "store_phase_restarting": "Restarting the plugin runner",
"store_phase_done": "Done" "store_phase_done": "Done",
"games_title": "Running games",
"games_state_launching": "Starting",
"games_state_running": "Running",
"games_state_exited": "Ended",
"games_state_grace": "Waiting for client",
"games_closing_in": "Its client is gone — closing in {time} unless it comes back",
"games_end_now": "End now",
"session_game_title": "When a game or a session ends",
"session_game_help": "A streaming session and the game it launched can share a fate. These settings are about the game; the keep-alive above is about the display, and the two have separate timers.",
"session_game_on_exit": "When the game exits",
"session_game_on_exit_help": "Quitting the game hands the client back to its own library instead of leaving it on your desktop. Turn this off if you stream the desktop and treat the game as incidental.",
"session_game_on_exit_end": "End the session",
"session_game_on_exit_keep": "Keep streaming",
"session_game_end_game": "When the session ends",
"session_game_end_game_help": "Whether stopping (or losing) a session also closes the game it launched. Never applies to a game you started yourself — only one this host launched for the session.",
"session_game_end_keep": "Leave it running",
"session_game_end_on_quit": "Close it on Stop",
"session_game_end_always": "Always close it",
"session_game_always_warning": "Closing a game costs whatever it had not saved. The host asks it to close first and only forces the issue if it refuses — but a network drop is not someone pressing Stop, so a dropped client gets the reconnect window below before anything happens. A display kept forever stays up regardless; this setting governs the game, not the screen.",
"session_game_grace": "Reconnect window",
"session_game_grace_help": "How long a client that vanished has to come back before its game is closed. The console shows the countdown, and reconnecting cancels it.",
"session_game_saved": "Session and game settings saved",
"session_game_inert": "This host has no way to launch games, so these settings do nothing here"
} }
+138
View File
@@ -0,0 +1,138 @@
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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Gamepad2 className="size-4" />
{m.games_title()}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{games.map((g) => (
<GameRow
// A host can run the same title for two clients at once (native admits concurrent
// sessions), so the title alone is not a key.
key={`${g.plane}:${g.session_id ?? "grace"}:${g.app_id ?? g.title}`}
game={g}
art={coverFor(g, library)}
onEnd={() => onEnd(g)}
isEnding={isEnding}
/>
))}
</CardContent>
</Card>
);
};
const GameRow: FC<{
game: ActiveGame;
art?: string;
onEnd: () => void;
isEnding: boolean;
}> = ({ game, art, onEnd, isEnding }) => {
const waiting = game.state === "grace";
return (
<div className="flex items-center gap-3">
<div className="h-16 w-11 shrink-0 overflow-hidden rounded bg-muted">
{art && (
<img
src={art}
alt=""
loading="lazy"
className="size-full object-cover"
/>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium">{game.title}</span>
<Badge variant={waiting ? "destructive" : stateVariant(game.state)}>
{stateLabel(game.state)}
</Badge>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{waiting
? m.games_closing_in({
time: formatCountdown(game.grace_remaining_s ?? 0),
})
: [game.client, planeLabel(game.plane)].filter(Boolean).join(" · ")}
</p>
</div>
<Button
variant={waiting ? "destructive" : "outline"}
size="sm"
disabled={isEnding}
onClick={onEnd}
>
<XCircle className="size-3.5" />
{m.games_end_now()}
</Button>
</div>
);
};
/**
* 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" : "";
}
+33 -1
View File
@@ -1,7 +1,13 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import type { FC } from "react"; import type { FC } from "react";
import { getGetStatusQueryKey, useGetStatus } from "@/api/gen/host/host"; import { getGetStatusQueryKey, useGetStatus } from "@/api/gen/host/host";
import { useRequestIdr, useStopSession } from "@/api/gen/session/session"; import { useGetLibrary } from "@/api/gen/library/library";
import type { ActiveGame } from "@/api/gen/model/activeGame";
import {
useEndGame,
useRequestIdr,
useStopSession,
} from "@/api/gen/session/session";
import { useLocale } from "@/lib/i18n"; import { useLocale } from "@/lib/i18n";
import { DashboardView } from "./view"; import { DashboardView } from "./view";
@@ -10,19 +16,45 @@ export const SectionDashboard: FC = () => {
const qc = useQueryClient(); const qc = useQueryClient();
// Poll live status every 2s so the console tracks an active session. // Poll live status every 2s so the console tracks an active session.
const status = useGetStatus({ query: { refetchInterval: 2_000 } }); const status = useGetStatus({ query: { refetchInterval: 2_000 } });
// The catalog, for the running-game card's box art. Fetched once and held: a library scan touches
// every installed store's on-disk metadata, so it must not ride the 2 s status poll.
const library = useGetLibrary(undefined, {
query: { staleTime: 5 * 60_000 },
});
const stop = useStopSession(); const stop = useStopSession();
const idr = useRequestIdr(); const idr = useRequestIdr();
const endGame = useEndGame();
const invalidate = () => const invalidate = () =>
qc.invalidateQueries({ queryKey: getGetStatusQueryKey() }); qc.invalidateQueries({ queryKey: getGetStatusQueryKey() });
/**
* "End now" means two different things, and which one is right follows from the row's state: a
* game whose session is still live ends by stopping that session (what then happens to the game
* follows the operator's policy — stopping a session is not licence to close a game), while a
* game already waiting out its reconnect window has no session left to stop and is ended directly.
*/
const onEndGame = (game: ActiveGame) => {
if (game.state === "grace") {
endGame.mutate(
{ data: { app_id: game.app_id ?? null } },
{ onSuccess: invalidate },
);
} else {
stop.mutate(undefined, { onSuccess: invalidate });
}
};
return ( return (
<DashboardView <DashboardView
status={status} status={status}
library={library.data}
onStopSession={() => stop.mutate(undefined, { onSuccess: invalidate })} onStopSession={() => stop.mutate(undefined, { onSuccess: invalidate })}
onRequestIdr={() => idr.mutate(undefined)} onRequestIdr={() => idr.mutate(undefined)}
onEndGame={onEndGame}
isStopping={stop.isPending} isStopping={stop.isPending}
isRequestingIdr={idr.isPending} isRequestingIdr={idr.isPending}
isEndingGame={endGame.isPending || stop.isPending}
/> />
); );
}; };
+25 -1
View File
@@ -1,6 +1,8 @@
import Section from "@unom/ui/section"; import Section from "@unom/ui/section";
import { MonitorPlay, RefreshCw, Video, Volume2, ZapOff } from "lucide-react"; import { MonitorPlay, RefreshCw, Video, Volume2, ZapOff } from "lucide-react";
import type { FC, ReactNode } from "react"; import type { FC, ReactNode } from "react";
import type { ActiveGame } from "@/api/gen/model/activeGame";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus"; import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
import { QueryState } from "@/components/query-state"; import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -8,14 +10,27 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query"; import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages"; import { m } from "@/paraglide/messages";
import { RunningGames } from "./RunningGames";
export const DashboardView: FC<{ export const DashboardView: FC<{
status: Loadable<RuntimeStatus>; status: Loadable<RuntimeStatus>;
library?: GameEntry[];
onStopSession: () => void; onStopSession: () => void;
onRequestIdr: () => void; onRequestIdr: () => void;
onEndGame: (game: ActiveGame) => void;
isStopping: boolean; isStopping: boolean;
isRequestingIdr: boolean; isRequestingIdr: boolean;
}> = ({ status, onStopSession, onRequestIdr, isStopping, isRequestingIdr }) => { isEndingGame: boolean;
}> = ({
status,
library,
onStopSession,
onRequestIdr,
onEndGame,
isStopping,
isRequestingIdr,
isEndingGame,
}) => {
const s = status.data; const s = status.data;
return ( return (
<Section maxWidth={false}> <Section maxWidth={false}>
@@ -61,6 +76,15 @@ export const DashboardView: FC<{
</Card> </Card>
</div> </div>
{/* Above the session card: a game the host is about to close is the most
time-sensitive thing on this page. */}
<RunningGames
games={s.games}
library={library}
onEnd={onEndGame}
isEnding={isEndingGame}
/>
<Card> <Card>
<CardHeader className="flex flex-col items-start gap-3 space-y-0 sm:flex-row sm:items-center sm:justify-between"> <CardHeader className="flex flex-col items-start gap-3 space-y-0 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -0,0 +1,206 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast";
import { type FC, type ReactNode, useEffect, useState } from "react";
import { ApiError } from "@/api/fetcher";
import type { GameOnSessionEnd, SessionSettings } from "@/api/gen/model";
import {
getGetSessionSettingsQueryKey,
useGetSessionSettings,
useSetSessionSettings,
} from "@/api/gen/session/session";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
const END_POLICIES: GameOnSessionEnd[] = ["keep", "on_quit", "always"];
/**
* Whether a launched game and its streaming session share a fate
* (design/session-game-lifetime.md), next to the display keep-alive policy because the two interact:
* a kept display and a kept game are separate decisions with separate timers, and `keep_alive:
* forever` outranks the game policy for the display itself.
*
* Every axis saves on change (like the display policy above) — there is no Save button to miss.
*/
export const SessionGameCard: FC = () => {
const qc = useQueryClient();
const q = useGetSessionSettings();
const save = useSetSessionSettings();
const server = q.data?.settings;
// Which axes this build acts on. Empty on a platform with no launch path (macOS), where the
// controls are shown disabled rather than hidden — "does nothing here" is information.
const enforced = q.data?.enforced ?? [];
const acts = (field: string) =>
enforced.length === 0 || enforced.includes(field);
// The grace field is free text while being typed, so it gets a local buffer; the other two axes
// are discrete and go straight to the host.
const [grace, setGrace] = useState("");
useEffect(() => {
if (server) setGrace(String(server.disconnect_grace_seconds ?? 300));
}, [server]);
const apply = (patch: Partial<SessionSettings>) => {
if (!server) return;
save.mutate(
{ data: { ...server, ...patch } },
{
onSuccess: () => {
qc.invalidateQueries({ queryKey: getGetSessionSettingsQueryKey() });
toast.success(m.session_game_saved());
},
},
);
};
const busy = save.isPending;
const error = save.error instanceof ApiError ? save.error.message : undefined;
return (
<Card>
<CardHeader>
<CardTitle>{m.session_game_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="max-w-prose text-sm text-muted-foreground">
{m.session_game_help()}
</p>
<QueryState isLoading={q.isLoading} error={q.error} refetch={q.refetch}>
{server && (
<div className="space-y-6">
<Field
label={m.session_game_on_exit()}
help={m.session_game_on_exit_help()}
>
<div className="flex flex-wrap gap-2">
<Choice
selected={server.session_on_game_exit === true}
disabled={busy || !acts("session_on_game_exit")}
onClick={() => apply({ session_on_game_exit: true })}
>
{m.session_game_on_exit_end()}
</Choice>
<Choice
selected={server.session_on_game_exit === false}
disabled={busy || !acts("session_on_game_exit")}
onClick={() => apply({ session_on_game_exit: false })}
>
{m.session_game_on_exit_keep()}
</Choice>
</div>
</Field>
<Field
label={m.session_game_end_game()}
help={m.session_game_end_game_help()}
>
<div className="flex flex-wrap gap-2">
{END_POLICIES.map((p) => (
<Choice
key={p}
selected={(server.game_on_session_end ?? "keep") === p}
disabled={busy || !acts("game_on_session_end")}
onClick={() => apply({ game_on_session_end: p })}
>
{END_POLICY_LABEL[p]()}
</Choice>
))}
</div>
{(server.game_on_session_end ?? "keep") === "always" && (
<p className="max-w-prose text-xs text-muted-foreground">
{m.session_game_always_warning()}
</p>
)}
</Field>
{(server.game_on_session_end ?? "keep") === "always" && (
<Field
label={m.session_game_grace()}
help={m.session_game_grace_help()}
>
<div className="flex items-center gap-2">
<Input
type="number"
min={10}
max={86400}
className="w-28"
value={grace}
disabled={busy || !acts("disconnect_grace_seconds")}
onChange={(e) => setGrace(e.target.value)}
onBlur={() => {
const n = Number(grace);
if (!Number.isFinite(n)) {
setGrace(
String(server.disconnect_grace_seconds ?? 300),
);
return;
}
// The host clamps to 10..=86400 and returns what it stored, so
// a nonsense number is corrected rather than rejected.
if (n !== server.disconnect_grace_seconds) {
apply({ disconnect_grace_seconds: n });
}
}}
/>
<span className="text-sm text-muted-foreground">
{m.display_keep_alive_seconds()}
</span>
</div>
</Field>
)}
{enforced.length === 0 && (
<Badge variant="outline">{m.session_game_inert()}</Badge>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
)}
</QueryState>
</CardContent>
</Card>
);
};
const END_POLICY_LABEL: Record<GameOnSessionEnd, () => string> = {
keep: () => m.session_game_end_keep(),
on_quit: () => m.session_game_end_on_quit(),
always: () => m.session_game_end_always(),
};
const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({
label,
help,
children,
}) => (
<div className="space-y-3">
<Label className="block">{label}</Label>
{children}
{help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</div>
);
const Choice: FC<{
selected: boolean;
disabled: boolean;
onClick: () => void;
children: ReactNode;
}> = ({ selected, disabled, onClick, children }) => (
<Button
type="button"
variant={selected ? "default" : "outline"}
size="sm"
disabled={disabled}
aria-pressed={selected}
className={cn(disabled && "opacity-60")}
onClick={onClick}
>
{children}
</Button>
);
+6
View File
@@ -3,11 +3,16 @@ import type { FC } from "react";
import { useLocale } from "@/lib/i18n"; import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages"; import { m } from "@/paraglide/messages";
import { DisplaySection } from "./DisplayCard"; import { DisplaySection } from "./DisplayCard";
import { SessionGameCard } from "./SessionGameCard";
/** /**
* The **Virtual displays** page (design/display-management.md): the host's virtual-display policy * The **Virtual displays** page (design/display-management.md): the host's virtual-display policy
* (presets + every axis) plus the live-display list + multi-monitor arrangement. Its own nav * (presets + every axis) plus the live-display list + multi-monitor arrangement. Its own nav
* section — the config surface is large enough to warrant the room, and it kept the Host page busy. * section — the config surface is large enough to warrant the room, and it kept the Host page busy.
*
* The session⇄game lifetime card sits here rather than on its own page because it is the same
* question one step further out: keep-alive decides how long a *display* outlives a disconnect, and
* this decides whether the *game* does.
*/ */
export const SectionDisplays: FC = () => { export const SectionDisplays: FC = () => {
useLocale(); useLocale();
@@ -16,6 +21,7 @@ export const SectionDisplays: FC = () => {
<div className="flex flex-col gap-card"> <div className="flex flex-col gap-card">
<h1 className="text-2xl font-semibold">{m.nav_displays()}</h1> <h1 className="text-2xl font-semibold">{m.nav_displays()}</h1>
<DisplaySection /> <DisplaySection />
<SessionGameCard />
</div> </div>
</Section> </Section>
); );
+8 -1
View File
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { DashboardView } from "@/sections/Dashboard/view"; import { DashboardView } from "@/sections/Dashboard/view";
import { statusActive, statusIdle } from "./lib/fixtures"; import { statusActive, statusGrace, statusIdle } from "./lib/fixtures";
const meta = { const meta = {
title: "Pages/Dashboard", title: "Pages/Dashboard",
@@ -8,8 +8,10 @@ const meta = {
args: { args: {
onStopSession: () => {}, onStopSession: () => {},
onRequestIdr: () => {}, onRequestIdr: () => {},
onEndGame: () => {},
isStopping: false, isStopping: false,
isRequestingIdr: false, isRequestingIdr: false,
isEndingGame: false,
}, },
} satisfies Meta<typeof DashboardView>; } satisfies Meta<typeof DashboardView>;
@@ -23,3 +25,8 @@ export const ActiveSession: Story = {
export const Idle: Story = { export const Idle: Story = {
args: { status: { data: statusIdle, isLoading: false, error: null } }, args: { status: { data: statusIdle, isLoading: false, error: null } },
}; };
/** A game whose client vanished: the host closes it when the countdown runs out. */
export const GameWaitingForItsClient: Story = {
args: { status: { data: statusGrace, isLoading: false, error: null } },
};
+31
View File
@@ -58,6 +58,17 @@ export const statusActive: RuntimeStatus = {
min_fec: 5, min_fec: 5,
packet_size: 1392, packet_size: 1392,
}, },
games: [
{
session_id: 1,
client: "Living room TV",
app_id: "steam:1145360",
title: "Hades",
store: "steam",
plane: "native",
state: "running",
},
],
}; };
export const statusIdle: RuntimeStatus = { export const statusIdle: RuntimeStatus = {
@@ -68,6 +79,26 @@ export const statusIdle: RuntimeStatus = {
active_sessions: 0, active_sessions: 0,
session: null, session: null,
stream: null, stream: null,
games: [],
};
/**
* Idle, but a game its client walked away from is still running — on a countdown to being closed.
* The state the running-game card exists for.
*/
export const statusGrace: RuntimeStatus = {
...statusIdle,
games: [
{
client: "Living room TV",
app_id: "steam:1145360",
title: "Hades",
store: "steam",
plane: "native",
state: "grace",
grace_remaining_s: 252,
},
],
}; };
export const pairedClients: PairedClient[] = [ export const pairedClients: PairedClient[] = [