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).
162 lines
5.1 KiB
C#
162 lines
5.1 KiB
C#
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;
|
|
}
|