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()} + + +

+ {m.library_providers_help()} +

+
+ {[...counts.entries()].map(([provider, count]) => ( +
+ {provider} + + {m.library_provider_count({ count })} + +
+ + +
+
+ ))} +
+
+
+ ); +}; 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 && (

{m.stats_live_window({ count: LIVE_WINDOW })}