feat(65): F10 saves a clip; the gear drawer owns the three settings

Slice 4. 607 green, 0 warnings. Clips remain **off by default** and, while off, the ring is never
constructed — so no reader thread and no encoder output exist until someone asks for the feature.

- **F10** (`HotkeyId.SaveClip`, default F10, remappable in the hotkey dialog) snapshots the ring, writes
  the file, prunes to the retention count and toasts the name. Empty ring → "nothing to clip yet"
  instead of a zero-byte file; clips off → says so and points at Settings.
- **Settings**: Enable, length (10 or 15, default 10), keep (3/4/5, default 3). Persisted in the layout
  store and **coerced on load** — a bad stored value falls back to a legal one, so the ring can never be
  sized for a length the UI cannot show.
- The ring is sized from length × tier bitrate × 1.25 and rebuilt if either changes; it is created at
  session start and dropped at stop, so memory is only spent while a show is running.
- The drawer states the boundary in the creator's terms: "A capture, not an editor: for trimming,
  captions or a library, use a video editor."

Still to come: the replay layer itself (play once then hide in Live, loop the kept clips elsewhere).
This commit is contained in:
2026-11-10 12:41:39 -08:00
parent f7d3d1301e
commit 28657918f9
9 changed files with 252 additions and 0 deletions
+15
View File
@@ -63,6 +63,21 @@
</Popup>
<!-- ═══ ZOOM ═══ -->
<TextBlock Text="Clips" Style="{StaticResource SectionHeader}" Margin="0,10,0,4"/>
<CheckBox Content="Save replay clips" Style="{StaticResource PropertyPillCheck}" Margin="12,2,0,4"
IsChecked="{Binding ClipsEnabled, Mode=TwoWay}"
ToolTip="F10 saves the last few seconds of the show. Off by default; nothing runs while it is off."/>
<StackPanel Margin="12,0,0,4" IsEnabled="{Binding ClipsEnabled}">
<TextBlock Text="Clip length" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,2"/>
<ComboBox ItemsSource="{Binding ClipLengthOptions}" SelectedItem="{Binding ClipLengthText, Mode=TwoWay}"
Style="{StaticResource YtComboBox}" Height="28" Margin="0,0,0,6"/>
<TextBlock Text="Keep" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,2"/>
<ComboBox ItemsSource="{Binding ClipKeepOptions}" SelectedItem="{Binding ClipKeepText, Mode=TwoWay}"
Style="{StaticResource YtComboBox}" Height="28"/>
<TextBlock Text="A capture, not an editor: for trimming, captions or a library, use a video editor."
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="10" TextWrapping="Wrap" Margin="0,6,0,0"/>
</StackPanel>
<TextBlock Text="Zoom" Style="{StaticResource SectionHeader}" Margin="0,6,0,4"/>
<TextBlock Text="Zoom view shape" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,0,0,2"/>
+3
View File
@@ -16,6 +16,8 @@ public enum HotkeyId
ToggleMicMute = 7,
ToggleGameMute = 8,
TraxPlayPause = 9,
/// <summary>TASK 65: snapshot the replay ring and save it as a clip.</summary>
SaveClip = 10,
}
/// <summary>Registers/revokes the OS-level hotkeys. Seam so tests never touch
@@ -65,6 +67,7 @@ public sealed class GlobalHotkeyManager
[HotkeyId.ToggleMicMute] = new HotkeyChord(0, 0x76), // F7
[HotkeyId.ToggleGameMute] = new HotkeyChord(0, 0x77), // F8
[HotkeyId.TraxPlayPause] = new HotkeyChord(0, 0x78), // F9
[HotkeyId.SaveClip] = new HotkeyChord(0, 0x79), // F10
};
private readonly IHotkeyRegistrar _registrar;
+17
View File
@@ -63,6 +63,23 @@ public partial class LayoutStore : IDisposable
public void SaveShowMonetaryMetrics(bool show) => UpsertSetting("Metrics.ShowMonetary", show ? "1" : "0");
/// <summary>TASK 65: replay clips. Off by default — the ring costs memory and the feature is opt-in.</summary>
public bool LoadClipsEnabled() => GetSetting("Clips.Enabled") == "1";
public void SaveClipsEnabled(bool enabled) => UpsertSetting("Clips.Enabled", enabled ? "1" : "0");
/// <summary>Clip length in seconds: 10 or 15 (default 10).</summary>
public int LoadClipSeconds()
=> int.TryParse(GetSetting("Clips.Seconds"), out var s) && (s == 10 || s == 15) ? s : 10;
public void SaveClipSeconds(int seconds) => UpsertSetting("Clips.Seconds", seconds.ToString());
/// <summary>How many clips survive on disk: 3, 4 or 5 (default 3).</summary>
public int LoadClipKeep()
=> int.TryParse(GetSetting("Clips.Keep"), out var k) && k >= 3 && k <= 5 ? k : 3;
public void SaveClipKeep(int keep) => UpsertSetting("Clips.Keep", keep.ToString());
public bool LoadAutoChromaScreen() => GetSetting("Video.AutoChromaScreen") == "1";
public void SaveAutoChromaScreen(bool enabled) => UpsertSetting("Video.AutoChromaScreen", enabled ? "1" : "0");
+1
View File
@@ -58,6 +58,7 @@ public sealed class HotkeyBindingRow : ViewModelBase
HotkeyId.ToggleMicMute => "Toggle Mic Mute",
HotkeyId.ToggleGameMute => "Toggle Desktop Audio Mute",
HotkeyId.TraxPlayPause => "TRAX Play / Pause",
HotkeyId.SaveClip => "Save Replay Clip",
_ => id.ToString(),
};
}
+161
View File
@@ -0,0 +1,161 @@
using System;
using System.IO;
using ytLive.Helpers;
using ytLive.Services;
namespace ytLive.ViewModels;
/// <summary>
/// TASK 65, slice 4: the creator-facing half of replay clips — the setting, the ring's lifetime, and F10.
///
/// <para>Clips are a **static feature**: 10 or 15 seconds, keep 3/4/5, no editor and no gallery. Off by
/// default, and when off nothing runs — no ring, no reader thread, no encoder output — so the feature
/// costs nothing until someone asks for it.</para>
/// </summary>
public partial class MainViewModel
{
private ClipRing? _clipRing;
private ClipLibrary? _clipLibrary;
public bool ClipsEnabled
{
get => _clipsEnabled;
set
{
if (!SetProperty(ref _clipsEnabled, value)) return;
_layoutStore.SaveClipsEnabled(value);
ApplyClipSettings();
}
}
private bool _clipsEnabled;
/// <summary>10 or 15 seconds; anything else in the store is coerced by the loader.</summary>
public int ClipSeconds
{
get => _clipSeconds;
set
{
var clamped = value == 15 ? 15 : 10;
if (!SetProperty(ref _clipSeconds, clamped)) return;
_layoutStore.SaveClipSeconds(clamped);
ApplyClipSettings();
}
}
private int _clipSeconds = 10;
public int ClipKeep
{
get => _clipKeep;
set
{
var clamped = Math.Clamp(value, 3, 5);
if (!SetProperty(ref _clipKeep, clamped)) return;
_layoutStore.SaveClipKeep(clamped);
}
}
private int _clipKeep = 3;
/// <summary>String-faced pickers, because the drawer binds a ComboBox and the model keeps ints.</summary>
public string[] ClipLengthOptions { get; } = { "10 seconds", "15 seconds" };
public string ClipLengthText
{
get => ClipSeconds == 15 ? "15 seconds" : "10 seconds";
set
{
ClipSeconds = value == "15 seconds" ? 15 : 10;
OnPropertyChanged(nameof(ClipLengthText));
}
}
public string[] ClipKeepOptions { get; } = { "3 clips", "4 clips", "5 clips" };
public string ClipKeepText
{
get => $"{ClipKeep} clips";
set
{
if (int.TryParse(value.Split(' ')[0], out var keep)) ClipKeep = keep;
OnPropertyChanged(nameof(ClipKeepText));
}
}
private void LoadClipSettings()
{
_clipsEnabled = _layoutStore.LoadClipsEnabled();
_clipSeconds = _layoutStore.LoadClipSeconds();
_clipKeep = _layoutStore.LoadClipKeep();
}
/// <summary>The ring exists only while a session runs and clips are on. Sized from the chosen length
/// and the tier's bitrate, because memory is the cost and bytes are the unit.</summary>
private void ApplyClipSettings()
{
if (!_clipsEnabled || !(IsLive || IsRecording))
{
_clipRing = null;
return;
}
var quality = Models.OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput);
var bytes = ClipLibrary.RingBytesFor(_clipSeconds, (int)Math.Round(quality.Bitrate * 1000));
if (_clipRing is null || _clipRingCapacity != bytes)
{
_clipRing = new ClipRing(bytes);
_clipRingCapacity = bytes;
}
}
private long _clipRingCapacity;
private void StartClipRing()
{
_clipLibrary = new ClipLibrary(() => _recordFolder ?? DefaultRecordFolder());
_clipRing = null;
_clipRingCapacity = 0;
ApplyClipSettings();
OnPropertyChanged(nameof(ClipsEnabled));
OnPropertyChanged(nameof(ClipSeconds));
OnPropertyChanged(nameof(ClipKeep));
}
private void StopClipRing()
{
_clipRing = null;
_clipRingCapacity = 0;
}
/// <summary>F10. Snapshots the ring, writes a file, and prunes to the retention count. When nothing
/// has been buffered yet it says so instead of writing a file that will not open.</summary>
public void SaveReplayClip()
{
if (!_clipsEnabled)
{
_notifications.Info("Replay clips are off", "Turn them on in Settings to save a clip.");
return;
}
var bytes = _clipRing?.Snapshot() ?? Array.Empty<byte>();
if (bytes.Length == 0)
{
_notifications.Warning("Nothing to clip yet",
"The replay buffer fills while you stream — wait a few seconds, then press again.");
return;
}
var library = _clipLibrary ??= new ClipLibrary(() => _recordFolder ?? DefaultRecordFolder());
var path = library.Save(bytes, DateTime.Now, _clipKeep);
if (path == null)
{
_notifications.Error("Couldn't save the clip", "The file could not be written. Check free space.");
return;
}
AppLog.Write($"Replay clip saved: {path}");
_notifications.Success("Clip saved", Path.GetFileName(path));
ClipSaved?.Invoke(path);
}
/// <summary>Raised with the saved path so the replay layer can play it immediately.</summary>
public event Action<string>? ClipSaved;
}
+1
View File
@@ -216,6 +216,7 @@ public partial class MainViewModel
case Services.HotkeyId.SceneBrb: TransitionToCanonical(SceneCatalog.Brb); break;
case Services.HotkeyId.SceneChat: TransitionToCanonical(SceneCatalog.Chat); break;
case Services.HotkeyId.SceneEnding: TransitionToCanonical(SceneCatalog.Ending); break;
case Services.HotkeyId.SaveClip: SaveReplayClip(); break;
case Services.HotkeyId.StartStopStream:
if (IsLive || IsRecording)
{
@@ -164,6 +164,7 @@ public partial class MainViewModel : ViewModelBase
_liveElapsed = TimeSpan.Zero;
_recordLength = TimeSpan.Zero;
StartStepDownWatch();
StartClipRing();
var recordStarted = false;
if (alsoRecord)
@@ -407,6 +408,7 @@ public partial class MainViewModel : ViewModelBase
{
CancelStreamRecovery(); // a stop wins over a relaunch in flight
StopStepDownWatch();
StopClipRing();
RemoveAttentionAlert("stream-recovery");
var wasLive = IsLive;
var wasRecording = IsRecording;
@@ -516,6 +518,8 @@ public partial class MainViewModel : ViewModelBase
BitrateKbps = (int)Math.Round(quality.Bitrate * 1000),
// Same derivation as the mixer's StartLive call: a dev instance serves its own pipe.
AudioPipeName = InstanceProfile.AudioPipeName(EncoderOptions.DefaultAudioPipeName),
// TASK 65: null when clips are off, which leaves ffmpeg's command line untouched.
ClipBuffer = _clipRing,
};
}
+1
View File
@@ -576,6 +576,7 @@ public partial class MainViewModel : ViewModelBase
SelectedTransitionType = parsed;
TransitionDurationMs = transDur;
LoadClipSettings();
BroadcastForm.Load();
ScheduleSave();
+49
View File
@@ -74,3 +74,52 @@ public sealed class ClipLibraryTests : IDisposable
Assert.True(tenAt8 < 15L * 1024 * 1024); // and still modest
}
}
/// <summary>
/// TASK 65 slice 4 — the three settings behind the gear drawer. They persist, and the loader coerces
/// anything unexpected back into the allowed set: a bad value must never leave the user with a clip
/// length the ring cannot serve.
/// </summary>
public sealed class ClipSettingsTests : IDisposable
{
private readonly string _db = Path.Combine(Path.GetTempPath(), $"ytLlive-clipsettings-{Guid.NewGuid():N}.db");
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { File.Delete(_db); } catch { /* best effort */ }
}
[Fact]
public void SettingsPersistAndCoerce()
{
using (var store = new LayoutStore(_db))
{
Assert.False(store.LoadClipsEnabled()); // opt-in
Assert.Equal(10, store.LoadClipSeconds()); // default 10
Assert.Equal(3, store.LoadClipKeep()); // default 3
store.SaveClipsEnabled(true);
store.SaveClipSeconds(15);
store.SaveClipKeep(5);
}
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
using (var store = new LayoutStore(_db))
{
Assert.True(store.LoadClipsEnabled());
Assert.Equal(15, store.LoadClipSeconds());
Assert.Equal(5, store.LoadClipKeep());
store.SaveClipSeconds(7); // not a choice we offer
store.SaveClipKeep(9); // past the ceiling
}
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
using (var store = new LayoutStore(_db))
{
Assert.Equal(10, store.LoadClipSeconds());
Assert.Equal(3, store.LoadClipKeep());
}
}
}