feat(65): the replay layer plays — once in Live, looping everywhere else

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.
This commit is contained in:
2026-11-10 12:54:54 -08:00
parent a5c2061b55
commit 5fcfa545e5
5 changed files with 163 additions and 0 deletions
+90
View File
@@ -1,6 +1,8 @@
using System;
using System.Linq;
using System.IO;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.ViewModels;
@@ -111,6 +113,8 @@ public partial class MainViewModel
private void StartClipRing()
{
_clipLibrary = new ClipLibrary(() => _recordFolder ?? DefaultRecordFolder());
ClipSaved -= OnClipSaved;
ClipSaved += OnClipSaved;
_clipRing = null;
_clipRingCapacity = 0;
ApplyClipSettings();
@@ -121,14 +125,22 @@ public partial class MainViewModel
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.");
@@ -158,4 +170,82 @@ public partial class MainViewModel
/// <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));
}
}