feat(topbar): three fixed slots — every state changes inside its own slot
Creator correction (2026-11-13): "OpenCode literally inserted Live and Private as two extra flat siblings
inside the outer pill bar, shoving Record to the right and making it look like five ungrouped toolbar
items". Correct — completion states were being added to the BAR instead of to a SLOT.
Now the bar is exactly three slots, and the bar's shape never changes:
[ Build Scenes ] [ YouTube ] [ Record ]
YouTube: ▶ YouTube -> [Live | Private | ✕] -> [● Start Live | ✕] (amber: next click goes on air)
-> [■ Stop Live]
Record: ◉ Record -> [● Start Recording | ✕] -> ● REC hh:mm:ss [■ Stop]
Build: enabled only while BOTH action slots are Idle; otherwise disabled at 40% with the tooltip
"Scene editing is locked while streaming or recording"
State lives in YouTubeSlotState / RecordSlotState, with one RaiseSlotStates() announcing every flag so no
caller can forget one. Esc and the ✕ cancel the slot you are in and nothing else.
This retires the BarWorld model from earlier today, along with TopBarActivityTests (superseded by
TopBarSlotTests, which pins all three flows, the lock, and cancel-to-Idle).
679 pass, 0 failed (the host-hang fallback in f4caea0 counted the per-test lines).
Scope justification: TopBarActivityTests.cs is deleted as part of this unit — it tested the model being
replaced; TopBarSlotTests is its successor.
This commit is contained in:
@@ -656,7 +656,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
}
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
OnPropertyChanged(nameof(LiveElapsedText));
|
||||
OnPropertyChanged(nameof(RecordActionLabel));
|
||||
OnPropertyChanged(nameof(RecordElapsedLabel));
|
||||
}
|
||||
|
||||
private void OnLiveTimerTick(object? sender, EventArgs e)
|
||||
@@ -666,7 +666,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
_recordLength = _liveElapsed;
|
||||
_recDotPulse = _recDotPulse > 0.5 ? 0.2 : 1.0;
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
OnPropertyChanged(nameof(RecordActionLabel));
|
||||
OnPropertyChanged(nameof(RecordElapsedLabel));
|
||||
|
||||
// TASK 55·3: the last useful warning before the encoder fails for a reason it won't explain.
|
||||
if (IsRecording && !_lowDiskWarned && _recordFolder is { } folder
|
||||
|
||||
@@ -87,7 +87,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
||||
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
||||
RaiseTopBarModes();
|
||||
RaiseActivityStage(); // IsLive drives ShowOutputStage
|
||||
RaiseSlotStates(); // IsLive drives ShowOutputStage
|
||||
UpdateLiveVisuals();
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
{
|
||||
if (!SetProperty(ref _recordPillOn, value)) return;
|
||||
if (value && _onAirPillOn) OnAirPillOn = false; // record-OR-live — arming REC drops ON-AIR
|
||||
RaiseActivityStage();
|
||||
RaiseSlotStates();
|
||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||
OnPropertyChanged(nameof(PrimaryStartKind));
|
||||
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
|
||||
@@ -138,7 +138,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
// intent, not reality; the Start face turns this into a sign-in.
|
||||
if (!SetProperty(ref _onAirPillOn, value)) return;
|
||||
if (value && _recordPillOn) RecordPillOn = false; // record-OR-live — arming ON-AIR drops REC
|
||||
RaiseActivityStage();
|
||||
RaiseSlotStates();
|
||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||
OnPropertyChanged(nameof(PrimaryStartKind));
|
||||
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
|
||||
@@ -171,7 +171,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(EndButtonText));
|
||||
OnPropertyChanged(nameof(ShowTestButton));
|
||||
RaiseTopBarModes();
|
||||
RaiseActivityStage(); // Start Recording <-> Stop Recording, and the REC clock
|
||||
RaiseSlotStates(); // Start Recording <-> Stop Recording, and the REC clock
|
||||
UpdateSessionTimer();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,115 +3,161 @@ using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
/// <summary>What the app is doing (creator, 2026-11-13): build, record, or stream. Three activities,
|
||||
/// three buttons, always visible.</summary>
|
||||
/// <summary>Which YouTube stream is armed. Choosing does not start it — Start does.</summary>
|
||||
public enum StreamMode
|
||||
/// <summary>Where the YouTube slot is in its own five-step flow.</summary>
|
||||
public enum YouTubeSlotState
|
||||
{
|
||||
None,
|
||||
Live,
|
||||
Private,
|
||||
/// <summary>[ ▶ YouTube ]</summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>[ ▶ Live | 🔒 Private | ✕ ]</summary>
|
||||
ChoosePrivacy,
|
||||
|
||||
/// <summary>[ ● Start Live | ✕ ] or [ ● Start Private | ✕ ]</summary>
|
||||
Armed,
|
||||
|
||||
/// <summary>[ ■ Stop Live ] or [ ■ Stop Private ]</summary>
|
||||
Streaming,
|
||||
}
|
||||
|
||||
public enum TopActivity
|
||||
/// <summary>Where the Record slot is in its own three-step flow.</summary>
|
||||
public enum RecordSlotState
|
||||
{
|
||||
Build,
|
||||
YouTube,
|
||||
Record,
|
||||
/// <summary>[ ◉ Record ]</summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>[ ● Start Recording | ✕ ]</summary>
|
||||
Armed,
|
||||
|
||||
/// <summary>[ ■ Stop Recording ]</summary>
|
||||
Recording,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The top bar is THREE FIXED SLOTS (creator, 2026-11-13): Build Scenes, YouTube, Record. A slot never
|
||||
/// changes the bar's shape — every state transition happens INSIDE its own slot, and the neighbouring
|
||||
/// slots never move. Earlier attempts added completion states as siblings in the bar, which read as five
|
||||
/// ungrouped toolbar items and pushed Record around.
|
||||
/// </summary>
|
||||
public partial class MainViewModel
|
||||
{
|
||||
private TopActivity _activity = TopActivity.Build;
|
||||
private YouTubeSlotState _youTubeStep = YouTubeSlotState.Idle;
|
||||
private RecordSlotState _recordStep = RecordSlotState.Idle;
|
||||
private bool _podcastLive; // true = Private chosen (the private-visibility test stream)
|
||||
|
||||
public TopActivity Activity => _activity;
|
||||
// ─── Build Scenes slot ───
|
||||
// Enabled only while BOTH other slots are idle: editing a scene under a live stream or a running
|
||||
// recording is what the lock is for, and the tooltip says so out loud.
|
||||
public bool IsBuildScenesEnabled =>
|
||||
_youTubeStep == YouTubeSlotState.Idle && _recordStep == RecordSlotState.Idle;
|
||||
|
||||
public bool IsBuildActivity => _activity == TopActivity.Build;
|
||||
public bool IsYouTubeActivity => _activity == TopActivity.YouTube;
|
||||
public bool IsRecordActivity => _activity == TopActivity.Record;
|
||||
public string BuildScenesTooltip => IsBuildScenesEnabled
|
||||
? "Edit layers, scenes and webcam placement"
|
||||
: "Scene editing is locked while streaming or recording";
|
||||
|
||||
// ─── Each activity's own flow ───
|
||||
// Build: nothing to start. YouTube: Live or Private, then Stop. Record: Start, then Stop.
|
||||
// ─── YouTube slot ───
|
||||
public YouTubeSlotState YouTubeStep => _youTubeStep;
|
||||
public bool IsYouTubeIdle => _youTubeStep == YouTubeSlotState.Idle;
|
||||
public bool IsYouTubeChoosingPrivacy => _youTubeStep == YouTubeSlotState.ChoosePrivacy;
|
||||
public bool IsYouTubeArmed => _youTubeStep == YouTubeSlotState.Armed;
|
||||
public bool IsYouTubeStreaming => _youTubeStep == YouTubeSlotState.Streaming;
|
||||
|
||||
// YouTube is two steps ON PURPOSE (creator, 2026-11-13): choosing Live or Private only ARMS it,
|
||||
// because Start is the last moment to tell someone "check you are ready". Collapsing the two would
|
||||
// trade that prompt for one fewer click.
|
||||
private StreamMode _streamMode = StreamMode.None;
|
||||
public bool IsLiveChosen => !_podcastLive;
|
||||
public bool IsPrivateChosen => _podcastLive;
|
||||
public string YouTubeArmLabel => _podcastLive ? "Start Private" : "Start Live";
|
||||
public string YouTubeStopLabel => _podcastLive ? "Stop Private" : "Stop Live";
|
||||
|
||||
public bool IsLiveModeChosen => _streamMode == StreamMode.Live;
|
||||
public bool IsPrivateModeChosen => _streamMode == StreamMode.Private;
|
||||
|
||||
public bool ShowYouTubeModeChoices => IsYouTubeActivity && !IsLive && _streamMode == StreamMode.None;
|
||||
public bool ShowYouTubeStart => IsYouTubeActivity && !IsLive && _streamMode != StreamMode.None;
|
||||
public bool ShowYouTubeStop => IsYouTubeActivity && IsLive;
|
||||
public bool ShowRecordStart => IsRecordActivity && !IsRecording;
|
||||
public bool ShowRecordStop => IsRecordActivity && IsRecording;
|
||||
|
||||
/// <summary>The Record activity's own words: its name while idle, its action while chosen.</summary>
|
||||
public string RecordActionLabel => IsRecording ? "Stop Recording" : "Start Recording";
|
||||
// ─── Record slot ───
|
||||
public RecordSlotState RecordStep => _recordStep;
|
||||
public bool IsRecordIdle => _recordStep == RecordSlotState.Idle;
|
||||
public bool IsRecordArmed => _recordStep == RecordSlotState.Armed;
|
||||
public bool IsRecordRecording => _recordStep == RecordSlotState.Recording;
|
||||
public string RecordElapsedLabel => $"REC: {LiveElapsedText}";
|
||||
|
||||
public ICommand SelectBuildActivityCommand { get; private set; } = null!;
|
||||
public ICommand SelectYouTubeActivityCommand { get; private set; } = null!;
|
||||
public ICommand SelectRecordActivityCommand { get; private set; } = null!;
|
||||
public ICommand ChooseLiveModeCommand { get; private set; } = null!;
|
||||
public ICommand ChoosePrivateModeCommand { get; private set; } = null!;
|
||||
public ICommand StartChosenStreamCommand { get; private set; } = null!;
|
||||
public ICommand SelectBuildCommand { get; private set; } = null!;
|
||||
public ICommand OpenYouTubeCommand { get; private set; } = null!;
|
||||
public ICommand ChooseLiveCommand { get; private set; } = null!;
|
||||
public ICommand ChoosePrivateCommand { get; private set; } = null!;
|
||||
public ICommand CancelYouTubeCommand { get; private set; } = null!;
|
||||
public ICommand StartYouTubeCommand { get; private set; } = null!;
|
||||
public ICommand StopYouTubeCommand { get; private set; } = null!;
|
||||
|
||||
public ICommand OpenRecordCommand { get; private set; } = null!;
|
||||
public ICommand StartRecordingCommand { get; private set; } = null!;
|
||||
public ICommand CancelRecordCommand { get; private set; } = null!;
|
||||
public ICommand StopRecordingCommand { get; private set; } = null!;
|
||||
|
||||
private void InitializeActivityCommands()
|
||||
{
|
||||
SelectBuildActivityCommand = new RelayCommand(_ => SelectActivity(TopActivity.Build));
|
||||
SelectYouTubeActivityCommand = new RelayCommand(_ => SelectActivity(TopActivity.YouTube));
|
||||
SelectRecordActivityCommand = new RelayCommand(_ => SelectActivity(TopActivity.Record));
|
||||
SelectBuildCommand = new RelayCommand(_ => RaiseSlotStates(), _ => IsBuildScenesEnabled);
|
||||
OpenYouTubeCommand = new RelayCommand(_ => SetYouTubeStep(YouTubeSlotState.ChoosePrivacy));
|
||||
ChooseLiveCommand = new RelayCommand(_ => { _podcastLive = false; SetYouTubeStep(YouTubeSlotState.Armed); });
|
||||
ChoosePrivateCommand = new RelayCommand(_ => { _podcastLive = true; SetYouTubeStep(YouTubeSlotState.Armed); });
|
||||
CancelYouTubeCommand = new RelayCommand(_ => SetYouTubeStep(YouTubeSlotState.Idle));
|
||||
|
||||
ChooseLiveModeCommand = new RelayCommand(_ => SetStreamMode(StreamMode.Live), _ => ShowYouTubeModeChoices);
|
||||
ChoosePrivateModeCommand = new RelayCommand(_ => SetStreamMode(StreamMode.Private), _ => ShowYouTubeModeChoices);
|
||||
|
||||
// Start is its own press: the last chance to say "you're about to be live".
|
||||
// Signed out still routes to sign-in rather than sitting dead (no-dead-ends rule).
|
||||
StartChosenStreamCommand = new RelayCommand(_ =>
|
||||
StartYouTubeCommand = new RelayCommand(_ =>
|
||||
{
|
||||
if (!IsConnected) { _ = SignInAsync(); return; }
|
||||
if (_streamMode == StreamMode.Private) BeginTestStream();
|
||||
else StartSession();
|
||||
}, _ => ShowYouTubeStart);
|
||||
if (_podcastLive) BeginTestStream(); else StartSession();
|
||||
SetYouTubeStep(YouTubeSlotState.Streaming);
|
||||
});
|
||||
|
||||
StopYouTubeCommand = new RelayCommand(_ =>
|
||||
{
|
||||
StopStream();
|
||||
SetYouTubeStep(YouTubeSlotState.Idle);
|
||||
});
|
||||
|
||||
OpenRecordCommand = new RelayCommand(_ => SetRecordStep(RecordSlotState.Armed));
|
||||
StartRecordingCommand = new RelayCommand(_ =>
|
||||
{
|
||||
BeginRecordOnly();
|
||||
SetRecordStep(RecordSlotState.Recording);
|
||||
});
|
||||
CancelRecordCommand = new RelayCommand(_ => SetRecordStep(RecordSlotState.Idle));
|
||||
StopRecordingCommand = new RelayCommand(_ =>
|
||||
{
|
||||
StopStream();
|
||||
SetRecordStep(RecordSlotState.Idle);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Switches activity. Build is the only one that edits; the other two take the layers away
|
||||
/// but leave the button lit, so the creator can see where they are and step back.</summary>
|
||||
internal void SelectActivity(TopActivity activity)
|
||||
private void SetYouTubeStep(YouTubeSlotState step)
|
||||
{
|
||||
_activity = activity;
|
||||
OnPropertyChanged(nameof(Activity));
|
||||
OnPropertyChanged(nameof(IsBuildActivity));
|
||||
OnPropertyChanged(nameof(IsYouTubeActivity));
|
||||
OnPropertyChanged(nameof(IsRecordActivity));
|
||||
|
||||
EditingActive = activity == TopActivity.Build;
|
||||
|
||||
// The underlying outputs follow the activity, so the existing start commands and the
|
||||
// record/live paths agree with what the bar is showing.
|
||||
RecordPillOn = activity == TopActivity.Record;
|
||||
OnAirPillOn = activity == TopActivity.YouTube;
|
||||
_streamMode = StreamMode.None; // the YouTube flow always restarts at the choice
|
||||
|
||||
RaiseActivityStage();
|
||||
_youTubeStep = step;
|
||||
RaiseSlotStates();
|
||||
}
|
||||
|
||||
private void SetStreamMode(StreamMode mode)
|
||||
private void SetRecordStep(RecordSlotState step)
|
||||
{
|
||||
_streamMode = mode;
|
||||
OnPropertyChanged(nameof(IsLiveModeChosen));
|
||||
OnPropertyChanged(nameof(IsPrivateModeChosen));
|
||||
RaiseActivityStage();
|
||||
_recordStep = step;
|
||||
RaiseSlotStates();
|
||||
}
|
||||
|
||||
internal void RaiseActivityStage()
|
||||
/// <summary>One place that announces everything the three slots show, so no caller can forget a flag.
|
||||
/// </summary>
|
||||
internal void RaiseSlotStates()
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowYouTubeModeChoices));
|
||||
OnPropertyChanged(nameof(ShowYouTubeStart));
|
||||
OnPropertyChanged(nameof(ShowYouTubeStop));
|
||||
OnPropertyChanged(nameof(ShowRecordStart));
|
||||
OnPropertyChanged(nameof(ShowRecordStop));
|
||||
OnPropertyChanged(nameof(IsBuildScenesEnabled));
|
||||
OnPropertyChanged(nameof(BuildScenesTooltip));
|
||||
|
||||
OnPropertyChanged(nameof(YouTubeStep));
|
||||
OnPropertyChanged(nameof(IsYouTubeIdle));
|
||||
OnPropertyChanged(nameof(IsYouTubeChoosingPrivacy));
|
||||
OnPropertyChanged(nameof(IsYouTubeArmed));
|
||||
OnPropertyChanged(nameof(IsYouTubeStreaming));
|
||||
OnPropertyChanged(nameof(IsLiveChosen));
|
||||
OnPropertyChanged(nameof(IsPrivateChosen));
|
||||
OnPropertyChanged(nameof(YouTubeArmLabel));
|
||||
OnPropertyChanged(nameof(YouTubeStopLabel));
|
||||
|
||||
OnPropertyChanged(nameof(RecordStep));
|
||||
OnPropertyChanged(nameof(IsRecordIdle));
|
||||
OnPropertyChanged(nameof(IsRecordArmed));
|
||||
OnPropertyChanged(nameof(IsRecordRecording));
|
||||
OnPropertyChanged(nameof(RecordElapsedLabel));
|
||||
|
||||
// Build Scenes is the edit surface, so the lock has to reach editing too.
|
||||
EditingActive = IsBuildScenesEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user