feat(topbar): the action follows the armed output; Test is an explicit ask again
Unit C (creator ruling 2026-11-12). - Local MP4: one button, "Start Recording". - YouTube: "Private Test Stream" beside "Review & Go Live", the latter with the primary face because it is the default ask. - Running: "End Live Stream" (or "End Test" for a test run, "Stop Recording" for a local one). The behavioural change underneath: SessionStartDecision.For no longer takes `editing`. Since 2026-10-30 EDIT silently chose test-vs-real, which meant the same button did two different things depending on a toggle three inches away. With Test as its own button that inference has no job, so For() now reads only the armed output and ON-AIR always means the real livestream. That supersedes the 2026-10-30 ruling; SessionStartDecisionTests is rewritten to pin the new matrix rather than the old one. A signed-out press still starts the sign-in flow instead of sitting dead (no-dead-ends), unchanged from before — now stated in one place, the two new commands, instead of inside a route table. 680 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art, still awaiting their visual check.
This commit is contained in:
+13
-5
@@ -145,11 +145,19 @@
|
|||||||
</Border>
|
</Border>
|
||||||
<TextBlock Text="One output at a time" FontSize="11" VerticalAlignment="Center"
|
<TextBlock Text="One output at a time" FontSize="11" VerticalAlignment="Center"
|
||||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" Margin="8,0,0,0"/>
|
Foreground="{StaticResource Brush.OnSurfaceVariant}" Margin="8,0,0,0"/>
|
||||||
<!-- Go face names the world: "Start Recording" vs "Go Live".
|
<!-- The action follows the armed output (creator ruling 2026-11-12).
|
||||||
(Signed-out ON-AIR Start routes to the sign-in — StartSession.) -->
|
MP4: one button. YouTube: the test run AND the real one, side by side — Test is
|
||||||
<Button Content="{Binding PrimaryStartButtonLabel}" Style="{StaticResource YtButton}"
|
something the creator asks for now, not something inferred from EDIT — with
|
||||||
Height="32" Margin="10,0,0,0" Command="{Binding PrimaryStartCommand}"
|
Review & Go Live carrying the primary face because it is the default ask. -->
|
||||||
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}"/>
|
<Button Content="Start Recording" Style="{StaticResource YtButton}"
|
||||||
|
Height="32" Margin="10,0,0,0" Command="{Binding StartRecordingCommand}"
|
||||||
|
Visibility="{Binding ShowStartRecordingAction, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<Button Content="Private Test Stream" Style="{StaticResource YtButtonSecondary}"
|
||||||
|
Height="32" Margin="10,0,0,0" Command="{Binding TestStreamCommand}"
|
||||||
|
Visibility="{Binding ShowYouTubeStartActions, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<Button Content="Review & Go Live" Style="{StaticResource YtButton}"
|
||||||
|
Height="32" Margin="8,0,0,0" Command="{Binding GoLiveCommand}"
|
||||||
|
Visibility="{Binding ShowYouTubeStartActions, Converter={StaticResource BoolToVis}}"/>
|
||||||
<!-- TASK 32·3: the drop ended this broadcast. YouTube cannot resume a completed one, so
|
<!-- TASK 32·3: the drop ended this broadcast. YouTube cannot resume a completed one, so
|
||||||
the only honest action is a new broadcast — one click, saved details, and the title
|
the only honest action is a new broadcast — one click, saved details, and the title
|
||||||
says it is a resume so the channel shows what happened. -->
|
says it is a resume so the channel shows what happened. -->
|
||||||
|
|||||||
@@ -21,12 +21,17 @@ public enum SessionStartKind
|
|||||||
/// <para>Pure so the whole matrix is testable without a window.</para></summary>
|
/// <para>Pure so the whole matrix is testable without a window.</para></summary>
|
||||||
public static class SessionStartDecision
|
public static class SessionStartDecision
|
||||||
{
|
{
|
||||||
public static SessionStartKind For(bool recordArmed, bool onAirArmed, bool editing)
|
/// <summary>
|
||||||
|
/// Which kind of session the armed output starts. Since 2026-11-12 there is no inference from
|
||||||
|
/// editing: the output stage offers "Private Test Stream" and "Review & Go Live" as separate
|
||||||
|
/// buttons, so ON-AIR always means the real livestream and Test is something the creator asks for.
|
||||||
|
/// </summary>
|
||||||
|
public static SessionStartKind For(bool recordArmed, bool onAirArmed)
|
||||||
{
|
{
|
||||||
// Neither pill armed still starts something: local recording needs no account, and the
|
// Neither output armed still starts something: local recording needs no account, and the
|
||||||
// no-dead-ends rule forbids an inert primary button.
|
// no-dead-ends rule forbids an inert primary button.
|
||||||
if (recordArmed || !onAirArmed) return SessionStartKind.Record;
|
if (recordArmed || !onAirArmed) return SessionStartKind.Record;
|
||||||
return editing ? SessionStartKind.Test : SessionStartKind.Live;
|
return SessionStartKind.Live;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>What pressing the button actually does, given whether YouTube is connected.
|
/// <summary>What pressing the button actually does, given whether YouTube is connected.
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
/// <summary>Creator ruling 2026-10-30: EDIT decides whether the ON-AIR start is the real
|
/// <summary>Creator ruling 2026-10-30: EDIT decides whether the ON-AIR start is the real
|
||||||
/// livestream or the test one, which is why the separate TEST button is gone.</summary>
|
/// livestream or the test one, which is why the separate TEST button is gone.</summary>
|
||||||
public SessionStartKind PrimaryStartKind =>
|
public SessionStartKind PrimaryStartKind =>
|
||||||
SessionStartDecision.For(RecordPillOn, OnAirPillOn, EditingActive);
|
SessionStartDecision.For(RecordPillOn, OnAirPillOn);
|
||||||
|
|
||||||
public string PrimaryStartButtonLabel => SessionStartDecision.LabelFor(PrimaryStartKind);
|
public string PrimaryStartButtonLabel => SessionStartDecision.LabelFor(PrimaryStartKind);
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
public string EndButtonText =>
|
public string EndButtonText =>
|
||||||
IsRecording && !IsLive ? "Stop Recording"
|
IsRecording && !IsLive ? "Stop Recording"
|
||||||
: IsTestStream ? "End Test"
|
: IsTestStream ? "End Test"
|
||||||
: "End Stream";
|
: "End Live Stream";
|
||||||
|
|
||||||
/// <summary>TASK 41 — Test is a child of Stream (creator ruling: "Test should only
|
/// <summary>TASK 41 — Test is a child of Stream (creator ruling: "Test should only
|
||||||
/// proc when we've selected stream"): a dry-run of the livestream, so it exists
|
/// proc when we've selected stream"): a dry-run of the livestream, so it exists
|
||||||
|
|||||||
@@ -35,11 +35,33 @@ public partial class MainViewModel
|
|||||||
/// running — a live session cannot change its output, so its switch is not offered.</summary>
|
/// running — a live session cannot change its output, so its switch is not offered.</summary>
|
||||||
public bool ShowOutputStage => IsOutputWorld && IsOffline && !IsRecording;
|
public bool ShowOutputStage => IsOutputWorld && IsOffline && !IsRecording;
|
||||||
|
|
||||||
|
/// <summary>The action the armed output offers: recording is one button, YouTube is two — the test
|
||||||
|
/// run and the real one are separate asks since 2026-11-12, not something inferred from EDIT.</summary>
|
||||||
|
public bool ShowStartRecordingAction => ShowOutputStage && RecordPillOn;
|
||||||
|
public bool ShowYouTubeStartActions => ShowOutputStage && OnAirPillOn;
|
||||||
|
|
||||||
|
public ICommand StartRecordingCommand { get; private set; } = null!;
|
||||||
|
public ICommand TestStreamCommand { get; private set; } = null!;
|
||||||
|
public ICommand GoLiveCommand { get; private set; } = null!;
|
||||||
|
|
||||||
public ICommand EnterBuildWorldCommand { get; private set; } = null!;
|
public ICommand EnterBuildWorldCommand { get; private set; } = null!;
|
||||||
public ICommand EnterOutputWorldCommand { get; private set; } = null!;
|
public ICommand EnterOutputWorldCommand { get; private set; } = null!;
|
||||||
|
|
||||||
private void InitializeWorldCommands()
|
private void InitializeWorldCommands()
|
||||||
{
|
{
|
||||||
|
StartRecordingCommand = new RelayCommand(_ => BeginRecordOnly(), _ => ShowStartRecordingAction);
|
||||||
|
// A signed-out press starts the sign-in flow instead of sitting dead (no-dead-ends rule).
|
||||||
|
TestStreamCommand = new RelayCommand(_ =>
|
||||||
|
{
|
||||||
|
if (!IsConnected) { _ = SignInAsync(); return; }
|
||||||
|
BeginTestStream();
|
||||||
|
}, _ => ShowYouTubeStartActions);
|
||||||
|
GoLiveCommand = new RelayCommand(_ =>
|
||||||
|
{
|
||||||
|
if (!IsConnected) { _ = SignInAsync(); return; }
|
||||||
|
StartSession();
|
||||||
|
}, _ => ShowYouTubeStartActions);
|
||||||
|
|
||||||
EnterBuildWorldCommand = new RelayCommand(_ => EnterWorld(BarWorld.Build));
|
EnterBuildWorldCommand = new RelayCommand(_ => EnterWorld(BarWorld.Build));
|
||||||
EnterOutputWorldCommand = new RelayCommand(_ => EnterWorld(BarWorld.Output));
|
EnterOutputWorldCommand = new RelayCommand(_ => EnterWorld(BarWorld.Output));
|
||||||
}
|
}
|
||||||
@@ -54,5 +76,7 @@ public partial class MainViewModel
|
|||||||
OnPropertyChanged(nameof(IsOutputWorld));
|
OnPropertyChanged(nameof(IsOutputWorld));
|
||||||
EditingActive = world == BarWorld.Build;
|
EditingActive = world == BarWorld.Build;
|
||||||
OnPropertyChanged(nameof(ShowOutputStage));
|
OnPropertyChanged(nameof(ShowOutputStage));
|
||||||
|
OnPropertyChanged(nameof(ShowStartRecordingAction));
|
||||||
|
OnPropertyChanged(nameof(ShowYouTubeStartActions));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,7 +157,14 @@ Placement math itself is covered by `WindowPlacementTests` (pure function, no HW
|
|||||||
its glyph, with **Local MP4 armed at launch** (`RecordPillOn` starts true — it did not before). The two
|
its glyph, with **Local MP4 armed at launch** (`RecordPillOn` starts true — it did not before). The two
|
||||||
are one axis and the view model already drops one when the other arms, so exclusivity is policy, not
|
are one axis and the view model already drops one when the other arms, so exclusivity is policy, not
|
||||||
layout. A running session hides the stage (its output cannot change) and the reality line plus the
|
layout. A running session hides the stage (its output cannot change) and the reality line plus the
|
||||||
stop action take the cluster's place. Next in the same redesign: the output-dependent primary action.
|
stop action take the cluster's place.
|
||||||
|
- **The action follows the armed output** (creator ruling 2026-11-12). Local MP4 → `Start Recording`
|
||||||
|
(`Stop Recording` while running). YouTube → `Private Test Stream` beside `Review & Go Live`, the latter
|
||||||
|
carrying the primary face because it is the default ask. **Test is no longer inferred from EDIT** — the
|
||||||
|
2026-10-30 rule where COLLECT EDIT decided test-vs-real is superseded, and `SessionStartDecision.For`
|
||||||
|
takes only the armed output. Running: `End Live Stream` (or `End Test` for a test run, `Stop Recording`
|
||||||
|
for a local one) via `EndButtonText`. A signed-out YouTube press starts the sign-in flow rather than
|
||||||
|
sitting dead (no-dead-ends).
|
||||||
- **Editing during a session is a setting (creator ruling 2026-11-12).** `App.LockEditWhileLive` is off
|
- **Editing during a session is a setting (creator ruling 2026-11-12).** `App.LockEditWhileLive` is off
|
||||||
by default: a show does not take EDIT away, so a creator may move a layer or resize a webcam while
|
by default: a show does not take EDIT away, so a creator may move a layer or resize a webcam while
|
||||||
live. On restores the old gun-safety — go-live flips EDIT off, stop flips it back, and only because
|
live. On restores the old gun-safety — go-live flips EDIT off, stop flips it back, and only because
|
||||||
|
|||||||
@@ -3,27 +3,22 @@ using ytLive.Models;
|
|||||||
|
|
||||||
namespace ytLive.Tests;
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
/// <summary>Creator ruling (2026-10-30): the top bar's primary button is context-sensitive and the
|
/// <summary>Which session an armed output starts, and where a press routes.
|
||||||
/// separate TEST button is gone.
|
|
||||||
///
|
///
|
||||||
/// <para>EDIT off + ON-AIR → the livestream message. EDIT on + ON-AIR → the test-livestream
|
/// <para>Superseded 2026-11-12: the ON-AIR start no longer infers Test from EDIT. The output stage
|
||||||
/// message. REC always records, because there is no test recording. The matrix is pure, so it is
|
/// offers "Private Test Stream" and "Review & Go Live" as separate buttons, so ON-AIR always means the
|
||||||
/// cheaper to pin it here than to drive a window.</para></summary>
|
/// real livestream here. REC always records, because there is no test recording.</para></summary>
|
||||||
public class SessionStartDecisionTests
|
public class SessionStartDecisionTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Edit_decides_whether_the_on_air_start_is_live_or_a_test()
|
public void The_armed_output_decides_the_session_kind_and_test_is_never_inferred()
|
||||||
{
|
{
|
||||||
// ON-AIR armed: EDIT decides.
|
// ON-AIR armed: the real livestream, never a test.
|
||||||
Assert.Equal(SessionStartKind.Live, SessionStartDecision.For(recordArmed: false, onAirArmed: true, editing: false));
|
Assert.Equal(SessionStartKind.Live, SessionStartDecision.For(recordArmed: false, onAirArmed: true));
|
||||||
Assert.Equal("Go Live", SessionStartDecision.LabelFor(SessionStartKind.Live));
|
Assert.Equal("Go Live", SessionStartDecision.LabelFor(SessionStartKind.Live));
|
||||||
|
|
||||||
Assert.Equal(SessionStartKind.Test, SessionStartDecision.For(recordArmed: false, onAirArmed: true, editing: true));
|
// REC armed: always a recording (no test recording exists).
|
||||||
Assert.Equal("Test Stream", SessionStartDecision.LabelFor(SessionStartKind.Test));
|
Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(true, false));
|
||||||
|
|
||||||
// REC armed: always a recording, whatever EDIT says (no test recording exists).
|
|
||||||
Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(true, false, false));
|
|
||||||
Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(true, false, true));
|
|
||||||
Assert.Equal("Start Recording", SessionStartDecision.LabelFor(SessionStartKind.Record));
|
Assert.Equal("Start Recording", SessionStartDecision.LabelFor(SessionStartKind.Record));
|
||||||
|
|
||||||
// Signed out: an ON-AIR press (live OR test) routes to the sign-in flow, never a dead
|
// Signed out: an ON-AIR press (live OR test) routes to the sign-in flow, never a dead
|
||||||
@@ -35,6 +30,6 @@ public class SessionStartDecisionTests
|
|||||||
Assert.Equal(SessionStartDecision.Route.Record, SessionStartDecision.RouteFor(SessionStartKind.Record, connected: false));
|
Assert.Equal(SessionStartDecision.Route.Record, SessionStartDecision.RouteFor(SessionStartKind.Record, connected: false));
|
||||||
|
|
||||||
// Unarmed start records locally (the no-dead-ends rule).
|
// Unarmed start records locally (the no-dead-ends rule).
|
||||||
Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(false, false, false));
|
Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(false, false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ public class TopBarWorldTests
|
|||||||
Assert.False(vm.RecordPillOn);
|
Assert.False(vm.RecordPillOn);
|
||||||
vm.RecordPillOn = true;
|
vm.RecordPillOn = true;
|
||||||
Assert.False(vm.OnAirPillOn);
|
Assert.False(vm.OnAirPillOn);
|
||||||
|
|
||||||
|
// The action follows the output: one button for MP4, two for YouTube (test + real).
|
||||||
|
Assert.True(vm.ShowStartRecordingAction);
|
||||||
|
Assert.False(vm.ShowYouTubeStartActions);
|
||||||
|
vm.OnAirPillOn = true;
|
||||||
|
Assert.False(vm.ShowStartRecordingAction);
|
||||||
|
Assert.True(vm.ShowYouTubeStartActions);
|
||||||
|
|
||||||
|
// Neither action exists while building scenes.
|
||||||
|
vm.EnterBuildWorldCommand.Execute(null);
|
||||||
|
Assert.False(vm.ShowStartRecordingAction);
|
||||||
|
Assert.False(vm.ShowYouTubeStartActions);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ public class UiShellTests
|
|||||||
Assert.Contains("IsChecked=\"{Binding OnAirPillOn}\"", topBar);
|
Assert.Contains("IsChecked=\"{Binding OnAirPillOn}\"", topBar);
|
||||||
Assert.Contains("IsChecked=\"{Binding RecordPillOn}\"", topBar);
|
Assert.Contains("IsChecked=\"{Binding RecordPillOn}\"", topBar);
|
||||||
|
|
||||||
|
// 2d. The action follows the armed output, and Test is an explicit ask again.
|
||||||
|
Assert.Contains("Content=\"Start Recording\"", topBar);
|
||||||
|
Assert.Contains("Content=\"Private Test Stream\"", topBar);
|
||||||
|
Assert.Contains("Content=\"Review & Go Live\"", topBar);
|
||||||
|
|
||||||
// 3. Active scene = orange; live stays red.
|
// 3. Active scene = orange; live stays red.
|
||||||
Assert.Contains("IsStaged}\" Value=\"True\">\n <Setter Property=\"BorderBrush\" Value=\"{StaticResource Brush.Attention}\"/>", strip);
|
Assert.Contains("IsStaged}\" Value=\"True\">\n <Setter Property=\"BorderBrush\" Value=\"{StaticResource Brush.Attention}\"/>", strip);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user