608 green, 0 warnings. Completes the layer half of TASK 65. - **(+) → Replay Clip** adds an ordinary media layer with an app-owned path, one per scene, silent by default (`MediaMuted`) and looping only outside Live — the compositor needs no special case. - **F10 saves → the Live layer plays it once, then hides** (`SessionCompleted` → invisible, guard cleared). Every other scene with a replay layer keeps looping the kept set, advancing on each clip's completion, wrapping at the end. - **The guard the creator asked for:** while that Live replay is playing, **F10 is inert** with a plain explanation. That is what replaces the impossible "exclude the layer from its own clip" — with one encoder and two muxes the clip *is* the program, so the fix is to not clip a clip. `ReplayLayerTests` drives the real VM: one layer per scene, muted by default, once-through in Live, looping in BRB, and a stray F10 with clips off writes nothing.
252 lines
9.1 KiB
C#
252 lines
9.1 KiB
C#
using System;
|
|
using System.Linq;
|
|
using System.IO;
|
|
using ytLive.Helpers;
|
|
using ytLive.Models;
|
|
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());
|
|
ClipSaved -= OnClipSaved;
|
|
ClipSaved += OnClipSaved;
|
|
_clipRing = null;
|
|
_clipRingCapacity = 0;
|
|
ApplyClipSettings();
|
|
OnPropertyChanged(nameof(ClipsEnabled));
|
|
OnPropertyChanged(nameof(ClipSeconds));
|
|
OnPropertyChanged(nameof(ClipKeep));
|
|
}
|
|
|
|
private void StopClipRing()
|
|
{
|
|
ClipSaved -= OnClipSaved;
|
|
_clipRing = null;
|
|
_clipRingCapacity = 0;
|
|
_liveReplayPlaying = false;
|
|
}
|
|
|
|
/// <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 (_liveReplayPlaying)
|
|
{
|
|
_notifications.Info("Replay in progress", "Wait for the replay to finish before saving another clip.");
|
|
return;
|
|
}
|
|
|
|
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;
|
|
|
|
/// <summary>True while the Live scene's replay layer is playing a fresh clip. F10 is inert during
|
|
/// that window: the clip is a second mux of the frames viewers already got, so clipping a replay
|
|
/// would record a video of a video — the guard replaces the impossible "clean feed".</summary>
|
|
public bool IsLiveReplayPlaying => _liveReplayPlaying;
|
|
private bool _liveReplayPlaying;
|
|
|
|
/// <summary>Adds the replay layer to the staged scene. One per scene; it is an ordinary media layer
|
|
/// whose path the app owns, so the compositor needs no special case.</summary>
|
|
internal void AddReplayLayer()
|
|
{
|
|
var scene = StagedScene;
|
|
if (scene is null || scene.Elements.OfType<Source>().Any(s => s.IsReplayLayer)) return;
|
|
|
|
AddSource(Models.SourceType.MediaSource, NewestClipPath());
|
|
if (StagedScene?.Elements.OfType<Source>().LastOrDefault() is not { } layer) return;
|
|
|
|
layer.Name = "Replay";
|
|
layer.IsReplayLayer = true;
|
|
layer.MediaMuted = true; // silent by default
|
|
layer.MediaIsLooping = !Models.SceneCatalog.Is(scene.Name, Models.SceneCatalog.Live);
|
|
ScheduleSave();
|
|
}
|
|
|
|
private string? NewestClipPath()
|
|
=> (_clipLibrary ??= new ClipLibrary(() => _recordFolder ?? DefaultRecordFolder())).Recent().FirstOrDefault();
|
|
|
|
/// <summary>A new clip landed: Live plays it once, every other scene keeps its loop of the kept set.</summary>
|
|
private void OnClipSaved(string path)
|
|
{
|
|
foreach (var scene in Scenes)
|
|
{
|
|
var layer = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsReplayLayer);
|
|
if (layer is null) continue;
|
|
|
|
if (Models.SceneCatalog.Is(scene.Name, Models.SceneCatalog.Live))
|
|
{
|
|
layer.MediaPath = path;
|
|
layer.MediaIsLooping = false;
|
|
layer.MediaMuted = true;
|
|
layer.IsVisible = true;
|
|
layer.MediaPlaybackState = Models.MediaPlaybackState.Playing;
|
|
_liveReplayPlaying = true;
|
|
OnPropertyChanged(nameof(IsLiveReplayPlaying));
|
|
EnsureMediaSession(MediaSessionKey(layer));
|
|
}
|
|
else if (!layer.IsMediaRunning)
|
|
{
|
|
layer.MediaPath = NewestClipPath();
|
|
layer.MediaIsLooping = true;
|
|
layer.IsVisible = true;
|
|
if (!string.IsNullOrWhiteSpace(layer.MediaPath)) EnsureMediaSession(MediaSessionKey(layer));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>A replay layer's clip finished: Live hides (you were there to see it), everything else
|
|
/// advances through the kept set, looped.</summary>
|
|
private void OnReplayLayerCompleted(Source layer)
|
|
{
|
|
if (Models.SceneCatalog.Is(StagedScene?.Name ?? string.Empty, Models.SceneCatalog.Live)
|
|
&& layer == Scenes.SelectMany(s => s.Elements).OfType<Source>().FirstOrDefault(s => s.IsReplayLayer))
|
|
{
|
|
layer.IsVisible = false;
|
|
layer.MediaPlaybackState = Models.MediaPlaybackState.Stopped;
|
|
_liveReplayPlaying = false;
|
|
OnPropertyChanged(nameof(IsLiveReplayPlaying));
|
|
return;
|
|
}
|
|
|
|
var playlist = (_clipLibrary ??= new ClipLibrary(() => _recordFolder ?? DefaultRecordFolder())).Recent();
|
|
if (playlist.Count == 0) return;
|
|
var index = playlist.ToList().IndexOf(layer.MediaPath ?? string.Empty);
|
|
var next = playlist[(index + 1 + playlist.Count) % playlist.Count];
|
|
layer.MediaPath = next;
|
|
layer.MediaIsLooping = true;
|
|
EnsureMediaSession(MediaSessionKey(layer));
|
|
}
|
|
}
|