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) => { 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 ( {m.session_game_title()}

{m.session_game_help()}

{server && (
apply({ session_on_game_exit: true })} > {m.session_game_on_exit_end()} apply({ session_on_game_exit: false })} > {m.session_game_on_exit_keep()}
{END_POLICIES.map((p) => ( apply({ game_on_session_end: p })} > {END_POLICY_LABEL[p]()} ))}
{(server.game_on_session_end ?? "keep") === "always" && (

{m.session_game_always_warning()}

)} {/* 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. */}

{m.session_game_nested_note()}

{(server.game_on_session_end ?? "keep") === "always" && (
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 }); } }} /> {m.display_keep_alive_seconds()}
)} {/* 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 && ( {m.session_game_inert()} )} {error &&

{error}

}
)}
); }; const END_POLICY_LABEL: Record 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 `