diff --git a/apps/web/src/app.ts b/apps/web/src/app.ts index 1ca21ee..fee9923 100644 --- a/apps/web/src/app.ts +++ b/apps/web/src/app.ts @@ -98,6 +98,10 @@ class App { private streamGame: { appId: string; title: string } | null = null; /** What the host said when End game was refused mid-stream, for a few seconds. */ private streamNotice: string | undefined; + /** When the live stream began, while its exit hint is up; `0` once the hint is gone. */ + private hintSince = 0; + private hintTimer = 0; + private wasStreaming = false; private streamNoticeTimer = 0; /** The origin whose running title is being polled. */ private pollingFor: string | null = null; @@ -237,6 +241,19 @@ class App { this.menuOpen = false; this.streamGame = null; } + // The exit hint: armed on the edge into a stream, dropped by its timer or the stream's end. + const streaming = s.kind === "streaming"; + if (streaming && !this.wasStreaming && this.prefs.exitHint) { + this.hintSince = Date.now(); + clearTimeout(this.hintTimer); + this.hintTimer = window.setTimeout(() => { + this.hintSince = 0; + this.render(this.engine.current); + }, EXIT_HINT_MS); + } else if (!streaming) { + this.hintSince = 0; + } + this.wasStreaming = streaming; if (this.settingsOpen) { return this.show({ kind: "settings", values: this.prefs, streaming: s.kind === "streaming" }); } @@ -337,6 +354,9 @@ class App { stats: { origin: s.origin, ...s.stats }, diagnostics: s.stats.statsTier !== "off", menu: this.menuOpen, + corner: this.prefs.hudPlacement, + scale: this.prefs.statsScalePct / 100, + ...(this.hintSince ? { exitHint: exitHintText() } : {}), ...(this.streamGame ? { endGame: this.streamGame.title } : {}), ...(this.streamNotice ? { notice: this.streamNotice } : {}), }); @@ -1085,3 +1105,14 @@ try { text: sentence(e instanceof Error ? e.message : String(e)), }); } + +/** How long the exit hint stays up; the page's keyframes fade it out over the last tenth. */ +const EXIT_HINT_MS = 6000; + +/** How to leave with the input in hand: the pad chord with a controller connected, the dial's + * End stream on a touchscreen, the key otherwise. */ +function exitHintText(): string { + if (navigator.getGamepads?.().some((g) => g?.mapping === "standard")) return "Hold L1 + R1 + Start + Select to leave"; + if (matchMedia("(pointer: coarse)").matches) return "Menu, then End stream, to leave"; + return "Ctrl+Alt+Shift+D to leave"; +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 23d63b9..5f0b9e3 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -202,3 +202,14 @@ radial-gradient(50rem 36rem at 90% 110%, rgb(108 91 243 / 0.14), transparent 62%), var(--background); } + +/* The exit hint: six seconds on screen, the last 0.6 fading, as on every client. */ +@keyframes pf-exit-hint { + 0%, + 90% { + opacity: 1; + } + 100% { + opacity: 0; + } +} diff --git a/apps/web/src/ui/fixtures.ts b/apps/web/src/ui/fixtures.ts index 55c49ad..e9c62bf 100644 --- a/apps/web/src/ui/fixtures.ts +++ b/apps/web/src/ui/fixtures.ts @@ -54,6 +54,8 @@ const hosts: HostCard[] = [ ]; const shelves = hosts.filter((h) => h.fingerprint); const origin = "https://192.168.1.25:47990"; +/** The statistics where a fresh browser puts them. */ +const overlay = { corner: "topTrailing", scale: 1 } as const; export const SCREENS = { "home": { kind: "home", adding: false, hosts }, @@ -78,14 +80,16 @@ export const SCREENS = { "library-loading": { kind: "library", origin, shelves, host: "living-room-pc", entries: [], art: new Map(), busy: true }, "library-offline": { kind: "library", origin, shelves, host: "living-room-pc", entries: [], art: new Map(), offline: true }, "library-none": { kind: "library", origin: null, shelves: [], entries: [], art: new Map(), offline: true }, - "streaming": { kind: "streaming", stats, diagnostics: false, menu: false }, - "streaming-diagnostics": { kind: "streaming", stats, diagnostics: true, menu: false }, - "streaming-menu": { kind: "streaming", stats, diagnostics: false, menu: true }, + "streaming": { kind: "streaming", stats, diagnostics: false, menu: false, ...overlay }, + "streaming-hint": { kind: "streaming", stats, diagnostics: false, menu: false, ...overlay, exitHint: "Ctrl+Alt+Shift+D to leave" }, + "streaming-diagnostics": { kind: "streaming", stats, diagnostics: true, menu: false, ...overlay }, + "streaming-menu": { kind: "streaming", stats, diagnostics: false, menu: true, ...overlay }, "streaming-captured": { kind: "streaming", stats: { ...stats, pointerCaptured: true }, diagnostics: false, menu: false, + ...overlay, }, "settings": { kind: "settings", values: DEFAULTS, streaming: false }, "settings-streaming": { kind: "settings", values: DEFAULTS, streaming: true }, diff --git a/apps/web/src/ui/hud.tsx b/apps/web/src/ui/hud.tsx index 4126893..bbdaa5d 100644 --- a/apps/web/src/ui/hud.tsx +++ b/apps/web/src/ui/hud.tsx @@ -1,7 +1,7 @@ // The overlay over a live picture. Out of the way by default — this is the screen someone came // for — and brought back by a pointer, a key or a tap, then hidden again. -import type { HudLine } from "@punktfunk/stream"; +import type { HudCorner, HudLine } from "@punktfunk/stream"; import { cn } from "@unom/ui/lib/utils"; import { Maximize, Menu, MousePointer2, Settings } from "lucide-react"; import { type JSX, useEffect, useState } from "react"; @@ -100,7 +100,13 @@ export function Hud({ screen, actions }: { screen: Extract {screen.menu && } - {screen.diagnostics && } + {screen.diagnostics && } + {/* How to leave, once, as the stream starts; the captured-pointer line below says it then. */} + {screen.exitHint && !stats.pointerCaptured && ( +
+ {screen.exitHint} +
+ )} {/* The host's word on a launch that did not give the player their game, or on this device's access changing or about to end. */} {(screen.notice ?? stats.launchNotice ?? stats.accessNotice) && ( @@ -213,9 +219,18 @@ const ROLE_CLASS: Record = { warn: "text-amber-500", }; +/** Where each corner puts the panel. The top ones clear the toolbar. */ +const CORNER_CLASS: Record = { + topLeading: "top-[calc(3.5rem+var(--pf-inset))] left-inset", + topTrailing: "top-[calc(3.5rem+var(--pf-inset))] right-inset", + bottomLeading: "bottom-inset left-inset", + bottomTrailing: "bottom-inset right-inset", +}; + /** The stats overlay every client draws, in this session's tier and vocabulary, then what only a - * browser measures. Lines come from the engine formatted; this panel only paints them. */ -function Diagnostics({ stats: s }: { stats: SessionStats }): JSX.Element { + * browser measures. Lines come from the engine formatted; this panel only paints them, in + * `corner` and at `scale` times its size. */ +function Diagnostics({ stats: s, corner, scale }: { stats: SessionStats; corner: HudCorner; scale: number }): JSX.Element { const lines = s.hud ?? []; const browser = [ s.backend ?? "", @@ -228,7 +243,14 @@ function Diagnostics({ stats: s }: { stats: SessionStats }): JSX.Element { return ( {lines.map((l, i) => ( // Lines have no identity beyond their place in the list. diff --git a/apps/web/src/ui/screens.stories.tsx b/apps/web/src/ui/screens.stories.tsx index 5d99079..f6574e6 100644 --- a/apps/web/src/ui/screens.stories.tsx +++ b/apps/web/src/ui/screens.stories.tsx @@ -40,6 +40,7 @@ export const LibraryLoading = of(SCREENS["library-loading"]); export const LibraryOffline = of(SCREENS["library-offline"]); export const LibraryNone = of(SCREENS["library-none"]); export const Streaming = of(SCREENS["streaming"]); +export const StreamingHint = of(SCREENS["streaming-hint"]); export const StreamingDiagnostics = of(SCREENS["streaming-diagnostics"]); export const StreamingCaptured = of(SCREENS["streaming-captured"]); export const StreamingMenu = of(SCREENS["streaming-menu"]); diff --git a/apps/web/src/ui/settings.tsx b/apps/web/src/ui/settings.tsx index f0a7ff2..ac0710a 100644 --- a/apps/web/src/ui/settings.tsx +++ b/apps/web/src/ui/settings.tsx @@ -1,14 +1,16 @@ // Settings, in the macOS client's tabs: General, Display, Input, Audio, Controllers, About. A // page in the frame, or a dialog over a live stream — the same tabs either way. Inside a tab, // rows are grouped in cards as the host console groups its own: label and hint on the left, the -// control on the right, and a choice of four or fewer as buttons side by side. +// control on the right, and a choice of four or fewer as buttons side by side. Each tab's +// advanced rows show under Show advanced; hidden, a tab says how many of them are changed. -import { DEFAULTS, type Settings } from "@punktfunk/stream"; +import { ASPECTS, aspectOf, customSize, DEFAULTS, nearest, type Settings, STATS_SCALES } from "@punktfunk/stream"; import { cn } from "@unom/ui/lib/utils"; import { type JSX, type ReactNode, useState } from "react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -18,16 +20,8 @@ import type { Actions, Screen } from "./types.ts"; type SettingsScreen = Extract; type SetFn = (patch: Partial) => void; -/** The sizes offered, and what each means on the wire. `0×0` follows the window, which is what - * a browser usually wants — the others pin a stream to something the host encodes well - * regardless of how the tab is sized. */ -const SIZES: ReadonlyArray<[label: string, width: number, height: number]> = [ - ["Follow the window", 0, 0], - ["1280 × 720", 1280, 720], - ["1920 × 1080", 1920, 1080], - ["2560 × 1440", 2560, 1440], - ["3840 × 2160", 3840, 2160], -]; +/** The Resolution list's entry that shows the typed width and height. */ +const CUSTOM = "custom"; const RATES = [30, 60, 90, 120, 144, 165, 240]; @@ -71,10 +65,20 @@ export function SettingsDialog({ screen, actions }: { screen: SettingsScreen; ac ); } +/** How many of `keys` hold something other than a fresh browser would. */ +function changed(v: Settings, keys: ReadonlyArray): number { + return keys.filter((k) => v[k] !== DEFAULTS[k]).length; +} + function SettingsTabs({ screen, actions }: { screen: SettingsScreen; actions: Actions }): JSX.Element { const [tab, setTab] = useState(lastTab); const v = screen.values; const set: SetFn = (patch) => actions.setSettings(patch); + const advanced = (keys: ReadonlyArray, rows: ReactNode) => ( + set({ showAdvanced: true })}> + {rows} + + ); return ( set({ fullscreen: on })} @@ -106,16 +110,6 @@ function SettingsTabs({ screen, actions }: { screen: SettingsScreen; actions: Ac onChange={(statsTier) => set({ statsTier })} /> - set({ advancedStats: on })} - > - - What each number means - - {!screen.streaming && ( @@ -124,24 +118,61 @@ function SettingsTabs({ screen, actions }: { screen: SettingsScreen; actions: Ac )} + + set({ showAdvanced: on })} + /> + + {advanced( + ["advancedStats", "hudPlacement", "statsScalePct", "exitHint"], + <> + set({ advancedStats: on })} + > + + What each number means + + + + set({ hudPlacement })} + /> + + + + + set({ exitHint: on })} + /> + , + )} - - - + {v.width === 0 && ( set({ resizeStream: on })} /> )} - + - + `${n} Mbps`} onValueChange={(n) => set({ bitrateKbps: n * 1000 })} /> )} - - set({ codec })} - /> - set({ hdr: on })} /> - - - set({ videoBackend })} - /> - - + {advanced( + ["codec", "videoBackend"], + <> + + set({ codec })} + /> + + + set({ videoBackend })} + /> + + , + )} @@ -275,11 +309,123 @@ function SettingsTabs({ screen, actions }: { screen: SettingsScreen; actions: Ac ); } +/** + * Aspect ratio over Resolution: the window, the family's sizes, then Custom…, which shows the + * typed width and height. A size no family lists reads as Custom, whatever was picked. + */ +function Resolution({ v, set }: { v: Settings; set: SetFn }): JSX.Element { + // Sticky once picked, so a typed size that equals a listed one stays on Custom. + const [customPicked, setCustomPicked] = useState(false); + const family = Math.max(0, aspectOf(v.width, v.height)); + const sizes = ASPECTS[family]?.sizes ?? []; + const listed = v.width === 0 || sizes.some(([w, h]) => w === v.width && h === v.height); + const custom = v.width !== 0 && (customPicked || !listed); + const typed = (w: number, h: number) => { + const [cw, ch] = customSize(w, h, v.codec); + set({ width: cw, height: ch }); + }; + return ( + <> + + [String(i), a.label] as const)} + onChange={(i) => { + setCustomPicked(false); + const [w, h] = nearest(Number(i), v.height); + set({ width: w, height: h }); + }} + /> + + + + + {custom && ( + +
+ {/* Keyed on the stored side, so a size the rule clamped shows what was kept. */} + typed(w, v.height)} /> + + typed(v.width, h)} /> +
+
+ )} + + ); +} + +/** One side of a typed size, committed on blur or Enter so a half-typed number is never clamped. + * It then shows the stored side: a new one remounts it by key, one the rule clamped back here. */ +function SizeField({ label, value, onCommit }: { label: string; value: number; onCommit: (n: number) => void }): JSX.Element { + const [text, setText] = useState(String(value)); + const commit = () => { + const n = Number.parseInt(text, 10); + if (n > 0 && n !== value) onCommit(n); + setText(String(value)); + }; + return ( + setText(e.target.value.replace(/\D/g, ""))} + onBlur={commit} + onKeyDown={(e) => { if (e.key === "Enter") commit(); }} + /> + ); +} + +/** A tab's advanced rows: their own card under Show advanced, otherwise one button naming how + * many hold a changed value, which shows them. Nothing when none changed. */ +function Advanced({ + show, + changed, + onShow, + children, +}: { + show: boolean; + changed: number; + onShow: () => void; + children: ReactNode; +}): JSX.Element | null { + if (show) return {children}; + if (changed === 0) return null; + return ( + + + + + + ); +} + /** A card of related rows under a heading, a rule between rows. */ -function Group({ title, children }: { title: string; children: ReactNode }): JSX.Element { +function Group({ title, children }: { title?: string; children: ReactNode }): JSX.Element { return (
-

{title}

+ {title &&

{title}

} {children}
); diff --git a/apps/web/src/ui/types.ts b/apps/web/src/ui/types.ts index 0d50c8d..03e6411 100644 --- a/apps/web/src/ui/types.ts +++ b/apps/web/src/ui/types.ts @@ -8,7 +8,7 @@ // The split is what makes a second interface cheap rather than a fork: nothing about pairing, // trust or the session lives in a renderer. -import type { AudioSnapshot, HudLine, KnownHost, LibraryEntry, Reach, Settings } from "@punktfunk/stream"; +import type { AudioSnapshot, HudCorner, HudLine, KnownHost, LibraryEntry, Reach, Settings } from "@punktfunk/stream"; /** Everything worth showing about a live session. */ export interface SessionStats { @@ -119,12 +119,17 @@ type ScreenBody = busy?: boolean; } /** `menu`: the quick menu is open over the picture. `endGame`: the title this device launched - * that the stream plays, which End game ends. `notice`: what the host said when it refused. */ + * that the stream plays, which End game ends. `notice`: what the host said when it refused. + * `corner` and `scale` place and size the statistics; `exitHint` says how to leave, for the + * first seconds of a stream. */ | { kind: "streaming"; stats: SessionStats; diagnostics: boolean; menu: boolean; + corner: HudCorner; + scale: number; + exitHint?: string; endGame?: string; notice?: string; } diff --git a/packages/stream/Cargo.lock b/packages/stream/Cargo.lock index a2b3e68..a72a229 100644 --- a/packages/stream/Cargo.lock +++ b/packages/stream/Cargo.lock @@ -52,56 +52,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - [[package]] name = "anyhow" version = "1.0.104" @@ -265,25 +215,6 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "cbindgen" -version = "0.29.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" -dependencies = [ - "clap", - "heck", - "indexmap", - "log", - "proc-macro2", - "quote", - "serde", - "serde_json", - "syn 2.0.119", - "tempfile", - "toml 0.9.12+spec-1.1.0", -] - [[package]] name = "cc" version = "1.5.1" @@ -363,33 +294,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "clap" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" -dependencies = [ - "clap_builder", -] - -[[package]] -name = "clap_builder" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_lex" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" - [[package]] name = "cmake" version = "0.1.58" @@ -405,12 +309,6 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - [[package]] name = "combine" version = "4.6.8" @@ -479,7 +377,7 @@ dependencies = [ [[package]] name = "cros-codecs" version = "0.0.5" -source = "git+https://git.unom.io/unom/punktfunk?rev=c6691fc1f#c6691fc1ff6df303bef6536da71a0f389ec7538f" +source = "git+https://git.unom.io/unom/punktfunk?rev=1184f7371#1184f73718adb3dc984925eef24fb925a9e6d4cc" dependencies = [ "log", ] @@ -649,16 +547,10 @@ dependencies = [ "siphasher", ] -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - [[package]] name = "fec-rs" version = "0.1.0" -source = "git+https://git.unom.io/unom/punktfunk?rev=c6691fc1f#c6691fc1ff6df303bef6536da71a0f389ec7538f" +source = "git+https://git.unom.io/unom/punktfunk?rev=1184f7371#1184f73718adb3dc984925eef24fb925a9e6d4cc" [[package]] name = "fiat-crypto" @@ -876,12 +768,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - [[package]] name = "itertools" version = "0.13.0" @@ -1105,12 +991,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "openssl-probe" version = "0.2.1" @@ -1139,7 +1019,7 @@ dependencies = [ [[package]] name = "pf-bitstream" version = "0.40.0" -source = "git+https://git.unom.io/unom/punktfunk?rev=c6691fc1f#c6691fc1ff6df303bef6536da71a0f389ec7538f" +source = "git+https://git.unom.io/unom/punktfunk?rev=1184f7371#1184f73718adb3dc984925eef24fb925a9e6d4cc" dependencies = [ "cros-codecs", "tracing", @@ -1148,9 +1028,10 @@ dependencies = [ [[package]] name = "pf-client-core" version = "0.40.0" -source = "git+https://git.unom.io/unom/punktfunk?rev=c6691fc1f#c6691fc1ff6df303bef6536da71a0f389ec7538f" +source = "git+https://git.unom.io/unom/punktfunk?rev=1184f7371#1184f73718adb3dc984925eef24fb925a9e6d4cc" dependencies = [ "anyhow", + "cfg_aliases", "punktfunk-core", "rand 0.9.5", "serde", @@ -1162,7 +1043,7 @@ dependencies = [ [[package]] name = "pf-console-ui" version = "0.40.0" -source = "git+https://git.unom.io/unom/punktfunk?rev=c6691fc1f#c6691fc1ff6df303bef6536da71a0f389ec7538f" +source = "git+https://git.unom.io/unom/punktfunk?rev=1184f7371#1184f73718adb3dc984925eef24fb925a9e6d4cc" dependencies = [ "anyhow", "pf-client-core", @@ -1264,16 +1145,14 @@ dependencies = [ [[package]] name = "punktfunk-core" version = "0.40.0" -source = "git+https://git.unom.io/unom/punktfunk?rev=c6691fc1f#c6691fc1ff6df303bef6536da71a0f389ec7538f" +source = "git+https://git.unom.io/unom/punktfunk?rev=1184f7371#1184f73718adb3dc984925eef24fb925a9e6d4cc" dependencies = [ "aes-gcm", - "cbindgen", "chacha20poly1305", "fec-rs", "hmac 0.13.0", "if-addrs", "libc", - "log", "opus", "pf-bitstream", "quinn", @@ -1288,7 +1167,7 @@ dependencies = [ "thiserror", "tokio", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "zerocopy", "zeroize", ] @@ -1805,7 +1684,7 @@ dependencies = [ "regex", "serde_json", "tar", - "toml 1.1.6+spec-1.1.0", + "toml", ] [[package]] @@ -1855,12 +1734,6 @@ dependencies = [ "sha2 0.10.9", ] -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "subtle" version = "2.6.1" @@ -1922,19 +1795,6 @@ dependencies = [ "xattr", ] -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "thiserror" version = "2.0.21" @@ -2017,21 +1877,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - [[package]] name = "toml" version = "1.1.6+spec-1.1.0" @@ -2041,19 +1886,10 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", "toml_writer", - "winnow 1.0.4", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", + "winnow", ] [[package]] @@ -2071,7 +1907,7 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.4", + "winnow", ] [[package]] @@ -2146,12 +1982,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "version_check" version = "0.9.5" @@ -2271,15 +2101,6 @@ dependencies = [ "windows-targets", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -2353,12 +2174,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - [[package]] name = "winnow" version = "1.0.4" diff --git a/packages/stream/Cargo.toml b/packages/stream/Cargo.toml index 335ce39..a8a6389 100644 --- a/packages/stream/Cargo.toml +++ b/packages/stream/Cargo.toml @@ -24,7 +24,7 @@ anyhow = "1" sha2 = "0.11" # `transport::Transport`, the wire codecs and the SPAKE2 ceremony. `pake`, not `quic`: the # browser plane is WebTransport, so the ceremony is wanted and the quinn runtime under it is not. -punktfunk-core = { git = "https://git.unom.io/unom/punktfunk", rev = "c6691fc1f", default-features = false, features = ["pake"] } +punktfunk-core = { git = "https://git.unom.io/unom/punktfunk", rev = "1184f7371", default-features = false, features = ["pake"] } # wasm only. Off it this is a bin that prints how to build it, so `cargo test` on a developer's # desktop stays green and does not resolve skia-safe for a target that wants a second Skia @@ -33,12 +33,12 @@ punktfunk-core = { git = "https://git.unom.io/unom/punktfunk", rev = "c6691fc1f" # The shell itself. `default-features = false`: `vulkan-overlay` is the desktop session binary's, # and it would put `vulkan` on skia-safe and change the prebuilt key away from # `gl-jpegd-jpege-pdf-textlayout` — the one rust-skia publishes for this target. -pf-console-ui = { git = "https://git.unom.io/unom/punktfunk", rev = "c6691fc1f", default-features = false } +pf-console-ui = { git = "https://git.unom.io/unom/punktfunk", rev = "1184f7371", default-features = false } # The bridge's JSON both ways: the model the page pushes, what the shell raises. serde = "1" serde_json = "1" # The portable half — `trust::Settings` for the snapshot store the shell loads from. -pf-client-core = { git = "https://git.unom.io/unom/punktfunk", rev = "c6691fc1f", default-features = false } +pf-client-core = { git = "https://git.unom.io/unom/punktfunk", rev = "1184f7371", default-features = false } # The SAME line pf-console-ui's wasm block carries, features included: two feature sets in one # build resolve two prebuilt keys, and the second one puts a source build of Skia on the critical # path. Only `host.rs` names it, to build the context and wrap the canvas. diff --git a/packages/stream/rust/recovery.rs b/packages/stream/rust/recovery.rs index 1ab0914..8eb530a 100644 --- a/packages/stream/rust/recovery.rs +++ b/packages/stream/rust/recovery.rs @@ -2,7 +2,6 @@ //! itself is `punktfunk_core::reanchor`; this is the frame-index bookkeeping around it. use punktfunk_core::packet::RFI_MAX_RANGE; -use punktfunk_core::reanchor::index_gap; /// What one access unit's frame index says about loss. #[derive(Debug, PartialEq, Eq)] @@ -26,14 +25,16 @@ pub fn on_index(next: &mut Option, pending: &mut Option<(u32, u32)>, index: *next = Some(exp.wrapping_add(1)); return Step::Deliver; } - match index_gap(exp, index) { - Some(gap) => { - let first = pending.map_or(exp, |(first, _)| first); - *pending = Some((first, index.wrapping_sub(1))); - *next = Some(index.wrapping_add(1)); - Step::Gap(gap) - } - None => Step::Straggler, + // Half-space wrap, as core's own RFI tracker reads it: a jump under half the index space is + // a gap, the top half a straggler. + let ahead = index.wrapping_sub(exp); + if ahead < u32::MAX / 2 { + let first = pending.map_or(exp, |(first, _)| first); + *pending = Some((first, index.wrapping_sub(1))); + *next = Some(index.wrapping_add(1)); + Step::Gap(ahead) + } else { + Step::Straggler } } diff --git a/packages/stream/src/index.ts b/packages/stream/src/index.ts index 52a3447..b9722db 100644 --- a/packages/stream/src/index.ts +++ b/packages/stream/src/index.ts @@ -15,7 +15,8 @@ export { type StreamOptions, type TunableOptions, } from "./engine.ts"; -export { DEFAULTS, settings, type Settings, type StatsTier } from "./settings.ts"; +export { DEFAULTS, HUD_CORNERS, type HudCorner, settings, STATS_SCALES, type Settings, type StatsTier } from "./settings.ts"; +export { ASPECTS, aspectOf, customSize, nearest } from "./resolutions.ts"; export type { AudioSnapshot, AudioState } from "./audio.ts"; export { Host, type HostAction, type HostInfo, type HostStatus, type LibraryEntry, VersionSkew } from "./host.ts"; export { type GameEnd, gameEndNotice, gameEndOf, gameGone } from "./game-end.ts"; diff --git a/packages/stream/src/resolutions.test.ts b/packages/stream/src/resolutions.test.ts new file mode 100644 index 0000000..27fd031 --- /dev/null +++ b/packages/stream/src/resolutions.test.ts @@ -0,0 +1,35 @@ +// The shared size rule. Cases are punktfunk's `clients/shared/custom-resolution-vectors.json`, +// which the Rust, Swift and Kotlin twins run too. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ASPECTS, aspectOf, customSize, nearest } from "./resolutions.ts"; + +const VECTORS: Array<[name: string, typed: [number, number], codec: string, want: [number, number]]> = [ + ["a listed size passes", [1920, 1080], "hevc", [1920, 1080]], + ["odd sides floor even", [1921, 1081], "hevc", [1920, 1080]], + ["a handheld", [1280, 800], "auto", [1280, 800]], + ["portrait", [1080, 2400], "av1", [1080, 2400]], + ["an unlisted shape", [1500, 1000], "hevc", [1500, 1000]], + ["below the floor", [100, 100], "hevc", [320, 200]], + ["zero", [0, 0], "auto", [320, 200]], + ["odd at the floor", [321, 201], "hevc", [320, 200]], + ["H.264 ceiling", [5120, 2880], "h264", [4096, 2880]], + ["H.264 ceiling, both sides", [8191, 4097], "h264", [4096, 4096]], + ["HEVC ceiling", [9999, 9999], "hevc", [8192, 8192]], + ["Automatic takes the wider ceiling", [7680, 4320], "auto", [7680, 4320]], + ["PyroWave ceiling", [10000, 1440], "pyrowave", [8192, 1440]], +]; + +test("a typed size goes through the shared rule", () => { + for (const [name, [w, h], codec, want] of VECTORS) assert.deepEqual(customSize(w, h, codec), want, name); +}); + +test("a size belongs to its family by shape", () => { + assert.equal(ASPECTS[aspectOf(1920, 1080)]?.label, "16:9"); + assert.equal(ASPECTS[aspectOf(3440, 1440)]?.label, "21:9"); + assert.equal(ASPECTS[aspectOf(1500, 1000)]?.label, "3:2", "a custom size too"); + assert.equal(aspectOf(0, 0), -1, "Native has no shape"); + assert.deepEqual(nearest(1, 1080), [1920, 1200]); + assert.deepEqual(nearest(0, 0), [1920, 1080], "Native looks for 1080"); +}); diff --git a/packages/stream/src/resolutions.ts b/packages/stream/src/resolutions.ts new file mode 100644 index 0000000..d3f1578 --- /dev/null +++ b/packages/stream/src/resolutions.ts @@ -0,0 +1,48 @@ +// Stream sizes grouped by shape, and the rule a typed size goes through. The twin of +// `punktfunk_core::resolutions`: the same families, sizes and limits, so a size picked or typed +// here asks the host for what every other client would. + +/** One family of sizes with the same shape, ascending. Every side is even: the host refuses odd. */ +export interface Aspect { + label: string; + ratio: readonly [number, number]; + sizes: ReadonlyArray; +} + +/** The families in the order the switch shows them, most common first. */ +export const ASPECTS: ReadonlyArray = [ + { label: "16:9", ratio: [16, 9], sizes: [[1280, 720], [1920, 1080], [2560, 1440], [3840, 2160], [5120, 2880]] }, + { label: "16:10", ratio: [16, 10], sizes: [[1280, 800], [1920, 1200], [2560, 1600], [2880, 1800], [3840, 2400]] }, + { label: "21:9", ratio: [21, 9], sizes: [[2560, 1080], [3440, 1440], [3840, 1600], [5120, 2160]] }, + { label: "32:9", ratio: [32, 9], sizes: [[3840, 1080], [5120, 1440], [7680, 2160]] }, + { label: "3:2", ratio: [3, 2], sizes: [[2160, 1440], [2256, 1504], [2880, 1920], [3000, 2000]] }, + { label: "4:3", ratio: [4, 3], sizes: [[1024, 768], [1600, 1200], [2048, 1536]] }, +]; + +/** "21:9" panels are really 2.37–2.40; 4 % keeps them together and still parts 16:10 from 3:2. */ +const TOLERANCE = 0.04; + +/** The family `w`×`h` belongs to by shape, not by membership. `-1` for a zero side or no match. */ +export function aspectOf(w: number, h: number): number { + if (w <= 0 || h <= 0) return -1; + return ASPECTS.findIndex((a) => Math.abs(w / h / (a.ratio[0] / a.ratio[1]) - 1) < TOLERANCE); +} + +/** The size in family `aspect` nearest in height to `h`; `0` looks for 1080. Ties go smaller. */ +export function nearest(aspect: number, h: number): readonly [number, number] { + const want = h || 1080; + const sizes = ASPECTS[aspect]?.sizes ?? ASPECTS[0]!.sizes; + return sizes.reduce((best, s) => (Math.abs(s[1] - want) < Math.abs(best[1] - want) ? s : best)); +} + +/** Smallest stream mode the host accepts, per side. */ +export const MIN_WIDTH = 320; +export const MIN_HEIGHT = 200; + +/** A typed size as a mode the host takes: at least 320 × 200, at most the codec's ceiling per + * side (4096 for H.264, 8192 otherwise), then floored even. */ +export function customSize(w: number, h: number, codec: string): [number, number] { + const max = codec === "h264" ? 4096 : 8192; + const side = (n: number, min: number) => Math.floor(Math.min(max, Math.max(min, Math.floor(n) || 0)) / 2) * 2; + return [side(w, MIN_WIDTH), side(h, MIN_HEIGHT)]; +} diff --git a/packages/stream/src/settings.test.ts b/packages/stream/src/settings.test.ts index 5e5fab9..3c560f1 100644 --- a/packages/stream/src/settings.test.ts +++ b/packages/stream/src/settings.test.ts @@ -31,3 +31,14 @@ test("a rate stored before Automatic existed is kept, not second-guessed", () => store.set("pf.settings", JSON.stringify({ ...DEFAULTS, bitrateKbps: 20_000 })); assert.equal(settings.get().bitrateKbps, 20_000); }); + +test("the overlay's corner and size keep to the values every client stores", () => { + store.clear(); + assert.equal(settings.get().hudPlacement, "topTrailing"); + assert.equal(settings.set({ hudPlacement: "bottomLeading" }).hudPlacement, "bottomLeading"); + assert.equal(settings.set({ hudPlacement: "middle" as never }).hudPlacement, "topTrailing", "an unknown corner reads as the default"); + assert.equal(settings.set({ statsScalePct: 150 }).statsScalePct, 150); + assert.equal(settings.set({ statsScalePct: 140 }).statsScalePct, 100, "a size off the list reads as 100 %"); + assert.equal(settings.get().exitHint, true); + assert.equal(settings.get().showAdvanced, false); +}); diff --git a/packages/stream/src/settings.ts b/packages/stream/src/settings.ts index 14a4e4b..4b47ec3 100644 --- a/packages/stream/src/settings.ts +++ b/packages/stream/src/settings.ts @@ -16,6 +16,13 @@ export const STATS_TIERS = ["off", "compact", "normal", "detailed"] as const; export type StatsTier = (typeof STATS_TIERS)[number]; +/** The overlay's corner, as every client stores `hud_placement`. */ +export const HUD_CORNERS = ["topLeading", "topTrailing", "bottomLeading", "bottomTrailing"] as const; +export type HudCorner = (typeof HUD_CORNERS)[number]; + +/** The overlay's size in percent — `punktfunk_core::hud::STATS_SCALE_PCTS`. */ +export const STATS_SCALES = [75, 100, 125, 150, 175, 200] as const; + /** Everything a person can choose. `width`/`height` of 0 mean "whatever the window is". */ export interface Settings { width: number; @@ -51,6 +58,14 @@ export interface Settings { invertScroll: boolean; /** Go fullscreen when a stream starts from a click, and back when it ends. */ fullscreen: boolean; + /** Where the stats overlay sits. */ + hudPlacement: HudCorner; + /** The stats overlay's size in percent, on top of the page's own. */ + statsScalePct: number; + /** Say how to leave for a few seconds when a stream starts. */ + exitHint: boolean; + /** Settings list their advanced rows. Hiding a row keeps its value. */ + showAdvanced: boolean; } export const DEFAULTS: Settings = { @@ -70,6 +85,10 @@ export const DEFAULTS: Settings = { hdr: true, invertScroll: false, fullscreen: true, + hudPlacement: "topTrailing", + statsScalePct: 100, + exitHint: true, + showAdvanced: false, }; const KEY = "pf.settings"; @@ -127,5 +146,9 @@ function sane(s: Settings): Settings { hdr: s.hdr !== false, invertScroll: s.invertScroll === true, fullscreen: s.fullscreen !== false, + hudPlacement: HUD_CORNERS.includes(s.hudPlacement) ? s.hudPlacement : DEFAULTS.hudPlacement, + statsScalePct: (STATS_SCALES as readonly number[]).includes(s.statsScalePct) ? s.statsScalePct : DEFAULTS.statsScalePct, + exitHint: s.exitHint !== false, + showAdvanced: s.showAdvanced === true, }; }