From 5fcfa545e5336946c8e9994615f2229927ed03c4 Mon Sep 17 00:00:00 2001 From: gramps Date: Tue, 10 Nov 2026 12:54:54 -0800 Subject: [PATCH] =?UTF-8?q?feat(65):=20the=20replay=20layer=20plays=20?= =?UTF-8?q?=E2=80=94=20once=20in=20Live,=20looping=20everywhere=20else?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Controls/LeftPanel.xaml | 2 + ViewModels/MainViewModel.Clips.cs | 90 +++++++++++++++++++++++++++++++ ViewModels/MainViewModel.Media.cs | 1 + ViewModels/MainViewModel.cs | 3 ++ ytLive.Tests/ReplayLayerTests.cs | 67 +++++++++++++++++++++++ 5 files changed, 163 insertions(+) create mode 100644 ytLive.Tests/ReplayLayerTests.cs diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml index c4d4244..f8b7a80 100644 --- a/Controls/LeftPanel.xaml +++ b/Controls/LeftPanel.xaml @@ -125,6 +125,8 @@ + _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; } /// 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. 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 /// Raised with the saved path so the replay layer can play it immediately. public event Action? ClipSaved; + + /// 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". + public bool IsLiveReplayPlaying => _liveReplayPlaying; + private bool _liveReplayPlaying; + + /// 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. + internal void AddReplayLayer() + { + var scene = StagedScene; + if (scene is null || scene.Elements.OfType().Any(s => s.IsReplayLayer)) return; + + AddSource(Models.SourceType.MediaSource, NewestClipPath()); + if (StagedScene?.Elements.OfType().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(); + + /// A new clip landed: Live plays it once, every other scene keeps its loop of the kept set. + private void OnClipSaved(string path) + { + foreach (var scene in Scenes) + { + var layer = scene.Elements.OfType().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)); + } + } + } + + /// A replay layer's clip finished: Live hides (you were there to see it), everything else + /// advances through the kept set, looped. + private void OnReplayLayerCompleted(Source layer) + { + if (Models.SceneCatalog.Is(StagedScene?.Name ?? string.Empty, Models.SceneCatalog.Live) + && layer == Scenes.SelectMany(s => s.Elements).OfType().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)); + } } diff --git a/ViewModels/MainViewModel.Media.cs b/ViewModels/MainViewModel.Media.cs index 648acaa..8de0f6b 100644 --- a/ViewModels/MainViewModel.Media.cs +++ b/ViewModels/MainViewModel.Media.cs @@ -278,6 +278,7 @@ public partial class MainViewModel { source.IsMediaRunning = false; source.MediaPlaybackState = MediaPlaybackState.Stopped; + if (source.IsReplayLayer) OnReplayLayerCompleted(source); } } diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 767add2..ec1cbe6 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -189,6 +189,8 @@ public partial class MainViewModel : ViewModelBase public ICommand AddSourceCommand { get; } public ICommand AddImageCommand { get; } public ICommand AddMediaCommand { get; } + /// TASK 65: add the replay layer to the staged scene. + public ICommand AddReplayLayerCommand { get; } public ICommand ChangeMediaFileCommand { get; } /// Accepts the detector's green/blue-screen offer on a clip (TASK 64). 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 }); diff --git a/ytLive.Tests/ReplayLayerTests.cs b/ytLive.Tests/ReplayLayerTests.cs new file mode 100644 index 0000000..849c268 --- /dev/null +++ b/ytLive.Tests/ReplayLayerTests.cs @@ -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; + +/// +/// TASK 65 slice 5 — the replay layer as a thing the creator can add, and the one guard that matters. +/// +/// 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. +/// +[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() + .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() + .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 */ } + } + }); +}