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
+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;
}