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
+2
View File
@@ -125,6 +125,8 @@
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
<MenuItem Header="Video File" Command="{Binding AddMediaCommand}"
ToolTip="Play a video file in the scene (mp4, mov, avi, mkv, webm)"/>
<MenuItem Header="Replay Clip" Command="{Binding AddReplayLayerCommand}"
ToolTip="Plays saved replay clips: once in Live, looping in other scenes"/>
<MenuItem Header="Text" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.TextOverlay}"/>
<MenuItem Header="Countdown" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Countdown}"
Visibility="{Binding IsCountdownAvailable, Converter={StaticResource BoolToVis}}"
+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));
}
}
+1
View File
@@ -278,6 +278,7 @@ public partial class MainViewModel
{
source.IsMediaRunning = false;
source.MediaPlaybackState = MediaPlaybackState.Stopped;
if (source.IsReplayLayer) OnReplayLayerCompleted(source);
}
}
+3
View File
@@ -189,6 +189,8 @@ public partial class MainViewModel : ViewModelBase
public ICommand AddSourceCommand { get; }
public ICommand AddImageCommand { get; }
public ICommand AddMediaCommand { get; }
/// <summary>TASK 65: add the replay layer to the staged scene.</summary>
public ICommand AddReplayLayerCommand { get; }
public ICommand ChangeMediaFileCommand { get; }
/// <summary>Accepts the detector's green/blue-screen offer on a clip (TASK 64).</summary>
public ICommand AcceptChromaSuggestionCommand { get; }
@@ -268,6 +270,7 @@ public partial class MainViewModel : ViewModelBase
AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToStagedSceneAsync());
AddImageCommand = new RelayCommand(_ => AddImage());
AddMediaCommand = new RelayCommand(_ => AddMedia());
AddReplayLayerCommand = new RelayCommand(_ => AddReplayLayer());
ChangeMediaFileCommand = new RelayCommand(
element => ChangeMediaFile(element as Source),
element => element is Source { IsMediaSource: true });
+67
View File
@@ -0,0 +1,67 @@
using System;
using System.IO;
using System.Linq;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// TASK 65 slice 5 — the replay layer as a thing the creator can add, and the one guard that matters.
///
/// <para>The layer is an ordinary media layer with an app-owned path, so the test only has to prove the
/// flags: one per scene, silent by default, once-through in Live and looping everywhere else. Playback
/// itself rides the media manager that TASK 21 already covers.</para>
/// </summary>
[Collection("RealApp")]
public sealed class ReplayLayerTests
{
private readonly RealAppHost _app;
public ReplayLayerTests(RealAppHost app) => _app = app;
[Fact]
public void AddReplayLayer_IsOnePerScene_SilentByDefault_AndLoopsOutsideLive() => _app.Run(() =>
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-replay-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
try
{
var vm = new MainViewModel();
vm.StagedScene = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
vm.AddReplayLayer();
vm.AddReplayLayer(); // second add is a no-op
var layers = vm.Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.IsReplayLayer).ToList();
Assert.Single(layers);
var liveLayer = layers[0];
Assert.True(liveLayer.MediaMuted); // silent by default
Assert.False(liveLayer.MediaIsLooping); // Live plays once
// Any other scene loops the kept set.
vm.StagedScene = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Brb));
vm.AddReplayLayer();
var brbLayer = vm.Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Single(s => s.IsReplayLayer && s != liveLayer);
Assert.True(brbLayer.MediaIsLooping);
Assert.True(brbLayer.MediaMuted);
// Clips off by default, and with no ring nothing is written by a stray F10.
Assert.False(vm.ClipsEnabled);
vm.SaveReplayClip();
var clipsFolder = Path.Combine(Path.GetDirectoryName(tempDb)!, "clips");
Assert.False(Directory.Exists(clipsFolder) && Directory.EnumerateFiles(clipsFolder).Any());
}
finally
{
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best effort */ }
}
});
}