From 28657918f97bf427273e682ce7996ef049ac6d80 Mon Sep 17 00:00:00 2001 From: gramps Date: Tue, 10 Nov 2026 12:41:39 -0800 Subject: [PATCH] feat(65): F10 saves a clip; the gear drawer owns the three settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- Controls/SettingsDrawer.xaml | 15 ++ Services/GlobalHotkeys.cs | 3 + Services/LayoutStore.Settings.cs | 17 ++ ViewModels/HotkeyConfigViewModel.cs | 1 + ViewModels/MainViewModel.Clips.cs | 161 ++++++++++++++++++ ViewModels/MainViewModel.Scenes.cs | 1 + .../MainViewModel.Streaming.Operations.cs | 4 + ViewModels/MainViewModel.cs | 1 + ytLive.Tests/ClipLibraryTests.cs | 49 ++++++ 9 files changed, 252 insertions(+) create mode 100644 ViewModels/MainViewModel.Clips.cs 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()); + } + } +}