feat(topbar): three activities, always visible — build, stream, record

Creator ruling 2026-11-13: "the app does three things. builds. records. live-streams." So the bar holds
three buttons that never disappear; the active one is lit and runs its own flow in place, and the other
two dim but stay clickable, so there is no state where the creator cannot see where they are or how to
step back.

  Build    [Build Scenes]  -> editing on, nothing to start
  YouTube  [Live][Private] -> [Start] -> [Stop]     (Private = the private-visibility test stream)
  Record   [Start Recording] -> [● REC hh:mm:ss][Stop Recording]

Arming and starting are separate presses on purpose (creator): Start is the last chance to tell someone
"check you are ready". Collapsing them would save one click and lose that prompt.

This replaces the two-world model (BarWorld) and its output stage outright, including the chips added
earlier today. The old pills remain the underlying state (OnAirPillOn/RecordPillOn) so the start, stop and
step-down paths are untouched; the activity only decides what the bar shows.

Closing the app no longer waits 6 s for a session that cannot stop: it waits ~2 s, then clears the state
and closes anyway. A window that refuses to close is worse than a stale flag — and that refusal is what
hung the suite in ZoomBoxInteractionTests, which sets IsRecording and closes a real window.

Test: TopBarActivityTests pins all three flows, the arm-then-start split, and that stepping back to Build
forgets the YouTube choice instead of carrying it. TopBarWorldTests is retired with the model it tested.
This commit is contained in:
2026-11-13 11:30:57 -08:00
parent f4caea09ad
commit ba60f9ab3d
9 changed files with 311 additions and 251 deletions
+12 -3
View File
@@ -342,7 +342,7 @@ public partial class MainViewModel : ViewModelBase
OpenHotkeyConfigCommand = new RelayCommand(_ => OpenHotkeyConfig());
ChangeAccountCommand = new RelayCommand(_ => _ = ChangeAccountAsync());
LogoutCommand = new RelayCommand(_ => _ = SignOutYouTubeAsync());
InitializeWorldCommands();
InitializeActivityCommands();
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
_activeLayoutPath = _layoutStore.ActivePath;
@@ -635,10 +635,19 @@ public partial class MainViewModel : ViewModelBase
_shuttingDown = true;
StopStream();
for (var i = 0; i < 120 && (IsLive || IsRecording); i++) // up to ~6 s
for (var i = 0; i < 40 && (IsLive || IsRecording); i++) // ~2 s: long enough for a flush
await Task.Delay(50).ConfigureAwait(true);
return !IsLive && !IsRecording;
if (IsLive || IsRecording)
{
// The stop could not finish (no pump, a dead encoder, a test harness). A window that cannot
// close is a worse failure than a session flag left set, so clear the UI state and go.
AppLog.Write("Shutdown: session did not stop in time; closing anyway", AppLogMessageType.Warning);
IsRecording = false;
StreamStatus = StreamStatus.Offline;
}
return true;
}
public void Shutdown()