Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 32 additions & 23 deletions components/match/DemoPlaybackControls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
} from "~/components/ui/tooltip";
import { useDemoPlayback } from "~/composables/useDemoPlayback";
import { useClipEditor } from "~/composables/useClipEditor";
import { useBroadcastHuds } from "~/composables/useBroadcastHuds";
import RoundSelector from "~/components/match/RoundSelector.vue";
import SpectatorSlots from "~/components/stream-deck/SpectatorSlots.vue";
import { resolveKeyToRealSlot } from "~/utilities/streamerSpecSlots";
Expand Down Expand Up @@ -87,22 +88,28 @@ const {
reloadDemo,
toggleXray,
toggleHud,
setHudMode,
setHud,
toggleHudSides,
toggleDemoUI,
toggleAutodirector,
setScoreboard,
} = useDemoPlayback();

// JTs Hud's default bundle declares variants ["default","horizontal",
// "vertical"] in hud.json — but `default` and `horizontal` render the
// same layout, so we only expose the two distinct ones. Legacy
// `default` payloads are folded into `horizontal` at the boundary.
const HUD_MODES: Array<"horizontal" | "vertical"> = ["horizontal", "vertical"];
const HUD_MODE_LABELS: Record<(typeof HUD_MODES)[number], string> = {
horizontal: "Horizontal",
vertical: "Vertical",
};
// The HUD library, imports included. This used to be a hardcoded pair, because
// horizontal/vertical were the only two things the pod could load — they were
// never separate HUDs, only layouts of the one bundled HUD, and they are now
// the two seeded builtin rows alongside whatever an administrator has imported.
const { huds: broadcastHuds, fetch: fetchBroadcastHuds } = useBroadcastHuds();
onMounted(() => {
void fetchBroadcastHuds();
});

// The picker sits in a toolbar, so a long library has to stay usable: the
// builtins and the active HUD are always shown, and the rest ride behind the
// same row. Label falls back to the slug so a row with a blank name is still
// selectable rather than invisible.
const hudLabel = (hud: { name?: string | null; slug: string }) =>
hud.name?.trim() || hud.slug;

