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:
@@ -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}}"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +278,7 @@ public partial class MainViewModel
|
||||
{
|
||||
source.IsMediaRunning = false;
|
||||
source.MediaPlaybackState = MediaPlaybackState.Stopped;
|
||||
if (source.IsReplayLayer) OnReplayLayerCompleted(source);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user