diff --git a/Controls/SettingsDrawer.xaml b/Controls/SettingsDrawer.xaml
index a5e90d3..bab50fe 100644
--- a/Controls/SettingsDrawer.xaml
+++ b/Controls/SettingsDrawer.xaml
@@ -63,6 +63,21 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/Services/GlobalHotkeys.cs b/Services/GlobalHotkeys.cs
index cd72023..7f51596 100644
--- a/Services/GlobalHotkeys.cs
+++ b/Services/GlobalHotkeys.cs
@@ -16,6 +16,8 @@ public enum HotkeyId
ToggleMicMute = 7,
ToggleGameMute = 8,
TraxPlayPause = 9,
+ /// TASK 65: snapshot the replay ring and save it as a clip.
+ SaveClip = 10,
}
/// 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;
diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs
index fcdaf09..767eec3 100644
--- a/Services/LayoutStore.Settings.cs
+++ b/Services/LayoutStore.Settings.cs
@@ -63,6 +63,23 @@ public partial class LayoutStore : IDisposable
public void SaveShowMonetaryMetrics(bool show) => UpsertSetting("Metrics.ShowMonetary", show ? "1" : "0");
+ /// TASK 65: replay clips. Off by default — the ring costs memory and the feature is opt-in.
+ public bool LoadClipsEnabled() => GetSetting("Clips.Enabled") == "1";
+
+ public void SaveClipsEnabled(bool enabled) => UpsertSetting("Clips.Enabled", enabled ? "1" : "0");
+
+ /// Clip length in seconds: 10 or 15 (default 10).
+ 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());
+
+ /// How many clips survive on disk: 3, 4 or 5 (default 3).
+ 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");
diff --git a/ViewModels/HotkeyConfigViewModel.cs b/ViewModels/HotkeyConfigViewModel.cs
index a993b3a..9367732 100644
--- a/ViewModels/HotkeyConfigViewModel.cs
+++ b/ViewModels/HotkeyConfigViewModel.cs
@@ -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(),
};
}
diff --git a/ViewModels/MainViewModel.Clips.cs b/ViewModels/MainViewModel.Clips.cs
new file mode 100644
index 0000000..0b4512a
--- /dev/null
+++ b/ViewModels/MainViewModel.Clips.cs
@@ -0,0 +1,161 @@
+using System;
+using System.IO;
+using ytLive.Helpers;
+using ytLive.Services;
+
+namespace ytLive.ViewModels;
+
+///
+/// TASK 65, slice 4: the creator-facing half of replay clips — the setting, the ring's lifetime, and F10.
+///
+/// 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.
+///
+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;
+
+ /// 10 or 15 seconds; anything else in the store is coerced by the loader.
+ 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;
+
+ /// String-faced pickers, because the drawer binds a ComboBox and the model keeps ints.
+ 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();
+ }
+
+ /// 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.
+ 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;
+ }
+
+ /// 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.
+ 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();
+ 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);
+ }
+
+ /// Raised with the saved path so the replay layer can play it immediately.
+ public event Action? ClipSaved;
+}
diff --git a/ViewModels/MainViewModel.Scenes.cs b/ViewModels/MainViewModel.Scenes.cs
index 06bb45e..3e2bdb1 100644
--- a/ViewModels/MainViewModel.Scenes.cs
+++ b/ViewModels/MainViewModel.Scenes.cs
@@ -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)
{
diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs
index b15ddb5..e717f78 100644
--- a/ViewModels/MainViewModel.Streaming.Operations.cs
+++ b/ViewModels/MainViewModel.Streaming.Operations.cs
@@ -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,
};
}
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index e1e8f64..767add2 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -576,6 +576,7 @@ public partial class MainViewModel : ViewModelBase
SelectedTransitionType = parsed;
TransitionDurationMs = transDur;
+ LoadClipSettings();
BroadcastForm.Load();
ScheduleSave();
diff --git a/ytLive.Tests/ClipLibraryTests.cs b/ytLive.Tests/ClipLibraryTests.cs
index 5dadd30..11a28f0 100644
--- a/ytLive.Tests/ClipLibraryTests.cs
+++ b/ytLive.Tests/ClipLibraryTests.cs
@@ -74,3 +74,52 @@ public sealed class ClipLibraryTests : IDisposable
Assert.True(tenAt8 < 15L * 1024 * 1024); // and still modest
}
}
+
+///
+/// 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.
+///
+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());
+ }
+ }
+}