Skip to content

[UX] Playout — Streaming ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch: develop @ 998e706d · Files reviewed: 26 Patterns: forms/toggle

Summary ​

The on-air machinery is honest: stage is derived in streaming.store.ts from the Firestore live-stage document that Mux webhooks and the poller write, so "Live" is never asserted before the provider says so, and widgetEnabled is read back from the same document it writes to. The publish path is likewise careful — it says "Publishing started", not "Published". The problems are on the surrounding controls: Reset silently destroys every recorded asset and every chapter behind a bare "Are you sure?", the auto-save that persists who is allowed to watch can fail without telling anyone, and the RTMPS stream key is printed in permanent plain text on a page operators routinely screen-share — in an app that already ships a masking SecretField for exactly this class of value.

Project-level defects that this feature also exhibits, not re-filed here: NAV-03 (no per-route document title), A11Y-03 (ContextMenu trigger, used at Index.vue:60), and CONTENT-01 at the values level (fr.yml:438-482 carries the whole streaming: block with # TODO: translate). See PROJECT-LEVEL.md.

Findings ​

1. "Reset" permanently deletes the recording behind a generic "Are you sure?" — High · MSG-05 ​

Where: src/views/Events/Streaming/Index.vue:60-67 and :754-764; backend functions/src/handlers/https/streaming/reset.ts:38-68What: The admin ContextMenu exposes a Reset item that opens components/common/Confirm.vue with no title and no subtitle, so the dialog reads "Are you sure?" with a "Confirm" button and nothing else. The endpoint it calls completes the Mux live stream, calls deleteMuxAsset on every recorded asset (recording segments, legacy asset, VOD source assets), batch-deletes all chapter documents, deletes both the recording and vod stage documents, and sets the event back to active. None of that is recoverable. The item is rendered at every stage, including live — pressing it mid-broadcast ends the broadcast. Why it matters: A misclick in the overflow menu of a live production destroys the recording of a service that cannot be re-run, and the confirmation gives the operator no information with which to catch the mistake. Fix: Pass an explicit title / subtitle naming the consequences ("Delete the recording, all chapters and the replay for this event? This cannot be undone."), use the existing DangerZone/typed-confirmation treatment for it, and hide or separately gate the item while stage === 'live'.

2. Access-rule and auto-archive edits can fail to save without the operator noticing — High · MSG-03, MSG-01 ​

Where: src/views/Events/Streaming/Index.vue:767-800 (persistDraft), status text at :11-18What: The settings modal (AccessRulesPanel, AutoArchivePanel) has no save button — edits are debounced into streaming.saveConfiguration, which runs validate(StreamingConfigurationSchema, …) and throws on an invalid draft. The handler sets saveStatus = "error" and, for ValidationError specifically, deliberately suppresses even the console.error. The only user-visible result is the 12px line "Couldn't save changes" in the page header — behind the modal the user is looking at, with no role="alert"/aria-live, no indication of which condition is invalid, no retry control, and no re-attempt until the user happens to edit something else. A half-filled condition row (value: null, which addCondition at AccessRulesPanel.vue:233 creates by default) is exactly the shape that fails. Why it matters: These rules decide who may watch the stream. An operator who adds "Church is X" to restrict a service, sees the row on screen, and closes the modal has good reason to believe the restriction is live when it was never persisted — the stream stays public. Fix: Surface save failures as a real error message inside the modal, next to the offending condition, in a live region; keep retrying or block "Done" while saveStatus === 'error'; say what is wrong ("Every condition needs a value").

3. The RTMPS stream key is printed in permanent plain text — High · proposed SEC-SECRET-DISPLAY ​

Where: src/views/Events/Streaming/Index.vue:145-163What: The ingest key is rendered as {{ ingestStreamKey }} in a break-all<div>, always visible, with an unlabelled copy icon beside it. The transport is fine — the value lives at production-units/{id}/private/credentials, which firestore.rules:237-239 closes to all clients (allow read, write: if false), it is served only through the adminCheck-guarded GET streaming/:tenant/production-units/:unit/credentials, and I found no code path that logs it. The exposure is purely on screen. The repo already has the right component for this: src/components/common/SecretField.vue masks with -webkit-text-security, offers a reveal toggle and a copy button that confirms — and it is used for the Mux token secret in Admin/TenantDashboard.vue:311-340. Why it matters: A stream key is a broadcast credential — anyone who reads it can push video into the tenant's live output. This page is the one an operator has open while screen-sharing to a colleague, projecting to a production monitor, or screenshotting a problem. Severity call: High, not Blocker: exploiting it needs a second condition (someone else seeing the screen). Nothing about it is reachable by an outsider directly. Fix: Render the key with SecretField (masked, reveal, copy-with-confirmation), as the tenant dashboard already does.

4. If the stream key cannot be fetched, the "get ready" checklist points at nothing — High · MSG-03, proposed MSG-06 ​

Where: src/views/Events/Streaming/Index.vue:575-584 and :99-119What: getProductionUnitCredentials is wrapped in catch { ingestStreamKey.value = null } — the error is swallowed entirely. The whole Ingest block is v-if="ingestStreamKey", but step 1 of the numbered checklist above it still reads "Copy the RTMPS URL and stream key below into your streaming software". The same blank result occurs when the event has no production unit or the unit has hasStream === false, again with no message. Why it matters: Without the key the operator cannot go live at all, and the page tells them to copy a key that is not on screen and gives no reason, no error and no next step. This is the only failure in the draft I would consider close to Blocker; it stays at High because it requires the credential fetch to fail or the unit to be unconfigured rather than failing for everyone. Fix: Distinguish the three states — fetch failed (show an error with a retry), no production unit assigned (link to the setting that assigns one), unit has no stream (explain) — and do not render the "copy the key below" step when there is no key.

5. Archive and Re-publish change public availability with no confirmation — Medium · MSG-05 ​

Where: src/views/Events/Streaming/Index.vue:351-359 / :742-752 (Archive) and :382-388 (Re-publish) What: Archive fires api.archiveStreamingVod straight from the click and removes a published replay from public view; Re-publish makes an archived replay public again from a single click. Both are externally visible state changes on content viewers may be watching. Stop & save on the same page does confirm (:463-491), so the inconsistency is internal to the feature. Why it matters: A stray click pulls a published service offline for viewers, or republishes a replay that was deliberately restricted, with no chance to reconsider. Fix: Route both through Confirm with copy that names the visible effect ("Viewers will no longer be able to watch this replay").

6. "Show on site" / "Hide from site" replace each other in place and are not exposed as a toggle — Medium · proposed LIVE-03, A11Y-03 ​

Where: src/views/Events/Streaming/Index.vue:210-232What: Two mutually exclusive VButtons occupy the same slot via v-if / v-else; after "Show on site" succeeds, "Hide from site" appears under the pointer in the same position, so a double-tap reverses the action just taken — the same shape recorded as LIVE-03 in PROJECT-LEVEL.md for the lower-third. There is also no toggle semantics: no role="switch"/aria-pressed, and the state text at :201-207 ("Player live on site" / "Player hidden") is not in a live region, so the change is silent to a screen reader. The forms/toggle pattern's "Confirmed toggle" variation covers exactly this case ("sensitive state changes"), and its reference markup pairs the control with aria-live="polite" state text via aria-describedby. Why it matters: This control decides whether the public can see the broadcast. A double-tap takes the player off the site mid-service; a screen-reader user gets no confirmation either way. Fix: One persistent control with aria-pressed, or keep both buttons rendered with the inactive one disabled; wrap the "Player live on site / Player hidden" status in aria-live="polite".

7. Switches and the age input have no accessible name — Medium · FORM-01, A11Y-05 ​

Where: src/components/streaming/AutoArchivePanel.vue:5-9 and :12-17; RecordingsPanel.vue:58-62; VodEditorPanel.vue:51-56; AccessRulesPanel.vue:70-77What: packages/ui/src/components/VSwitch.vue renders a Headless UI Switch whose only child is a decorative <span> — it has no text and no aria-label, so it is named entirely by what the consumer passes. AutoArchivePanel labels its switch with a <div class="kicker">Enable auto-archive</div> (not a <label>, no for), its "Delay (days)" <label> has no for and wraps nothing, RecordingsPanel's per-segment switch has no label or title at all, and VodEditorPanel's "Keep" <label> is likewise unassociated. The age condition input at AccessRulesPanel.vue:70-77 has only placeholder="Age" — the case FORM-01 names explicitly. (ChaptersPanel.vue:37-43 does pass :title, so it degrades better.) Why it matters: A screen-reader user hears "switch, off" with no idea whether it controls auto-archive, keeping a chapter, or including a recording segment — and these switches decide what ends up in the published replay. Fix: Give VSwitch a required label/aria-label prop, and associate the existing text with for/id.

8. Copy buttons are icon-only, unnamed, and give no feedback — Medium · A11Y-05 ​

Where: src/views/Events/Streaming/Index.vue:135-143, :154-162, :585-588What: Both copy controls are <VButton> wrapping a bare <MdiContentCopy /> — no aria-label, no title, no visible text — and copyToClipboard awaits copy() and then does nothing: no toast, no "Copied" state, no change to the icon. When isClipboardSupported is false the buttons are simply disabled with no explanation. SecretField.vue:32-42 gets all of this right (title, copied state, check icon). Why it matters: The operator cannot tell whether the stream key made it to the clipboard before pasting into OBS, and a screen-reader user hears only "button". Fix: Reuse SecretField, or add accessible names and the copied state from useClipboard({ copiedDuring }).

9. Raw provider status strings are shown to users — Medium · MSG-02 ​

Where: src/views/Events/Streaming/Index.vue:113-117 / :594; src/components/streaming/StreamingFlow.vue:91-93; src/components/streaming/RecordingsPanel.vue:33-36What: liveStatusLabel is muxLivestream?.status ?? "idle" rendered straight into "Live status: {{ liveStatusLabel }}", so the operator reads Mux's vocabulary — idle, active, disconnected. StreamingFlow interpolates the same class of raw value into a tooltip (status: ${vodStatus.value}), and RecordingsPanel prints rec.status next to a segment. Why it matters: "disconnected" and "errored" are the moments an operator most needs a sentence they can act on, and these are untranslatable as well as unmapped. Fix: Map the known Mux statuses to i18n keys with a fallback sentence, as the baseline requires for provider strings.

10. Nearly the whole feature is hardcoded English — Medium · CONTENT-01 ​

Where: src/views/Events/Streaming/Index.vue (~60 literals: the Streaming title :8, the entire idle checklist :89-111, every button label :219-243, every dialog body :469-508, all stageTips :618-641, both summaries :645-653); StreamingFlow.vue:76-109 (all five step labels and details); AccessRulesPanel.vue (option labels, "Viewers must match", the empty-state sentence); AutoArchivePanel.vue; RecordingsPanel.vue; VodEditorPanel.vue:96-109; Connections.vue:14-49 (all six column headers, while the page title above them is translated); MuxPlayer.vue:47-50 ("Forbidden access", "No playback ID yet."). What: These strings never reach en.yml, so pnpm check:locales cannot see them — this is a different failure from the project-level one (values present but marked # TODO: translate). The keys that do exist (streaming.chapters.*, streaming.vod.*) are used only by ChaptersPanel, VodEditorPanel and VodPublishProgress, producing a page that is half-translated mid-sentence. Why it matters: A French or Norwegian operator running a live service reads the go-live checklist, the stop/publish/archive buttons and every dialog in English. Fix: Extract to en.yml under the existing streaming: namespace; CI parity then keeps fr/no honest about the gap.

11. The video area can stick on a loading skeleton forever — Medium · proposed MSG-06, CONTENT-04 ​

Where: src/components/streaming/MuxPlayer.vue:52-72What: loadToken sets loading.value = true and then early-returns on if (!props.playbackId) return / if (!tenant.id) return — both before the try/finally that would clear it. loading therefore stays true permanently, and because the template checks v-else-if="loading" before the empty state, the No playback ID yet. / Forbidden access message at :19-25 is unreachable on the commonest path. The same code also swallows non-403 token errors to console.error, leaving the skeleton up. Why it matters: During live before a playback id exists, and in vod while the asset is still processing, the operator watches a shimmering placeholder with no message — indistinguishable from a hung page. Fix: Move the guards above loading.value = true, or clear loading in the early-return path, and give the token failure its own message.

12. A slow or failed configuration load renders as a blank page — Medium · proposed MSG-06 ​

Where: src/views/Events/Streaming/Index.vue:2-3What: The whole view is v-if="event.selected && draft", and draft is only populated by the watcher on streaming.configuration at :565-570. There is no v-else: while the VueFire document resolves — or if it never does, or if the streaming document does not exist for the event — the route renders nothing at all. PageSkeleton and EmptyState both exist in @playout/ui. Why it matters: The operator clicking "Streaming" from the menu during a service sees an empty page and cannot tell whether it is loading, broken, or has no data. Fix: Add a skeleton for the pending state and an error/empty state for the rest. (Playout-wide pattern — see the MSG-06 row in PROJECT-LEVEL.md's alignment table.)

13. Disabling the feature flag sends the user to "not found" — Medium · MSG-04 ​

Where: src/router/index.ts:81-84; src/router/routes.ts:95-97What: checkFeature returns false when the tenant lacks the streaming feature and the guard returns { name: "not-found" }. All three streaming routes are flagged, and Menu.vue:172 hides the entry, so the reachable cases are a bookmark, a shared link, or a tenant that had streaming turned off. Why it matters: "Page not found" is untrue and unactionable — the page exists, the tenant is not entitled to it. The user has no idea that asking an administrator to enable Streaming is the fix. Fix: Route feature-gated denials to a dedicated "not enabled for this tenant" state (the unauthorized view already exists) that names the feature and the way out.

14. Publish can be re-fired in the gap before the backend reports "processing" — Medium · FORM-06 ​

Where: src/views/Events/Streaming/Index.vue:703-726, :316-334What: isPublishing is cleared in finally, i.e. as soon as the POST returns, but the pipeline is asynchronous and the button's other guard (isPublishProcessing, from vodStage.status === "processing") only engages once the backend writes the stage. In that window the Publish button re-enables and VodPublishProgress's :requested="isPublishing" prop goes false, so the progress bar disappears — the operator sees the bar flash and the button come back, and a second click posts publish again. Separately, :disabled on the just-activated button drops focus to <body>, which FORM-06 addresses directly. Why it matters: Best case the progress bar flickers and the operator distrusts it; worst case the replay build is triggered twice. Fix: Hold isPublishing until vodStage.status leaves its pre-publish value (or until the realtime subscription confirms), and use aria-busy plus a re-entry guard rather than :disabled on the pressed control.

15. Toast notifications are not announced — Medium · MSG-01 ​

Where: src/components/common/Success.vue:12-15, src/components/common/Error.vue:12-16What: Every result in this feature — "Livestream ended", "Publishing started", "Livestream action failed", "Chapter action failed" — is delivered through these two components, and neither carries role="alert", role="status" or aria-live; both auto-dismiss after 5s. Why it matters: A screen-reader user gets no confirmation that the stream ended or that publishing failed. Shared-component defect — worth raising once for the project rather than per feature, but it is the whole feedback channel for this feature. Fix: role="status" on the success toast, role="alert" on the error toast.

16. Heading levels skip from h1 to h3 — Low · A11Y-04 ​

Where: src/views/Events/Streaming/Index.vue:7-9 vs :89, :253, :291, :344, :376, :403, :430, :439What: VTitle renders an <h1> (packages/ui/src/components/VTitle.vue:2 — this package is in-repo, so this is read from source, not inferred), and every section heading below it is <h3>. No <h2> exists on the page. Fix: Demote the section headings to h2.

17. streaming-modules is a stub route with no entry point — Low · MSG-04 ​

Where: src/views/Events/Streaming/Modules.vue:1-3; src/router/routes.ts:97; src/components/layout/Menu.vue:162-170What: The route renders <p>{{ $t('streaming.modules') }}</p> — the single word "Modules" — and nothing else. The menu registers only streaming and streaming-connections, so the route is reachable only by URL or an old bookmark, where it presents as a broken page rather than an unbuilt one. Fix: Remove the route until the view exists, or render an explicit "coming soon"/EmptyState.

18. Connections table: unguarded timestamps, state by opacity only — Low · A11Y-03, MSG-06 ​

Where: src/views/Events/Streaming/Connections.vue:52-73, :87What: Names are read defensively (item.user && item.user.firstName) but the timestamps are not — datetime(item.created_at.seconds) and item.last_connected.seconds throw if either field is missing, and connectionIsClosed dereferences last_connected unconditionally, so one malformed connection document breaks the render of the whole table. The "disconnected" state (last seen > 60s) is conveyed only by opacity-50 with no text, badge or legend, and the six column headers are hardcoded English under a translated page title. Fix: Optional-chain the timestamps with an em-dash fallback, and add a text "Disconnected" marker beside the dimming.

Unverified ​

  • A11Y-01 (contrast). Untestable from source. Several elements are candidates: text-faint on bg-panel for the stage tips and hints, the text-[10px]StreamingFlow labels, the text-[11px] progress-step row in VodPublishProgress.vue:21-23, and the amber "Hidden from site" chip over a bg-black/60 backdrop-blur at Index.vue:179-185.
  • A11Y-06 (short viewport / responsive). The lg:grid-cols-3 split, the max-h-112 chapter scroller (Index.vue:271-274), the max-h-[60vh] settings modal body and the 5-node StreamingFlow on a phone all need a rendered viewport.
  • A11Y-02 (target size) for the p-1 chapter delete button (ChaptersPanel.vue:44-53) and the px-1.5 py-0.5 ↑/↓ reorder buttons (RecordingsPanel.vue:40-57) — plausibly under 24×24 but the computed box needs a browser.
  • Whether VDialog traps focus. It sets role="dialog", aria-modal="true" and aria-labelledby (packages/ui/src/components/VDialog.vue:23-36) but is hand-rolled rather than Headless UI; I did not read the whole implementation.
  • The exact ordering of the backend publish pipeline (finding 14) — the size of the re-fire window depends on when vodStage.status is first written.

Baseline additions ​

  • SEC-SECRET-DISPLAY — A long-lived secret rendered in the UI (API key, stream key, token) is masked by default and revealed only by an explicit user action; copy controls confirm the copy. Playout already implements this in SecretField.vue and violates it in the streaming ingest panel. Distinct from NAV-04, which covers secrets in URLs.
  • LIVE-03 — already proposed in PROJECT-LEVEL.md from the lower-third audit ("an inverse control must not replace its counterpart in the same position"); confirmed here on the Show/Hide-on-site pair. No new ID needed.
  • MSG-06 — findings 4, 11 and 12 all fall under variants (a)/(b) of the existing MSG-06 collision listed in PROJECT-LEVEL.md ("a failure or pending fetch must not render as an empty page or a permanent loading state"). Renumbering is the orchestrator's; no new rule proposed.

Cross-project note ​

  • MSG-05 (destructive action without a specific confirmation) — the generic Confirm with a default "Are you sure?" is a shared component (src/components/common/Confirm.vue), so any playout view that uses it without passing title/subtitle has the same defect; worth a repo-wide grep. Delete flows in customer-portal, members and tt-time-tracker are worth the same check.
  • SEC-SECRET-DISPLAY — likely relevant to customer-portal (API credentials) and to any tt-time-tracker integration settings; not checked.
  • MSG-01 (toasts with no live region) — the playout toast components are app-wide; the equivalent question for PrimeVue Toast (customer-portal, tt-time-tracker) cannot be settled without node_modules.
  • MSG-04 (entitlement denial rendered as "not found") — feature flags exist only in playout among the four, so this one is playout-specific.