From 4b7e7987572f00e46af3c33170d8bb44c05ca710 Mon Sep 17 00:00:00 2001 From: gramps Date: Wed, 4 Nov 2026 11:19:40 -0800 Subject: [PATCH] feat(ui): show YouTube-confirmed live state in top bar Normal ON-AIR stays red until YouTube reports lifeCycleStatus=live, then turns green; TEST stays gold and stop restores the idle color. Status-color reference: https://www.carbondesignsystem.com/building-blocks/core/patterns/status-indicators --- HANDOFF.md | 21 +++++----- .../MainViewModel.Streaming.Operations.cs | 42 ++++++++++++------- ViewModels/MainViewModel.Streaming.cs | 18 ++++++++ docs/ai/ui-surface.md | 2 +- ytLive.Tests/OnAirSignTests.cs | 27 +++++++++--- 5 files changed, 77 insertions(+), 33 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index f91844d..cd059a2 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,21 +1,20 @@ -# HANDOFF — 2026-11-04 (Day 2 reconciliation and YouTube status color) +# HANDOFF — 2026-11-04 (Day 2 and YouTube live-state color) ## State -- `main`; recent commits: Logs `a6aea5b`, close-out dialog ownership `76e8c22`, vertical scaling via - FFmpeg `6310f50`. -- Day 2 documentation reconciliation is verified and pending its own commit. It retires unused - `StreamConfig` defaults, records the vertical resolution-only check, and updates visibility/tier policy. -- Top-bar status color is implemented and its focused test passes: normal ON-AIR stays red until - YouTube reports `live`, then turns green; TEST remains gold; stopping restores the idle color. -- Full verification: clean app build, 522/523 tests (the one failure was the known - `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`), scope check passed. +- `main`; relevant commits: dialog ownership `76e8c22`, vertical scaling through FFmpeg `6310f50`, + Day 2 documentation reconciliation `f921c01`. +- The top menu background follows YouTube's broadcast status: normal ON-AIR starts red and turns + green only at `lifeCycleStatus=live`; TEST stays gold; stopping restores the idle surface color. + The focused test passes; the change is included in this work unit. +- The focused top-bar test passes. The last full verification had a clean build and 522/523 tests; + the only failure was the known `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`. - Four untracked `Assets/llamacasty-*` files remain untouched. ## Next action -- Commit the pending Day 2 documentation unit and the top-bar color unit separately. -- Then continue TASK 53: enumerate output tiers and pin their geometry. Do not push absent a checkpoint request. +- TASK 53 remains the next queued output task: inventory emitted tiers and pin their geometry. +- Do not push absent a checkpoint request. ## Landmines diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index 5b4886c..27287dd 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -234,6 +234,12 @@ public partial class MainViewModel : ViewModelBase EditingActive = false; await _framePump.StartAsync(); // never throws; failures log + surface via Failed + // Check promptly until YouTube confirms the broadcast is live. Health + // polling returns to its normal interval after that confirmation. + _healthPollTimer.Interval = TimeSpan.FromSeconds(2); + _healthPollTimer.Start(); + _ = PollHealthAsync(); + string? liveChatId = null; try { @@ -264,10 +270,6 @@ public partial class MainViewModel : ViewModelBase AppLog.Write($"Test session dock open for {_currentBroadcastId}"); } - // Health polling (TASK 5 item 3): poll immediately, then every 30s while - // live. Report-by-exception — a banner appears only on warning/error. - _healthPollTimer.Start(); - _ = PollHealthAsync(); } private void OnHealthPollTick(object? sender, EventArgs e) => _ = PollHealthAsync(); @@ -293,19 +295,21 @@ public partial class MainViewModel : ViewModelBase { AppLog.Write("Stream health poll failed; check the OAuth session", AppLogMessageType.Error, AppLogErrorClass.Class2); - return; } - CurrentHealth.HealthStatus = health.HealthStatus; - CurrentHealth.HealthMessage = health.HealthStatus; - CurrentHealth.ConfigurationIssues.Clear(); - CurrentHealth.ConfigurationIssues.AddRange(health.ConfigurationIssues); - OnPropertyChanged(nameof(CurrentHealth)); - ApplyHealthIssue(health.ConfigurationIssues); + else + { + CurrentHealth.HealthStatus = health.HealthStatus; + CurrentHealth.HealthMessage = health.HealthStatus; + CurrentHealth.ConfigurationIssues.Clear(); + CurrentHealth.ConfigurationIssues.AddRange(health.ConfigurationIssues); + OnPropertyChanged(nameof(CurrentHealth)); + ApplyHealthIssue(health.ConfigurationIssues); + } if (!string.IsNullOrWhiteSpace(_currentBroadcastId) && !_broadcastSeenLive) { var lifeCycle = await _youtubeStream.LogBroadcastDiagnosticsAsync(_currentBroadcastId, _reusableStream.Id); - if (lifeCycle is "live" or "liveStarting") _broadcastSeenLive = true; + ApplyBroadcastLifecycleStatus(lifeCycle); } } catch (Exception ex) @@ -505,17 +509,23 @@ public partial class MainViewModel : ViewModelBase private void UpdateLiveVisuals() { var live = IsLive; - // A live TEST broadcast wears gold, not the live red — the TEST badge + - // gold are the "this is a rehearsal" cue (TASK 41). + // TEST keeps its gold identity. For a normal ON-AIR session, green means + // YouTube has confirmed live; before then it keeps the existing red color. TopBarBackground = live - ? ThemeBrush.Hex(IsTestStream ? "Brush.TestContainer" : "Brush.Primary") + ? IsTestStream + ? ThemeBrush.Hex("Brush.TestContainer") + : ThemeBrush.Hex(_youtubeBroadcastLive ? "Brush.Positive" : "Brush.Primary") : ThemeBrush.Hex("Brush.SurfaceContainer"); PreviewGlowBrush = live ? ThemeBrush.Hex(IsTestStream ? "Brush.TestContainer" : "Brush.Primary") : "Transparent"; PreviewGlowThickness = live ? new Thickness(3) : new Thickness(0); - if (!live) _healthPollTimer.Stop(); + if (!live) + { + _healthPollTimer.Stop(); + _healthPollTimer.Interval = TimeSpan.FromSeconds(30); + } // NOTE: the branding credit is deliberately NOT started/stopped here. // CREATOR RULING 2026-09-26 — it must appear in EVERY scene and in diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs index ee09929..c735bf1 100644 --- a/ViewModels/MainViewModel.Streaming.cs +++ b/ViewModels/MainViewModel.Streaming.cs @@ -24,6 +24,7 @@ public partial class MainViewModel : ViewModelBase /// Latches once the broadcast reaches live so the per-poll broadcast /// diagnostic stops logging (2026-10-04). Reset at broadcast creation. private bool _broadcastSeenLive; + private bool _youtubeBroadcastLive; private string? _reusableStreamUrl; private ReusableStream? _reusableStream; private string? _healthIssueBanner; @@ -63,6 +64,7 @@ public partial class MainViewModel : ViewModelBase { if (SetProperty(ref _streamStatus, value)) { + _youtubeBroadcastLive = false; OnPropertyChanged(nameof(IsOffline)); OnPropertyChanged(nameof(IsLive)); OnPropertyChanged(nameof(IsEditMode)); @@ -373,6 +375,22 @@ public partial class MainViewModel : ViewModelBase set => SetProperty(ref _topBarBackground, value); } + internal void ApplyBroadcastLifecycleStatus(string? lifeCycleStatus) + { + if (!IsLive) return; + if (!string.Equals(lifeCycleStatus, "live", StringComparison.OrdinalIgnoreCase)) + { + if (!_broadcastSeenLive) + _healthPollTimer.Interval = TimeSpan.FromSeconds(2); + return; + } + + _broadcastSeenLive = true; + _youtubeBroadcastLive = true; + _healthPollTimer.Interval = TimeSpan.FromSeconds(30); + UpdateLiveVisuals(); + } + public string PreviewGlowBrush { get => _previewGlowBrush; diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md index 4c616b8..55c9fd5 100644 --- a/docs/ai/ui-surface.md +++ b/docs/ai/ui-surface.md @@ -105,7 +105,7 @@ The app is a **director's control surface**, not an editor. This replaces the OB - **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = a two-tab panel with **Live Chat** first/default and **Logs** second. Logs is a read-only view of up to 500 `AppLog` entries captured in this process; it does not load older `startup.log` contents or change file logging/rotation. Entries wrap and use the theme's type colors. Explicit Class 1/2 severity is independent of message type/source: Class 1 sorts above Class 2 in the pinned group; acknowledging unpins the entry but retains it in the chronological list. The staged scene is the preview/edit target; `LiveScene` is the program output. - **Settings is a right-edge drawer, not a modal (2026-11-01):** the gear rail tab opens `Controls/SettingsDrawer` in the same animated-width stack as Stream Settings / YPP / Socials / TEST (`SettingsDrawerOpen`, exclusive with the other four). Categories are section headings with their controls indented under them — Zoom holds *Zoom view shape* and *Zoom box follows*. The app menu (Report Bug / Feature Request / About) hangs off a **right-click on the LlamaCasty wordmark**; left-click still opens About. The modal shell keeps only Bug / Feature / About, which is why it no longer needs a scroll viewer. -- **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand and account surfaces remain at the sides. Its **right edge aligns with the layers panel below** (`TopBar` column 0 is a fixed **352** = work-area margin 8 + 360px column − the bar's own 16px padding), so the button and the panel share an edge. The **settings gear moved to the preview rail** (2026-11-01): first tab, orange (`Brush.Attention`), gear icon only — left-click opens the app menu (Settings / Bug / Feature / About), right-click goes straight to App Settings. +- **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand and account surfaces remain at the sides. Its background uses the idle surface color when stopped. Normal ON-AIR stays `Brush.Primary` red while connecting and turns `Brush.Positive` green only after YouTube reports the broadcast `live`; stopping restores the idle color. TEST stays gold for the entire test session. Its **right edge aligns with the layers panel below** (`TopBar` column 0 is a fixed **352** = work-area margin 8 + 360px column − the bar's own 16px padding), so the button and the panel share an edge. The **settings gear moved to the preview rail** (2026-11-01): first tab, orange (`Brush.Attention`), gear icon only — left-click opens the app menu (Settings / Bug / Feature / About), right-click goes straight to App Settings. - **Preview gestures (2026-11-01):** *edit mode* — left selects/drags/resizes, right opens the context menu, **Ctrl+right toggles the zoom box**. *Preview mode* — **left toggles the zoom box**, right opens the context menu; **Ctrl+right does nothing at all** (no chord while live or recording — one hand, always). ⚠️ **Both the Down and the Up event must be handled for a Ctrl+right-click**: WPF's `PopupControlService` opens a context menu on right-button-**up**, so suppressing only the down event leaves the menu appearing under the gesture (found 2026-11-01 in edit mode). Menus exist on the webcam layer and the backdrop; every other layer right-clicks through to the backdrop menu. Left-dragging with the zoom box open moves it, in both modes. **Ctrl-down closes it**: pressing Ctrl while the box is open dismisses it, so the same hand that opened it can put it away (`MainWindow_PreviewKeyDown`; the box cannot cancel itself on creation because Ctrl is already down before it exists). **Follow mode** is a Settings pill, default **off = Anchor**: with Follow on, dragging re-anchors the capture to the box centre (`ZoomFollowsBox`, persisted as the `Zoom.FollowBox` setting). **The gesture activates the box at the cursor**, not merely dismisses it: `ActivateZoom` no longer refuses when idle (it used to return unless `IsLive || IsRecording`, and `ToggleZoom` only ever dismissed — so the gesture did nothing at all outside a session until 2026-11-01). The social bar consumes its own click (toggle top ⇄ bottom), and the preview's tunnelling handler steps aside for it — otherwise the bar is unclickable in preview mode and drags the layer beneath it in edit mode. - **Preview undo/redo (2026-11-01):** `Ctrl+Z` / `Ctrl+Shift+Z`, window-level bindings, live only while `EditingActive` — undo with the preview showing program output would rewrite a scene nobody is looking at. `Models/PreviewHistory` snapshots element **references** plus geometry, visibility, order and membership, so move / resize / reorder / add / remove / hide all undo in one step per gesture (`PushPreviewUndo` is called once at drag or resize start, never inside the move loop). Property edits (colour, opacity, border, font) are **not** captured yet — stated in the type's doc comment so nobody assumes otherwise. The stack clears on layout load. Guard: `ytLive.Tests/PreviewHistoryTests.cs`. - **Developer affordances are compiled out of release, not hidden.** Any control that must never ship (today: the all-resolutions override) lives in its own `UserControl` whose `Page` and `Compile` items are removed by `ytLive.csproj` when `$(Configuration) != 'Debug'`, and is added to its host at runtime inside `#if DEBUG` (`OverlayHost`). A collapsed row still exists in the visual tree of every shipped copy; "not compiled" is the only claim worth making. Label such rows with `Brush.Attention` (orange) while they are visible in Debug. Guard: `ytLive.Tests/OutputProfileTests.cs` asserts the build rule, and `Helpers/DevBuild.cs` is the single source for "is this a dev build". diff --git a/ytLive.Tests/OnAirSignTests.cs b/ytLive.Tests/OnAirSignTests.cs index 7a696bc..8f3c8b8 100644 --- a/ytLive.Tests/OnAirSignTests.cs +++ b/ytLive.Tests/OnAirSignTests.cs @@ -1,22 +1,21 @@ using System; using System.ComponentModel; using Xunit; +using ytLive.Helpers; using ytLive.Models; using ytLive.ViewModels; namespace ytLive.Tests; /// -/// The ON-AIR sign (right of REC in the top bar): gray offline, red when -/// streaming public, green when streaming private. Driven through the real -/// MainViewModel so StreamStatus/StreamVisibility notification wiring is -/// exercised, not just the computed property. +/// The ON-AIR sign and menu-bar color follow the real MainViewModel state, +/// including the YouTube lifecycle confirmation before the bar turns green. /// [Collection("RealApp")] public sealed class OnAirSignTests { [Fact] - public void OnAirBrush_Follows_Offline_Public_And_Private() + public void OnAirBrush_And_TopBarBackground_Follow_YouTube_Live_State() { _app.Run(Run); } @@ -38,6 +37,7 @@ public sealed class OnAirSignTests Assert.Equal(StreamStatus.Offline, vm.StreamStatus); Assert.Equal("#5b6478", vm.OnAirBrush); + var idleBackground = vm.TopBarBackground; var raised = 0; vm.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(MainViewModel.OnAirBrush)) raised++; }; @@ -47,11 +47,28 @@ public sealed class OnAirSignTests vm.StreamStatus = StreamStatus.Streaming; Assert.Equal("#ff5451", vm.OnAirBrush); + Assert.Equal(ThemeBrush.Hex("Brush.Primary"), vm.TopBarBackground); + + vm.ApplyBroadcastLifecycleStatus("liveStarting"); + Assert.Equal(ThemeBrush.Hex("Brush.Primary"), vm.TopBarBackground); vm.StreamVisibility = "Private"; Assert.Equal("#22c55e", vm.OnAirBrush); Assert.Equal(3, raised); + vm.ApplyBroadcastLifecycleStatus("live"); + Assert.Equal(ThemeBrush.Hex("Brush.Positive"), vm.TopBarBackground); + + vm.StreamStatus = StreamStatus.Offline; + Assert.Equal(idleBackground, vm.TopBarBackground); + + vm.IsTestStream = true; + vm.StreamStatus = StreamStatus.Streaming; + Assert.Equal(ThemeBrush.Hex("Brush.TestContainer"), vm.TopBarBackground); + vm.ApplyBroadcastLifecycleStatus("live"); + Assert.Equal(ThemeBrush.Hex("Brush.TestContainer"), vm.TopBarBackground); + vm.StreamStatus = StreamStatus.Offline; + Assert.Equal(idleBackground, vm.TopBarBackground); } finally {