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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user