diff --git a/Controls/TopBar.xaml b/Controls/TopBar.xaml index 471360c..8a35538 100644 --- a/Controls/TopBar.xaml +++ b/Controls/TopBar.xaml @@ -66,141 +66,122 @@ question "which world am I in?" and "can I edit?" cannot disagree. Clicking the highlighted half re-enters it, which is how a creator gets back to the layers during a session. --> - + - - - - - - - - - - - - - - - + + + + + + + - - - - - - - + + - - + + + + + + + + + + + + + + + - - + 0.5 ? 0.2 : 1.0; OnPropertyChanged(nameof(RecDotOpacity)); - OnPropertyChanged(nameof(RecordChipText)); + OnPropertyChanged(nameof(RecordActionLabel)); // TASK 55·3: the last useful warning before the encoder fails for a reason it won't explain. if (IsRecording && !_lowDiskWarned && _recordFolder is { } folder diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs index 699558d..37c22cc 100644 --- a/ViewModels/MainViewModel.Streaming.cs +++ b/ViewModels/MainViewModel.Streaming.cs @@ -87,7 +87,7 @@ public partial class MainViewModel : ViewModelBase OnPropertyChanged(nameof(ShowChatInactiveMessage)); OnPropertyChanged(nameof(ShowPreviewPlaceholder)); RaiseTopBarModes(); - RaiseOutputStage(); // IsLive drives ShowOutputStage + RaiseActivityStage(); // 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 - RaiseOutputStage(); + RaiseActivityStage(); 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 - RaiseOutputStage(); + RaiseActivityStage(); 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(); - RaiseOutputStage(); // Start Recording <-> Stop Recording, and the REC clock + RaiseActivityStage(); // Start Recording <-> Stop Recording, and the REC clock UpdateSessionTimer(); } } diff --git a/ViewModels/MainViewModel.TopBar.cs b/ViewModels/MainViewModel.TopBar.cs index c729eae..e82d9b7 100644 --- a/ViewModels/MainViewModel.TopBar.cs +++ b/ViewModels/MainViewModel.TopBar.cs @@ -3,123 +3,115 @@ using ytLive.Helpers; namespace ytLive.ViewModels; -/// Which half of the top bar's dual segment is in force. -public enum BarWorld +/// What the app is doing (creator, 2026-11-13): build, record, or stream. Three activities, +/// three buttons, always visible. +/// Which YouTube stream is armed. Choosing does not start it — Start does. +public enum StreamMode { - /// Build Scenes — the edit surface. The default at launch (creator ruling 2026-11-12). - Build, - - /// Record / Go Live — the output surface: which output, and the one action that starts it. - Output, + None, + Live, + Private, +} + +public enum TopActivity +{ + Build, + YouTube, + Record, } -/// -/// The top bar's one decision (creator ruling 2026-11-12): [ Build Scenes ][ Record / Go Live ]. -/// Build Scenes is edit mode — there is no separate EDIT toggle any more, so "may I edit?" and -/// "which world am I in?" cannot disagree. -/// -/// Clicking the highlighted segment is not a no-op: it re-enters that world, which matters while a -/// session runs because Build Scenes is the only way back to the layers (see -/// ). -/// public partial class MainViewModel { - private BarWorld _barWorld = BarWorld.Build; + private TopActivity _activity = TopActivity.Build; - public BarWorld BarWorld => _barWorld; + public TopActivity Activity => _activity; - public bool IsBuildWorld => _barWorld == BarWorld.Build; - public bool IsOutputWorld => _barWorld == BarWorld.Output; + public bool IsBuildActivity => _activity == TopActivity.Build; + public bool IsYouTubeActivity => _activity == TopActivity.YouTube; + public bool IsRecordActivity => _activity == TopActivity.Record; - /// The output stage belongs to the Record / Go Live world, and only while nothing is - /// running — a live session cannot change its output, so its switch is not offered. - // A recording KEEPS the stage visible: the pill carries REC, the elapsed time and the stop. A live - // stream does not — its reality line takes over (see RunningVisible). - public bool ShowOutputStage => IsOutputWorld && !IsLive; + // ─── Each activity's own flow ─── + // Build: nothing to start. YouTube: Live or Private, then Stop. Record: Start, then Stop. - // ─── The output stage's three states (creator, builds 728/729) ─── - // 1. nothing chosen: [YouTube] [Local MP4] - // 2. YouTube chosen: [YouTube(red)] [Live Stream] [Private Stream] (Private = the test run) - // 3. Local MP4 chosen: [Local MP4(red)] [Start Recording] -> [End Recording] while recording - public bool ShowOutputChoice => ShowOutputStage && !RecordPillOn && !OnAirPillOn; - public bool ShowYouTubeSelected => ShowOutputStage && OnAirPillOn; - public bool ShowLocalMp4Selected => ShowOutputStage && RecordPillOn; - /// The YouTube chip shows while nothing is chosen and while YouTube IS the choice; once - /// Local MP4 is chosen the MP4 chip replaces it in the left slot (creator, build 729). - public bool YouTubeChipVisible => ShowOutputChoice || ShowYouTubeSelected; - public bool LocalMp4ChipVisible => ShowOutputChoice || ShowLocalMp4Selected; + // 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; - /// The left chip reads "Local MP4" while idle and "REC: hh:mm:ss" while recording, so the - /// tabular figure cannot jitter the pill's width. - public string RecordChipText => IsRecording ? $"REC: {LiveElapsedText}" : "Local MP4"; + public bool IsLiveModeChosen => _streamMode == StreamMode.Live; + public bool IsPrivateModeChosen => _streamMode == StreamMode.Private; - public bool ShowStartRecordingAction => ShowLocalMp4Selected && !IsRecording; - public bool ShowEndRecordingAction => ShowLocalMp4Selected && IsRecording; + 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; - public ICommand SelectYouTubeCommand { get; private set; } = null!; - public ICommand SelectLocalMp4Command { get; private set; } = null!; - public ICommand StartRecordingCommand { get; private set; } = null!; - public ICommand TestStreamCommand { get; private set; } = null!; - public ICommand GoLiveCommand { get; private set; } = null!; + /// The Record activity's own words: its name while idle, its action while chosen. + public string RecordActionLabel => IsRecording ? "Stop Recording" : "Start Recording"; + public string RecordElapsedLabel => $"REC: {LiveElapsedText}"; - public ICommand EnterBuildWorldCommand { get; private set; } = null!; - public ICommand EnterOutputWorldCommand { get; private set; } = null!; + 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!; - private void InitializeWorldCommands() + private void InitializeActivityCommands() { - SelectYouTubeCommand = new RelayCommand(_ => OnAirPillOn = true, _ => ShowOutputChoice); - SelectLocalMp4Command = new RelayCommand(_ => RecordPillOn = true, _ => ShowOutputChoice); - 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(_ => + SelectBuildActivityCommand = new RelayCommand(_ => SelectActivity(TopActivity.Build)); + SelectYouTubeActivityCommand = new RelayCommand(_ => SelectActivity(TopActivity.YouTube)); + SelectRecordActivityCommand = new RelayCommand(_ => SelectActivity(TopActivity.Record)); + + 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(_ => { if (!IsConnected) { _ = SignInAsync(); return; } - BeginTestStream(); - }, _ => ShowYouTubeSelected); - GoLiveCommand = new RelayCommand(_ => - { - if (!IsConnected) { _ = SignInAsync(); return; } - StartSession(); - }, _ => ShowYouTubeSelected); - - EnterBuildWorldCommand = new RelayCommand(_ => EnterWorld(BarWorld.Build)); - EnterOutputWorldCommand = new RelayCommand(_ => EnterWorld(BarWorld.Output)); + if (_streamMode == StreamMode.Private) BeginTestStream(); + else StartSession(); + }, _ => ShowYouTubeStart); } - /// Everything the output stage shows or hides, in one place so no caller can forget - /// one of them. - internal void RaiseOutputStage() + /// 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. + internal void SelectActivity(TopActivity activity) { - OnPropertyChanged(nameof(ShowOutputStage)); - OnPropertyChanged(nameof(ShowOutputChoice)); - OnPropertyChanged(nameof(ShowYouTubeSelected)); - OnPropertyChanged(nameof(YouTubeChipVisible)); - OnPropertyChanged(nameof(LocalMp4ChipVisible)); - OnPropertyChanged(nameof(ShowLocalMp4Selected)); - OnPropertyChanged(nameof(ShowStartRecordingAction)); - OnPropertyChanged(nameof(ShowEndRecordingAction)); - OnPropertyChanged(nameof(RecordChipText)); + _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(); } - /// Switches worlds. Re-entering the current one still re-asserts its edit state, which is - /// what lets a creator take EDIT back mid-show by clicking Build Scenes. - internal void EnterWorld(BarWorld world) + private void SetStreamMode(StreamMode mode) { - _barWorld = world; + _streamMode = mode; + OnPropertyChanged(nameof(IsLiveModeChosen)); + OnPropertyChanged(nameof(IsPrivateModeChosen)); + RaiseActivityStage(); + } - // Entering a world starts with NOTHING chosen inside it (creator, build 729): the output - // switches are a fresh decision each time, not a remembered one. - if (world == BarWorld.Output) - { - RecordPillOn = false; - OnAirPillOn = false; - } - - OnPropertyChanged(nameof(BarWorld)); - OnPropertyChanged(nameof(IsBuildWorld)); - OnPropertyChanged(nameof(IsOutputWorld)); - EditingActive = world == BarWorld.Build; - OnPropertyChanged(nameof(ShowOutputStage)); - RaiseOutputStage(); + internal void RaiseActivityStage() + { + OnPropertyChanged(nameof(ShowYouTubeModeChoices)); + OnPropertyChanged(nameof(ShowYouTubeStart)); + OnPropertyChanged(nameof(ShowYouTubeStop)); + OnPropertyChanged(nameof(ShowRecordStart)); + OnPropertyChanged(nameof(ShowRecordStop)); } } diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index f53d538..f7a810d 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -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() diff --git a/ytLive.Tests/TopBarActivityTests.cs b/ytLive.Tests/TopBarActivityTests.cs new file mode 100644 index 0000000..cad59d9 --- /dev/null +++ b/ytLive.Tests/TopBarActivityTests.cs @@ -0,0 +1,80 @@ +using System; +using System.IO; +using Xunit; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// Three activities, always visible (creator, 2026-11-13): build, stream, record. The active one is lit +/// and runs its own flow in place; the other two stay on the bar and dim, so there is no state where the +/// creator cannot see where they are or how to step back. +/// +[Collection("MainViewModelLayoutPath")] +public class TopBarActivityTests +{ + private static MainViewModel NewVm(string db) + { + MainViewModel.LayoutPathOverride = db; + return new MainViewModel(); + } + + [Fact] + public void Three_activities_stay_on_the_bar_and_the_active_one_owns_its_flow() + { + var db = Path.Combine(Path.GetTempPath(), $"llamacasty-activity-{Guid.NewGuid():N}.db"); + try + { + var vm = NewVm(db); + + // Launch: Build is the activity and the only one editing. + Assert.Equal(TopActivity.Build, vm.Activity); + Assert.True(vm.IsBuildActivity); + Assert.True(vm.IsEditMode); + Assert.False(vm.IsRecording); + + // Record: its own flow becomes Start, and editing steps aside. + vm.SelectRecordActivityCommand.Execute(null); + Assert.True(vm.IsRecordActivity); + Assert.False(vm.IsEditMode); + Assert.True(vm.ShowRecordStart); + Assert.False(vm.ShowRecordStop); + Assert.Equal("Start Recording", vm.RecordActionLabel); + + // Recording swaps the flow to Stop, and the elapsed label carries REC. + vm.IsRecording = true; + Assert.False(vm.ShowRecordStart); + Assert.True(vm.ShowRecordStop); + Assert.Equal("Stop Recording", vm.RecordActionLabel); + Assert.StartsWith("REC: ", vm.RecordElapsedLabel); + vm.IsRecording = false; + + // YouTube: choosing Live only ARMS it. Start is a second press — the "are you ready?" moment. + vm.SelectYouTubeActivityCommand.Execute(null); + Assert.True(vm.IsYouTubeActivity); + Assert.True(vm.ShowYouTubeModeChoices); + Assert.False(vm.ShowYouTubeStart); + + vm.ChooseLiveModeCommand.Execute(null); + Assert.True(vm.IsLiveModeChosen); + Assert.False(vm.ShowYouTubeModeChoices); + Assert.True(vm.ShowYouTubeStart); + + vm.ChoosePrivateModeCommand.Execute(null); + Assert.True(vm.IsPrivateModeChosen); + Assert.False(vm.IsLiveModeChosen); + + // Back to Build: editing returns, and the YouTube choice is forgotten, not carried. + vm.SelectBuildActivityCommand.Execute(null); + Assert.True(vm.IsBuildActivity); + Assert.True(vm.IsEditMode); + Assert.False(vm.ShowYouTubeModeChoices); + Assert.False(vm.ShowYouTubeStart); + } + finally + { + MainViewModel.LayoutPathOverride = null; + try { File.Delete(db); } catch (IOException) { } + } + } +} diff --git a/ytLive.Tests/TopBarModeTests.cs b/ytLive.Tests/TopBarModeTests.cs index a0e8b5f..d8d04d6 100644 --- a/ytLive.Tests/TopBarModeTests.cs +++ b/ytLive.Tests/TopBarModeTests.cs @@ -84,22 +84,21 @@ public sealed class TopBarModeTests Assert.False(vm.ShowTestButton); Assert.Equal("Start Recording", vm.PrimaryStartButtonLabel); - // A local recording STAYS in the output world (build 730): the pill carries REC, the - // clock and the stop, so the separate reality line stays away — it would be a second set - // of the same facts. - vm.EnterOutputWorldCommand.Execute(null); - vm.RecordPillOn = true; - vm.OnAirPillOn = false; + // A local recording lives in the Record activity (2026-11-13): the bar keeps its three + // buttons and the Record one carries REC + the clock + Stop, so the separate reality line + // stays away — it would be a second set of the same facts. + vm.SelectRecordActivityCommand.Execute(null); vm.IsRecording = true; - Assert.True(vm.ShowOutputStage); + Assert.True(vm.IsRecordActivity); + Assert.True(vm.ShowRecordStop); Assert.False(vm.RunningVisible); - Assert.StartsWith("REC: ", vm.RecordChipText); + Assert.StartsWith("REC: ", vm.RecordElapsedLabel); vm.IsRecording = false; - // A live stream retires the stage instead; the reality line pins the mode. + // A live stream is the YouTube activity's flow; the reality line pins the mode. + vm.SelectYouTubeActivityCommand.Execute(null); vm.StreamStatus = StreamStatus.Streaming; - Assert.True(vm.RunningVisible); - Assert.False(vm.ShowOutputStage); + Assert.True(vm.ShowYouTubeStop); Assert.Equal("LIVE", vm.RunningText); Assert.Equal("#ff5451", vm.RunningDotBrush); vm.IsTestStream = true; diff --git a/ytLive.Tests/UiShellTests.cs b/ytLive.Tests/UiShellTests.cs index 7cb4ddd..ff9662f 100644 --- a/ytLive.Tests/UiShellTests.cs +++ b/ytLive.Tests/UiShellTests.cs @@ -29,10 +29,11 @@ public class UiShellTests // 2. The world segment replaced the EDIT toggle (creator ruling 2026-11-12): both halves are // present, each lit by its own world flag through the shared style. - Assert.Contains("Text=\"Build scenes\"", topBar); - Assert.Contains("Text=\"Record / Go-Live\"", topBar); - Assert.Contains("Tag=\"{Binding IsBuildWorld}\"", topBar); - Assert.Contains("Tag=\"{Binding IsOutputWorld}\"", topBar); + Assert.Contains("Text=\"Build Scenes\"", topBar); + Assert.Contains("Text=\"YouTube\"", topBar); + Assert.Contains("Text=\"Record\"", topBar); + Assert.Contains("Tag=\"{Binding IsBuildActivity}\"", topBar); + Assert.Contains("Tag=\"{Binding IsYouTubeActivity}\"", topBar); Assert.DoesNotContain("x:Name=\"EditBody\"", topBar); // 2b. The segment is Build-world only: choosing Record / Go Live REPLACES it with the output @@ -42,18 +43,16 @@ public class UiShellTests Assert.Contains("Brush.SegmentTrack", topBar); Assert.Contains("Style=\"{StaticResource SegmentHalf}\"", topBar); Assert.Contains("Style=\"{StaticResource SegmentIcon}\"", topBar); - Assert.Contains("Visibility=\"{Binding IsBuildWorld, Converter={StaticResource BoolToVis}}\"", topBar); - Assert.Contains("Text=\"Output:\"", topBar); - Assert.Contains("Text=\"<esc> to return\"", topBar); + + + Assert.DoesNotContain("ToolTip=\"Back to Build Scenes\"", topBar); // Esc replaced the chevron // 2c. The output stage: two labelled switches and the exclusivity promise beside them. - Assert.Contains("Text=\"Output:\"", topBar); + Assert.Contains("Text=\"YouTube\"", topBar); - Assert.Contains("Text=\"Local MP4\"", topBar); - Assert.Contains("Text=\"One output at a time\"", topBar); - Assert.Contains("Command=\"{Binding SelectYouTubeCommand}\"", topBar); - Assert.Contains("Command=\"{Binding SelectLocalMp4Command}\"", topBar); + Assert.Contains("Command=\"{Binding SelectYouTubeActivityCommand}\"", topBar); + Assert.Contains("Command=\"{Binding SelectRecordActivityCommand}\"", topBar); // 2d. The bar carries NO action button: the output line is the bar's whole middle // (creator ruling 2026-11-12 — the action is placed elsewhere, on instruction). @@ -120,9 +119,9 @@ public class UiShellTests // The world segment leads the centre cluster; the brand column carries no edit control. var centre = topBar.IndexOf(" 0 && segment > centre, - "the [ Build Scenes ][ Record / Go Live ] segment must lead the centre cluster"); + "the three-activity segment must lead the centre cluster"); Assert.DoesNotContain("x:Name=\"EditBody\"", topBar); }