From d46e84562f9008c5174b0179d94460565192ed8b Mon Sep 17 00:00:00 2001 From: gramps Date: Thu, 12 Nov 2026 14:45:58 -0800 Subject: [PATCH] feat(settings): editing during a session is the creator's call (default: allowed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creator ruling 2026-11-12: "we don't care if a creator wants to edit their scene: move stuff around, shrink/grow a webcam view, etc. Why not?" Correct — the app was only ever *forcing* EDIT off at go-live, never enforcing it, so the gun-safety was a habit rather than a rule. App.LockEditWhileLive ships off: a show no longer takes EDIT away. Turning it on restores the old behaviour exactly — go-live flips EDIT off and stop flips it back, and only because the lock took it, so a creator who had already turned EDIT off does not get it handed back on stop. MainViewModel.ApplyEditLockForSession holds the rule so it is testable without starting a broadcast; EditWhileLiveTests covers both settings, the round trip, and the "don't hand it back" case. Settings → Editing carries the checkbox. This is the first slice of TASK 68 (editing while live). 678 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art, still awaiting their visual check. --- Controls/SettingsDrawer.xaml | 7 ++ Services/LayoutStore.Settings.cs | 8 +++ ViewModels/MainViewModel.Settings.cs | 39 +++++++++++ .../MainViewModel.Streaming.Operations.cs | 4 +- ViewModels/MainViewModel.cs | 1 + docs/ai/ui-surface.md | 5 ++ ytLive.Tests/EditWhileLiveTests.cs | 64 +++++++++++++++++++ 7 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 ytLive.Tests/EditWhileLiveTests.cs diff --git a/Controls/SettingsDrawer.xaml b/Controls/SettingsDrawer.xaml index 70dd245..f32e8cb 100644 --- a/Controls/SettingsDrawer.xaml +++ b/Controls/SettingsDrawer.xaml @@ -143,6 +143,13 @@ + + + + diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs index 622239c..25d9a2e 100644 --- a/Services/LayoutStore.Settings.cs +++ b/Services/LayoutStore.Settings.cs @@ -20,6 +20,14 @@ public partial class LayoutStore : IDisposable UpsertSetting("Notifications.Position", position.ToString()); } + /// May editing be used while a session runs? Off by default: the creator's ruling + /// (2026-11-12) is that moving a layer or resizing a webcam mid-show is theirs to do. Turning it on + /// restores the old gun-safety — go-live flips EDIT off and stop flips it back. + public bool LoadLockEditWhileLive() => GetSetting("App.LockEditWhileLive") == "1"; + + public void SaveLockEditWhileLive(bool locked) + => UpsertSetting("App.LockEditWhileLive", locked ? "1" : "0"); + public bool LoadStickyAlertsUntilDismissed() => GetSetting("Notifications.StickyUntilDismissed") == "1"; diff --git a/ViewModels/MainViewModel.Settings.cs b/ViewModels/MainViewModel.Settings.cs index e6a370c..10065de 100644 --- a/ViewModels/MainViewModel.Settings.cs +++ b/ViewModels/MainViewModel.Settings.cs @@ -15,6 +15,8 @@ public partial class MainViewModel private bool _settingsDrawerOpen; private NotificationPosition _toastPosition = NotificationPosition.BottomRight; private bool _stickyAlertsUntilDismissed; + private bool _lockEditWhileLive; + private bool _editingForcedOff; public bool StickyAlertsUntilDismissed { @@ -26,6 +28,43 @@ public partial class MainViewModel } } + /// Lock editing for the duration of a session. Off = edit while live (creator ruling + /// 2026-11-12); on = go-live flips EDIT off and stop flips it back, as it always did. + public bool LockEditWhileLive + { + get => _lockEditWhileLive; + set + { + if (!SetProperty(ref _lockEditWhileLive, value)) return; + _layoutStore.SaveLockEditWhileLive(value); + } + } + + /// + /// Applies the edit lock when a session starts or stops. Split out so the rule is testable without + /// starting a real broadcast. + /// + internal void ApplyEditLockForSession(bool starting) + { + if (!LockEditWhileLive) + { + // The creator may edit during the show; nothing is forced either way. + _editingForcedOff = false; + return; + } + + if (starting) + { + _editingForcedOff = true; + EditingActive = false; + } + else if (_editingForcedOff) + { + _editingForcedOff = false; + EditingActive = true; + } + } + public NotificationPosition ToastPosition { get => _toastPosition; diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index 461211a..e823742 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -298,7 +298,7 @@ public partial class MainViewModel : ViewModelBase // before that (2026-09-25 diagnosis), so chat resolution runs AFTER the pump // starts. Chat is non-fatal — if it fails, the stream continues without chat. LiveScene = StagedScene; - EditingActive = false; + ApplyEditLockForSession(starting: true); await _framePump.StartAsync(); // never throws; failures log + surface via Failed // Check promptly until YouTube confirms the broadcast is live. Health @@ -424,7 +424,7 @@ public partial class MainViewModel : ViewModelBase _sessionStartedAtUtc = null; IsRecording = false; LiveScene = null; - EditingActive = true; + ApplyEditLockForSession(starting: false); // TASK 41: a stopped test broadcast forgets its drawer session (idempotent) // and drops the TEST face — a test never outlives its run. TestSession.EndTestSilently(); diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 14b719f..459e26e 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -347,6 +347,7 @@ public partial class MainViewModel : ViewModelBase _activeLayoutPath = _layoutStore.ActivePath; _toastPosition = _layoutStore.LoadNotificationPosition(); _stickyAlertsUntilDismissed = _layoutStore.LoadStickyAlertsUntilDismissed(); + _lockEditWhileLive = _layoutStore.LoadLockEditWhileLive(); _recordFolder = _layoutStore.LoadRecordFolder(); // What the last few sessions on this tier actually held, if it is worth asking about. diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md index ad9adbe..125f590 100644 --- a/docs/ai/ui-surface.md +++ b/docs/ai/ui-surface.md @@ -145,6 +145,11 @@ Placement math itself is covered by `WindowPlacementTests` (pure function, no HW `FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard: `ytLive.Tests/GeometryFieldWidthTests.cs` - Resolution tiers: 1080p60@12 (default) → 1080p30@10 → 720p60@6 → 720p30@6 → **Vertical 1080p30@10 and 1080p60@12 (9:16, 1080×1920)** — the `@` is the CBR target the encoder is asked to hold, between YouTube's H.264 recommendation and the practitioner sweet spot (its 14/17/8 recommendations are the max, the tier minimums 5/6/3 the step-down floor). Vertical mirrors its landscape pair: same pixel count, same rate. The old flat 8 Mbps was an inherited round number, not a measurement. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; vertical tiers use a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. The resolution picker and live readouts (bitrate/FPS/dropped/duration) are in the top-left cell above the layers panel; the bottom bar is an empty height-preserving spacer. Under the picker sits the creator's own **rate box** (Mbps, per tier, persisted as `Video.Rate.`): the tier chooses geometry, the box chooses bits, clamped at the tier's YouTube minimum and otherwise unbounded because live step-down is the thing that catches a wrong guess. Its tooltip carries YouTube's recommendation for the tier (`Models/RateLadder`). Every consumer — encoder options, step-down policy, clip buffer — reads `MainViewModel.EffectiveQuality`, never the raw tier. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height +- **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 + live. On restores the old gun-safety — go-live flips EDIT off, stop flips it back, and only because + the lock took it away (`MainViewModel.ApplyEditLockForSession`, `EditWhileLiveTests`). Settings → + Editing carries the checkbox. - **OPEN (creator, 2026-11-12): can a locked scene still be changed through a layer's visibility eye?** Reported as "the creator can still use the layer icon — visibility — if it was already enabled for that layer". Not reproduced from the obvious paths: with EDIT off the left panel swaps the layer list diff --git a/ytLive.Tests/EditWhileLiveTests.cs b/ytLive.Tests/EditWhileLiveTests.cs new file mode 100644 index 0000000..f47a892 --- /dev/null +++ b/ytLive.Tests/EditWhileLiveTests.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; +using Xunit; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// Editing during a session is the creator's call (ruling 2026-11-12: moving a layer or resizing a +/// webcam mid-show is theirs to do). The setting decides whether go-live still flips EDIT off. +/// +[Collection("MainViewModelLayoutPath")] +public class EditWhileLiveTests +{ + private static MainViewModel NewVm(string db, out Action cleanup) + { + MainViewModel.LayoutPathOverride = db; + cleanup = () => + { + MainViewModel.LayoutPathOverride = null; + try { File.Delete(db); } catch (IOException) { } + }; + return new MainViewModel(); + } + + [Fact] + public void Editing_stays_available_during_a_session_unless_the_creator_locked_it() + { + var db = Path.Combine(Path.GetTempPath(), $"llamacasty-editlock-{Guid.NewGuid():N}.db"); + var vm = NewVm(db, out var cleanup); + try + { + Assert.False(vm.LockEditWhileLive); // unlocked ships as the default + Assert.True(vm.IsEditMode); + + // Unlocked: a session neither takes EDIT away nor gives it back. + vm.EditingActive = false; + vm.ApplyEditLockForSession(starting: true); + Assert.False(vm.EditingActive); + vm.EditingActive = true; + vm.ApplyEditLockForSession(starting: true); + Assert.True(vm.EditingActive); + + // Locked: go-live turns EDIT off… + vm.LockEditWhileLive = true; + vm.ApplyEditLockForSession(starting: true); + Assert.False(vm.EditingActive); + Assert.False(vm.IsEditMode); + + // …and stopping restores it, but only because the lock took it away. + vm.ApplyEditLockForSession(starting: false); + Assert.True(vm.EditingActive); + Assert.True(vm.IsEditMode); + + // The choice survives a restart: a second view model on the same profile reads it back. + var reloaded = new MainViewModel(); + Assert.True(reloaded.LockEditWhileLive); + } + finally + { + cleanup(); + } + } +}