diff --git a/web/messages/de.json b/web/messages/de.json
index 3687fd22..014437ee 100644
--- a/web/messages/de.json
+++ b/web/messages/de.json
@@ -72,6 +72,10 @@
"stream_codec": "Codec",
"stream_resolution": "Auflösung",
"stream_fps": "Bildrate",
+ "stream_first_frame": "Erstes Bild",
+ "stream_last_resize": "Letzte Größenänderung",
+ "stream_packet_size": "Paketgröße",
+ "stream_min_fec": "FEC-Minimum",
"stream_bitrate": "Bitrate",
"action_stop_session": "Sitzung beenden",
"action_request_idr": "Keyframe anfordern",
@@ -254,6 +258,15 @@
"library_field_players": "Spieler",
"library_details_legend": "Details (optional)",
"library_owned_by": "über {provider}",
+ "library_providers_title": "Von Plugins synchronisiert",
+ "library_providers_help": "Diese Einträge gehören einem Plugin und lassen sich deshalb nicht einzeln bearbeiten oder löschen — das Plugin synchronisiert sie neu. Ist das Plugin weg, entferne seine Einträge hier.",
+ "library_provider_count": "{count} Einträge",
+ "library_provider_filter": "Nur diese zeigen",
+ "library_provider_show_all": "Alle zeigen",
+ "library_provider_purge": "Einträge dieses Anbieters entfernen",
+ "library_provider_purge_confirm": "Alle {count} von „{provider}“ synchronisierten Einträge entfernen? Das entfernt sie nur aus der Bibliothek.",
+ "library_provider_purged": "Die von „{provider}“ synchronisierten Einträge wurden entfernt.",
+ "library_provider_purge_failed": "Die Einträge dieses Anbieters konnten nicht entfernt werden.",
"library_save": "Speichern",
"library_create": "Hinzufügen",
"library_cancel": "Abbrechen",
diff --git a/web/messages/en.json b/web/messages/en.json
index 85d3f303..4e063544 100644
--- a/web/messages/en.json
+++ b/web/messages/en.json
@@ -72,6 +72,10 @@
"stream_codec": "Codec",
"stream_resolution": "Resolution",
"stream_fps": "Frame rate",
+ "stream_first_frame": "First frame",
+ "stream_last_resize": "Last resize",
+ "stream_packet_size": "Packet size",
+ "stream_min_fec": "FEC floor",
"stream_bitrate": "Bitrate",
"action_stop_session": "Stop session",
"action_request_idr": "Request keyframe",
@@ -254,6 +258,15 @@
"library_field_players": "Players",
"library_details_legend": "Details (optional)",
"library_owned_by": "via {provider}",
+ "library_providers_title": "Synced by plugins",
+ "library_providers_help": "These entries are owned by a plugin, so they can't be edited or removed one at a time — the plugin re-syncs them. If the plugin is gone, remove its entries here.",
+ "library_provider_count": "{count} entries",
+ "library_provider_filter": "Show only these",
+ "library_provider_show_all": "Show all",
+ "library_provider_purge": "Remove this provider's entries",
+ "library_provider_purge_confirm": "Remove all {count} entries synced by “{provider}”? This only removes them from the library.",
+ "library_provider_purged": "Removed the entries synced by “{provider}”.",
+ "library_provider_purge_failed": "Could not remove this provider's entries.",
"library_save": "Save",
"library_create": "Add",
"library_cancel": "Cancel",
diff --git a/web/src/sections/Dashboard/view.tsx b/web/src/sections/Dashboard/view.tsx
index 9e8eb89d..b0ce0996 100644
--- a/web/src/sections/Dashboard/view.tsx
+++ b/web/src/sections/Dashboard/view.tsx
@@ -8,6 +8,7 @@ import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { fmtNumber } from "@/lib/format";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { RunningGames } from "./RunningGames";
@@ -73,8 +74,13 @@ export const DashboardView: FC<{
{m.status_pin_pending()}
+ {/* The whole value used to be "●" or "—": no text, no state, colour
+ doing all the work — nothing for a screen reader to read out and
+ nothing for anyone who can't tell the two badges apart. */}
- {s.pin_pending ? "●" : "—"}
+ {s.pin_pending
+ ? m.status_pin_waiting()
+ : m.status_pin_none()}
@@ -138,7 +144,34 @@ export const DashboardView: FC<{
/>
+ {/* Bring-up and reconfigure cost, the parity floor and the packet
+ size: the host has reported all four for as long as this
+ endpoint has existed and the console showed none of them, so
+ "it takes ages to start" and "it hitches when I resize" had no
+ number attached anywhere. Native-plane only — null on
+ GameStream and null until the first frame lands, so the two
+ timings appear only once they mean something. */}
+ {s.stream.time_to_first_frame_ms != null && (
+
+ )}
+ {s.stream.last_resize_ms != null && (
+
+ )}
+
+
) : (
diff --git a/web/src/sections/Library/LibraryGrid.tsx b/web/src/sections/Library/LibraryGrid.tsx
index 0cbf16e3..f51fd6d8 100644
--- a/web/src/sections/Library/LibraryGrid.tsx
+++ b/web/src/sections/Library/LibraryGrid.tsx
@@ -1,7 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { motion, stagger } from "motion/react";
-import type { FC } from "react";
+import { type FC, useEffect, useMemo } from "react";
import {
getGetLibraryQueryKey,
useDeleteCustomGame,
@@ -21,11 +21,32 @@ import { customId } from "./helpers";
* Editing is escalated to the parent (it opens the separate add/edit form), so
* this subsection knows nothing about the form beyond firing `onEdit`.
*/
-export const LibraryGridSection: FC<{ onEdit: (entry: GameEntry) => void }> = ({
- onEdit,
-}) => {
+export const LibraryGridSection: FC<{
+ onEdit: (entry: GameEntry) => void;
+ /** Show only entries owned by this provider, or everything when null. */
+ providerFilter?: string | null;
+ /** Reports the full (unfiltered) list up, so the providers card can count owners. */
+ onEntries?: (entries: GameEntry[]) => void;
+}> = ({ onEdit, providerFilter, onEntries }) => {
const qc = useQueryClient();
const library = useGetLibrary();
+ const all = library.data;
+ useEffect(() => {
+ if (all) onEntries?.(all);
+ }, [all, onEntries]);
+ // Filtering CLIENT-side: `GET /library?provider=` exists, but the page already holds the whole
+ // list for the grid, and a second parameterised query would just be a second cache entry of the
+ // same data going stale independently.
+ const filtered = useMemo(
+ () =>
+ providerFilter
+ ? {
+ ...library,
+ data: all?.filter((e) => e.provider === providerFilter),
+ }
+ : library,
+ [library, all, providerFilter],
+ );
const remove = useDeleteCustomGame();
// A refused delete has to say so. The host has real reasons to say no (a provider-owned entry
@@ -44,7 +65,7 @@ export const LibraryGridSection: FC<{ onEdit: (entry: GameEntry) => void }> = ({
return (
void;
+}> = ({ entries, active, onFilter }) => {
+ const qc = useQueryClient();
+ const purge = useDeleteProviderEntries();
+
+ // Count per provider, in first-seen order — the list is small and operator-facing.
+ const counts = new Map();
+ for (const e of entries) {
+ if (e.provider) counts.set(e.provider, (counts.get(e.provider) ?? 0) + 1);
+ }
+ if (counts.size === 0) return null;
+
+ const onPurge = async (provider: string, count: number) => {
+ if (!confirm(m.library_provider_purge_confirm({ provider, count }))) return;
+ try {
+ await purge.mutateAsync({ provider });
+ // The host emits `library.changed`, but don't wait for the round trip to redraw.
+ qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
+ if (active === provider) onFilter(null);
+ toast.success(m.library_provider_purged({ provider }));
+ } catch (e) {
+ toast.error(apiErrorMessage(e) ?? m.library_provider_purge_failed());
+ }
+ };
+
+ return (
+
+
+ {m.library_providers_title()}
+
+
+
+
+
+ );
+};
diff --git a/web/src/sections/Library/index.tsx b/web/src/sections/Library/index.tsx
index e8c979f2..05ff4944 100644
--- a/web/src/sections/Library/index.tsx
+++ b/web/src/sections/Library/index.tsx
@@ -1,11 +1,13 @@
import Section from "@unom/ui/section";
import { Plus } from "lucide-react";
import { type FC, useState } from "react";
+import type { GameEntry } from "@/api/gen/model/gameEntry";
import { Button } from "@/components/ui/button";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { type FormTarget, GameFormSection } from "./GameForm";
import { LibraryGridSection } from "./LibraryGrid";
+import { ProvidersCard } from "./Providers";
import { SourceTogglesSection } from "./SourceToggles";
// Library = an OVERVIEW grid + a SEPARATE add/edit form, deliberately split into their own files
@@ -16,6 +18,10 @@ export const SectionLibrary: FC = () => {
// null = form hidden; "new" = adding; a GameEntry = editing that custom entry. Keying the form
// by the target re-seeds its fields when switching add → edit (or between entries).
const [target, setTarget] = useState(null);
+ // The full list, lifted from the grid so the providers card can count owners without a second
+ // copy of the same query, plus which provider (if any) the grid is filtered to.
+ const [entries, setEntries] = useState([]);
+ const [providerFilter, setProviderFilter] = useState(null);
return (
@@ -40,7 +46,17 @@ export const SectionLibrary: FC = () => {
- setTarget(entry)} />
+
+
+ setTarget(entry)}
+ providerFilter={providerFilter}
+ onEntries={setEntries}
+ />
);
diff --git a/web/src/sections/Stats/LiveCard.tsx b/web/src/sections/Stats/LiveCard.tsx
index 4828eee5..0d4ebfec 100644
--- a/web/src/sections/Stats/LiveCard.tsx
+++ b/web/src/sections/Stats/LiveCard.tsx
@@ -9,7 +9,7 @@ import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
-import { LatencyChart, ThroughputChart } from "./charts";
+import { HealthChart, LatencyChart, ThroughputChart } from "./charts";
import { ChartBlock } from "./helpers";
/**
@@ -76,6 +76,13 @@ export const LiveCard: FC<{ live: Loadable }> = ({ live }) => {
+ {/* Loss/recovery was only ever visible AFTER stopping and reopening the
+ saved recording — which is backwards: dropped frames and FEC recovery
+ are what you watch a live capture FOR. The `kind` note keeps the
+ GameStream caveat (only `frames` is instrumented there). */}
+
+
+
{(live.data?.samples?.length ?? 0) > LIVE_WINDOW && (