feat(settings): editing during a session is the creator's call (default: allowed)
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.
This commit is contained in:
@@ -143,6 +143,13 @@
|
|||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
<!-- ═══ LAYOUT DEFAULTS (TASK 37) ═══ -->
|
<!-- ═══ LAYOUT DEFAULTS (TASK 37) ═══ -->
|
||||||
|
<TextBlock Text="Editing" Style="{StaticResource SectionHeader}" Margin="0,10,0,4"/>
|
||||||
|
<CheckBox IsChecked="{Binding LockEditWhileLive, Mode=TwoWay}" Margin="10,0,0,2"
|
||||||
|
Style="{StaticResource PropertyPillCheck}"
|
||||||
|
Content="Lock editing while recording or live"/>
|
||||||
|
<TextBlock Text="Off: layers can be moved or resized during a show. On: going live turns EDIT off and stopping turns it back on."
|
||||||
|
Style="{StaticResource PropertyHint}" Margin="28,0,0,10"/>
|
||||||
|
|
||||||
<TextBlock Text="Layout defaults" Style="{StaticResource SectionHeader}" Margin="0,10,0,4"/>
|
<TextBlock Text="Layout defaults" Style="{StaticResource SectionHeader}" Margin="0,10,0,4"/>
|
||||||
<TextBlock Text="Replaces every scene and layer with this build’s factory layout. Your selected webcam stays the same."
|
<TextBlock Text="Replaces every scene and layer with this build’s factory layout. Your selected webcam stays the same."
|
||||||
Style="{StaticResource PropertyHint}" Margin="10,0,0,6"/>
|
Style="{StaticResource PropertyHint}" Margin="10,0,0,6"/>
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ public partial class LayoutStore : IDisposable
|
|||||||
UpsertSetting("Notifications.Position", position.ToString());
|
UpsertSetting("Notifications.Position", position.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public bool LoadLockEditWhileLive() => GetSetting("App.LockEditWhileLive") == "1";
|
||||||
|
|
||||||
|
public void SaveLockEditWhileLive(bool locked)
|
||||||
|
=> UpsertSetting("App.LockEditWhileLive", locked ? "1" : "0");
|
||||||
|
|
||||||
public bool LoadStickyAlertsUntilDismissed()
|
public bool LoadStickyAlertsUntilDismissed()
|
||||||
=> GetSetting("Notifications.StickyUntilDismissed") == "1";
|
=> GetSetting("Notifications.StickyUntilDismissed") == "1";
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ public partial class MainViewModel
|
|||||||
private bool _settingsDrawerOpen;
|
private bool _settingsDrawerOpen;
|
||||||
private NotificationPosition _toastPosition = NotificationPosition.BottomRight;
|
private NotificationPosition _toastPosition = NotificationPosition.BottomRight;
|
||||||
private bool _stickyAlertsUntilDismissed;
|
private bool _stickyAlertsUntilDismissed;
|
||||||
|
private bool _lockEditWhileLive;
|
||||||
|
private bool _editingForcedOff;
|
||||||
|
|
||||||
public bool StickyAlertsUntilDismissed
|
public bool StickyAlertsUntilDismissed
|
||||||
{
|
{
|
||||||
@@ -26,6 +28,43 @@ public partial class MainViewModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public bool LockEditWhileLive
|
||||||
|
{
|
||||||
|
get => _lockEditWhileLive;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (!SetProperty(ref _lockEditWhileLive, value)) return;
|
||||||
|
_layoutStore.SaveLockEditWhileLive(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the edit lock when a session starts or stops. Split out so the rule is testable without
|
||||||
|
/// starting a real broadcast.
|
||||||
|
/// </summary>
|
||||||
|
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
|
public NotificationPosition ToastPosition
|
||||||
{
|
{
|
||||||
get => _toastPosition;
|
get => _toastPosition;
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
// before that (2026-09-25 diagnosis), so chat resolution runs AFTER the pump
|
// 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.
|
// starts. Chat is non-fatal — if it fails, the stream continues without chat.
|
||||||
LiveScene = StagedScene;
|
LiveScene = StagedScene;
|
||||||
EditingActive = false;
|
ApplyEditLockForSession(starting: true);
|
||||||
await _framePump.StartAsync(); // never throws; failures log + surface via Failed
|
await _framePump.StartAsync(); // never throws; failures log + surface via Failed
|
||||||
|
|
||||||
// Check promptly until YouTube confirms the broadcast is live. Health
|
// Check promptly until YouTube confirms the broadcast is live. Health
|
||||||
@@ -424,7 +424,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
_sessionStartedAtUtc = null;
|
_sessionStartedAtUtc = null;
|
||||||
IsRecording = false;
|
IsRecording = false;
|
||||||
LiveScene = null;
|
LiveScene = null;
|
||||||
EditingActive = true;
|
ApplyEditLockForSession(starting: false);
|
||||||
// TASK 41: a stopped test broadcast forgets its drawer session (idempotent)
|
// TASK 41: a stopped test broadcast forgets its drawer session (idempotent)
|
||||||
// and drops the TEST face — a test never outlives its run.
|
// and drops the TEST face — a test never outlives its run.
|
||||||
TestSession.EndTestSilently();
|
TestSession.EndTestSilently();
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
_activeLayoutPath = _layoutStore.ActivePath;
|
_activeLayoutPath = _layoutStore.ActivePath;
|
||||||
_toastPosition = _layoutStore.LoadNotificationPosition();
|
_toastPosition = _layoutStore.LoadNotificationPosition();
|
||||||
_stickyAlertsUntilDismissed = _layoutStore.LoadStickyAlertsUntilDismissed();
|
_stickyAlertsUntilDismissed = _layoutStore.LoadStickyAlertsUntilDismissed();
|
||||||
|
_lockEditWhileLive = _layoutStore.LoadLockEditWhileLive();
|
||||||
_recordFolder = _layoutStore.LoadRecordFolder();
|
_recordFolder = _layoutStore.LoadRecordFolder();
|
||||||
|
|
||||||
// What the last few sessions on this tier actually held, if it is worth asking about.
|
// What the last few sessions on this tier actually held, if it is worth asking about.
|
||||||
|
|||||||
@@ -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:
|
`FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard:
|
||||||
`ytLive.Tests/GeometryFieldWidthTests.cs`
|
`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.<tier>`): 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
|
- 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.<tier>`): 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?**
|
- **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
|
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
|
that layer". Not reproduced from the obvious paths: with EDIT off the left panel swaps the layer list
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using Xunit;
|
||||||
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user