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:
@@ -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"/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -576,6 +576,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
SelectedTransitionType = parsed;
|
||||
TransitionDurationMs = transDur;
|
||||
|
||||
LoadClipSettings();
|
||||
BroadcastForm.Load();
|
||||
|
||||
ScheduleSave();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user