forked from unom/punktfunk
**The streamed-screen pin could still be clobbered by three other write paths.** Deferring it to the server's value on Save fixed the reported sequence but not the general case: the draft is only re-seeded while it is CLEAN, so once there is an unsaved edit its `capture_monitor` is frozen at whatever it was before the operator used the picker — and `applyAxis` (which spreads the last saved policy), the built-in preset switch and the custom-preset apply all put that stale value back. Every write path reads `serverCaptureMonitor()` now; no path spreads the draft's copy. **The session⇄game grace input had no accessible name.** That card has its own `Field` and only DisplayCard's was fixed, so the number input was still announced as an unnamed spin button. Same treatment: `htmlFor`/`id` for the single control, `fieldset`/`legend` for the two button groups. Verified in a browser — zero inputs without an accessible name across the Displays page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
255 lines
8.6 KiB
TypeScript
255 lines
8.6 KiB
TypeScript
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. An EMPTY list means the build enforces nothing — the contract
|
|
// says so outright ("Empty on a platform with no launch path (macOS), so the console can say so
|
|
// instead of offering a switch that does nothing"), and this card's own comment promises the
|
|
// controls are "shown disabled rather than hidden".
|
|
//
|
|
// The old `enforced.length === 0 || …` read empty as "enforces EVERYTHING", so on exactly the
|
|
// platform the flag exists for, every control stayed live: clicking one PUT the setting and
|
|
// toasted success for an axis the host would never act on. Absent (an older host that never
|
|
// sent the field) still means "assume it acts" — that is the compatible reading, and it is a
|
|
// different case from present-and-empty.
|
|
const enforced = q.data?.enforced;
|
|
const acts = (field: string) => !enforced || 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()}
|
|
group
|
|
>
|
|
<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()}
|
|
group
|
|
>
|
|
<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>
|
|
)}
|
|
{/* Shown for every option, including "leave it running": on a nested
|
|
gamescope launch the game IS inside the streamed display, so the
|
|
display's own keep-alive outranks anything chosen here — verified
|
|
on glass (.41), where a deliberate stop ended the game under
|
|
`keep`. Worded so a non-gamescope host reads it and moves on. */}
|
|
<p className="max-w-prose text-xs text-muted-foreground">
|
|
{m.session_game_nested_note()}
|
|
</p>
|
|
</Field>
|
|
|
|
{(server.game_on_session_end ?? "keep") === "always" && (
|
|
<Field
|
|
label={m.session_game_grace()}
|
|
help={m.session_game_grace_help()}
|
|
htmlFor="session-grace-seconds"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
id="session-grace-seconds"
|
|
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>
|
|
)}
|
|
|
|
{/* Present-and-empty is the "this build acts on none of it" signal; ABSENT
|
|
is an older host that never sent the field, where claiming inertness
|
|
would be a guess. Same distinction `acts()` makes above. */}
|
|
{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(),
|
|
};
|
|
|
|
/**
|
|
* A labelled block. `htmlFor` pairs the label with a single control; without one it is a group.
|
|
*
|
|
* A bare `<Label>` beside an `<input>` with no `id` labels nothing at all — the grace input was
|
|
* announced as an unnamed spin button. Mirrors the same fix in DisplayCard's `Field`; the two stay
|
|
* separate on purpose (this card's axes are its own).
|
|
*/
|
|
const Field: FC<{
|
|
label: string;
|
|
help?: string;
|
|
children: ReactNode;
|
|
htmlFor?: string;
|
|
group?: boolean;
|
|
}> = ({ label, help, children, htmlFor, group }) => {
|
|
const body = (
|
|
<>
|
|
<Label className="block" htmlFor={htmlFor}>
|
|
{label}
|
|
</Label>
|
|
{children}
|
|
{help && (
|
|
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
|
|
)}
|
|
</>
|
|
);
|
|
return group ? (
|
|
<fieldset className="space-y-3">
|
|
<legend className="mb-3 block text-sm font-medium leading-none">
|
|
{label}
|
|
</legend>
|
|
{children}
|
|
{help && (
|
|
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
|
|
)}
|
|
</fieldset>
|
|
) : (
|
|
<div className="space-y-3">{body}</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>
|
|
);
|