// Slot identity is GSI — survives a demo attached to the wrong match_map.
const ctSlots = computed(() =>
Expand Down Expand Up @@ -1188,31 +1195,33 @@ const killMarkers = computed<Marker[]>(() => {
}}</TooltipContent>
</Tooltip>

<!-- HUD bundle picker. Hot-swaps the active JTs Hud Manager
BrowserWindow in the streamer pod via /spec/hud-mode →
POST /api/overlay/start. Ephemeral; reset by a pod
restart to whatever HUD_MODE the api stamped. The
trailing Eye toggle lives inside the picker (where the
legacy "Default" segment used to sit) so visibility is
framed as a third HUD state alongside the two layouts. -->
<!-- HUD picker, listing the panel's HUD library. Hot-swaps the
active JTs Hud Manager BrowserWindow in the streamer pod via
/spec/hud-mode → POST /api/overlay/start; an imported HUD is
installed into the pod on first use. Ephemeral; reset by a pod
restart to whatever the api stamped. The trailing Eye toggle
lives inside the picker (where the legacy "Default" segment
used to sit) so visibility is framed as another HUD state
alongside the bundles. -->
<Tooltip>
<TooltipTrigger as-child>
<div
class="inline-flex rounded-md border border-border/60 bg-card/40 p-0.5"
>
<button
v-for="m in HUD_MODES"
:key="m"
v-for="hud in broadcastHuds"
:key="hud.slug"
type="button"
class="px-2 h-8 font-mono text-[0.6rem] uppercase tracking-[0.18em] rounded-sm cursor-pointer transition-colors"
class="px-2 h-8 font-mono text-[0.6rem] uppercase tracking-[0.18em] rounded-sm cursor-pointer transition-colors whitespace-nowrap"
:class="
store.hudVisible && store.hudMode === m
store.hudVisible && store.hudSlug === hud.slug
? 'bg-[hsl(var(--tac-amber)/0.18)] text-[hsl(var(--tac-amber))]'
: 'text-muted-foreground hover:text-foreground'
"
@click="setHudMode(m)"
:title="hud.description || hudLabel(hud)"
@click="setHud(hud.slug)"
>
{{ HUD_MODE_LABELS[m] }}
{{ hudLabel(hud) }}
</button>
<button
type="button"
Expand Down
88 changes: 88 additions & 0 deletions composables/useBroadcastHuds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import gql from "graphql-tag";
import { computed, ref } from "vue";
import { useApolloClient } from "@vue/apollo-composable";

export type BroadcastHud = {
id: string;
slug: string;
name: string;
author: string | null;
version: string | null;
description: string | null;
source: "builtin" | "imported";
variant: string | null;
thumbnail: string | null;
is_signed: boolean;
};

// Raw gql rather than the generated zeus client: broadcast_huds is new, and the
// generated types are produced by `yarn codegen` against a running Hasura. Using
// zeus here would mean nobody can build this branch until someone has migrated
// an instance and regenerated. The leaderboard page reaches for gql for its own
// reasons, so the pattern is not novel here.
const HUDS_QUERY = gql`
query BroadcastHuds {
broadcast_huds(order_by: [{ source: asc }, { name: asc }]) {
id
slug
name
author
version
description
source
variant
thumbnail
is_signed
}
}
`;

// Module scope, not composable scope: the demo player, the stream deck and the
// settings page all want the same list, and it changes only when an
// administrator imports something. Fetching it once per mount would put the
// same query behind every HUD picker on the page.
const huds = ref<Array<BroadcastHud>>([]);
const loading = ref(false);
let inFlight: Promise<void> | null = null;

export function useBroadcastHuds() {
const { client } = useApolloClient();

async function fetch(force = false): Promise<void> {
if (!force && huds.value.length > 0) {
return;
}
// Three pickers mounting at once must not be three round-trips.
if (inFlight && !force) {
return inFlight;
}

loading.value = true;
inFlight = (async () => {
try {
const { data } = await client.query({
query: HUDS_QUERY,
fetchPolicy: "network-only",
});
huds.value = (data?.broadcast_huds ?? []) as Array<BroadcastHud>;
} catch {
// A picker with nothing in it is recoverable; the caller falls back to
// whatever the pod already booted with.
huds.value = [];
} finally {
loading.value = false;
inFlight = null;
}
})();

return inFlight;
}

return {
huds: computed(() => huds.value),
loading: computed(() => loading.value),
fetch,
refresh: () => fetch(true),
bySlug: (slug: string) => huds.value.find((hud) => hud.slug === slug) ?? null,
};
}
30 changes: 15 additions & 15 deletions composables/useDemoPlayback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,10 +350,10 @@ export function useDemoPlayback() {
) {
store.reset();
store.localStatus = "starting";
// The pod boots the configured default_hud_mode (api resolveHudMode), so
// seed the player's active-layout state to match — otherwise reset()'s
// "horizontal" default mislabels a vertical pod until the operator swaps.
store.hudMode = useApplicationSettingsStore().defaultHudMode;
// The pod boots the instance's configured HUD (api resolveHudEnv), so seed
// the player's active-HUD state to match — otherwise reset()'s
// "default-horizontal" mislabels the pod until the operator swaps.
store.hudSlug = useApplicationSettingsStore().defaultBroadcastHud;
try {
if (opts?.attach) {
// DEV: bind to the standing gs-demo-dev pod. attachDemo takes no ids —
Expand Down Expand Up @@ -719,16 +719,16 @@ export function useDemoPlayback() {
function toggleHud() {
setHudVisible(!store.hudVisible);
}
// Hot-swap the active HUD bundle (horizontal | vertical). The api
// spec-server's /spec/hud-mode handler proxies to JTs Hud Manager's
// POST /api/overlay/start which rebuilds the BrowserWindow against
// /huds/<mode>/index.html. Ephemeral — a pod restart resets to
// whatever HUD_MODE the api stamped at job creation. Picking a mode
// also force-shows the overlay so the operator doesn't have to
// hunt for a separate visibility toggle after a hot-swap.
function setHudMode(mode: "horizontal" | "vertical") {
store.hudMode = mode;
control("hud-mode", { mode });
// Hot-swap the active HUD. `slug` names a broadcast_huds row; the api resolves
// it into the JTs Hud Manager hudId + variant (and, for an imported HUD, the
// bundle to install first) before proxying to /spec/hud-mode, which rebuilds
// the overlay BrowserWindow via POST /api/overlay/start. Ephemeral — a pod
// restart resets to whatever the api stamped at job creation. Picking a HUD
// also force-shows the overlay so the operator doesn't have to hunt for a
// separate visibility toggle after a hot-swap.
function setHud(slug: string) {
store.hudSlug = slug;
control("hud-mode", { slug });
if (!store.hudVisible) setHudVisible(true);
}
function toggleHudSides() {
Expand Down Expand Up @@ -788,7 +788,7 @@ export function useDemoPlayback() {
toggleXray,
setHudVisible,
toggleHud,
setHudMode,
setHud,
toggleHudSides,
toggleDemoUI,
setAutodirector,
Expand Down
5 changes: 5 additions & 0 deletions composables/useSettingsNav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
labelKey: "pages.settings.application.highlights.title",
order: 4,
},
{
path: "/settings/application/broadcast-huds",
labelKey: "pages.settings.application.broadcast_huds.title",
order: 5,
},
],
},
{
Expand Down
22 changes: 17 additions & 5 deletions i18n/locales/ar_SA.json
Original file line number Diff line number Diff line change
Expand Up @@ -2337,10 +2337,6 @@
"auto_clip_min_kills_ace": "Ace فقط (5K)",
"visibility_private": "خاص",
"visibility_public": "عام",
"default_hud_mode": "نمط HUD الافتراضي",
"default_hud_mode_description": "تخطيط JTs Hud Manager الذي تحمّله وحدة البث عند الإقلاع للبث المباشر وتشغيل Demo ومعالجة اللقطات. يظل بإمكان المشغّلين تبديل النمط لكل بث من عناصر التحكم.",
"hud_mode_horizontal": "أفقي",
"hud_mode_vertical": "رأسي",
"playback_section": "التشغيل",
"reparse_warning_title": "هذا يعيد تحليل كل ملفات Demo — وقد يستغرق وقتًا طويلًا جدًا",
"reparse_warning_body": "تُعالَج ملفات Demo واحدًا تلو الآخر، وقد يستمر ذلك ساعات أو أيامًا حسب عددها. لا تفعل هذا إلا إذا كنت تعرف ما تفعله، أو بعد تحديث كبير يغيّر طريقة تحليل الملفات. الإحصائيات الجديدة من التحديثات تُحلَّل تلقائيًا عند عرض المباريات — وهذا الخيار فقط لفرض كل شيء دفعة واحدة.",
Expand Down Expand Up @@ -2387,7 +2383,8 @@
"orphaned_scanning_short": "جارٍ الفحص…",
"orphaned_demos_count": "{count} demos",
"orphaned_highlights_count": "{count} لقطة",
"orphaned_object_count": "{count} ملف"
"orphaned_object_count": "{count} ملف",
"hud_moved": "The broadcast HUD is now a library of importable bundles, managed in"
},
"update_map_pools": {
"title": "تحديث قوائم الخرائط تلقائيًا",
Expand Down Expand Up @@ -2691,6 +2688,21 @@
"description": "فوّت فريق تأكيد حضور مطلوبًا في البطولة. يُفحص عند الدخول فقط، لذا يحتفظ اللاعب الموجود في شجرة جارية بمكانه."
}
}
},
"broadcast_huds": {
"title": "Broadcast HUDs",
"description": "The HUD burned into live streams, demo playback and batch highlights. Import a JTs Hud Manager or Lexogrine bundle as a .zip and pick which one the game-streamer pod loads; streamers can still switch per-stream from the player controls.",
"import": "Import HUD",
"import_hint": "A .zip with hud.json at its root or in a single top-level folder.",
"imported": "HUD imported",
"import_failed": "Could not import that HUD",
"make_default": "Make default",
"is_default": "Default",
"default_set": "Default HUD updated",
"default_failed": "Could not set the default HUD",
"removed": "HUD removed",
"remove_failed": "Could not remove that HUD",
"signed": "Signed bundle"
}
},
"language": {
Expand Down
22 changes: 17 additions & 5 deletions i18n/locales/da_DK.json
Original file line number Diff line number Diff line change
Expand Up @@ -2337,10 +2337,6 @@
"auto_clip_min_kills_ace": "Kun aces (5K)",
"visibility_private": "Privat",
"visibility_public": "Offentlig",
"default_hud_mode": "Standard HUD-stil",
"default_hud_mode_description": "Hvilket JTs Hud Manager-layout game-streamer-podden indlæser ved opstart til livestreams, demoafspilning og batch-highlights. Streamere kan stadig skifte stil pr. stream fra afspillerens kontroller.",
"hud_mode_horizontal": "Vandret",
"hud_mode_vertical": "Lodret",
"playback_section": "Afspilning",
"reparse_warning_title": "Dette genindlæser hver eneste demo — det kan tage meget lang tid.",
"reparse_warning_body": "Demoer behandles én ad gangen, hvilket kan køre i timer eller dage afhængigt af hvor mange du har. Gør kun dette, hvis du ved hvad du laver, eller efter en større opdatering ændrer måden demoer parses på. Nye stats fra opdateringer parses automatisk, efterhånden som kampe vises — dette er kun til at tvinge det hele igennem på én gang.",
Expand Down Expand Up @@ -2387,7 +2383,8 @@
"orphaned_scanning_short": "Scanner…",
"orphaned_demos_count": "{count} demoer",
"orphaned_highlights_count": "{count} highlights",
"orphaned_object_count": "{count} filer"
"orphaned_object_count": "{count} filer",
"hud_moved": "The broadcast HUD is now a library of importable bundles, managed in"
},
"update_map_pools": {
"title": "Opdatér map pools automatisk",
Expand Down Expand Up @@ -2691,6 +2688,21 @@
"description": "Et hold missede et påkrævet turnerings-check-in. Tjekkes kun ved indgangen, så en spiller, der allerede er i en igangværende bracket, beholder sin plads."
}
}
},
"broadcast_huds": {
"title": "Broadcast HUDs",
"description": "The HUD burned into live streams, demo playback and batch highlights. Import a JTs Hud Manager or Lexogrine bundle as a .zip and pick which one the game-streamer pod loads; streamers can still switch per-stream from the player controls.",
"import": "Import HUD",
"import_hint": "A .zip with hud.json at its root or in a single top-level folder.",
"imported": "HUD imported",
"import_failed": "Could not import that HUD",
"make_default": "Make default",
"is_default": "Default",
"default_set": "Default HUD updated",
"default_failed": "Could not set the default HUD",
"removed": "HUD removed",
"remove_failed": "Could not remove that HUD",
"signed": "Signed bundle"
}
},
"language": {
Expand Down
22 changes: 17 additions & 5 deletions i18n/locales/de_DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -2337,10 +2337,6 @@
"auto_clip_min_kills_ace": "Nur Aces (5K)",
"visibility_private": "Privat",
"visibility_public": "Öffentlich",
"default_hud_mode": "Standard-HUD-Stil",
"default_hud_mode_description": "Welches Layout des JTs Hud Managers der Game-Streamer-Pod beim Start für Live-Streams, Demo-Wiedergabe und Batch-Highlights lädt. Streamer können den Stil pro Stream weiterhin über die Player-Steuerung wechseln.",
"hud_mode_horizontal": "Horizontal",
"hud_mode_vertical": "Vertikal",
"playback_section": "Wiedergabe",
"reparse_warning_title": "Das parst jede Demo neu — es kann sehr lange dauern.",
"reparse_warning_body": "Demos werden nacheinander neu verarbeitet, was je nach Menge Stunden oder Tage laufen kann. Mach das nur, wenn du weißt, was du tust, oder nachdem ein größeres Update das Demo-Parsing geändert hat. Neue Stats aus Updates werden ohnehin automatisch neu geparst, sobald Matches angesehen werden — das hier erzwingt nur alles auf einmal.",
Expand Down Expand Up @@ -2387,7 +2383,8 @@
"orphaned_scanning_short": "Scan läuft…",
"orphaned_demos_count": "{count} Demos",
"orphaned_highlights_count": "{count} Highlights",
"orphaned_object_count": "{count} Dateien"
"orphaned_object_count": "{count} Dateien",
"hud_moved": "The broadcast HUD is now a library of importable bundles, managed in"
},
"update_map_pools": {
"title": "Map-Pools automatisch aktualisieren",
Expand Down Expand Up @@ -2691,6 +2688,21 @@
"description": "Ein Team hat einen erforderlichen Turnier-Check-in verpasst. Wird nur beim Eintritt geprüft, ein Spieler in einem laufenden Bracket behält also seinen Platz."
}
}
},
"broadcast_huds": {
"title": "Broadcast HUDs",
"description": "The HUD burned into live streams, demo playback and batch highlights. Import a JTs Hud Manager or Lexogrine bundle as a .zip and pick which one the game-streamer pod loads; streamers can still switch per-stream from the player controls.",
"import": "Import HUD",
"import_hint": "A .zip with hud.json at its root or in a single top-level folder.",
"imported": "HUD imported",
"import_failed": "Could not import that HUD",
"make_default": "Make default",
"is_default": "Default",
"default_set": "Default HUD updated",
"default_failed": "Could not set the default HUD",
"removed": "HUD removed",
"remove_failed": "Could not remove that HUD",
"signed": "Signed bundle"
}
},
"language": {
Expand Down
Loading