feat(media): add a video file layer that decodes into the scene
TASK 21's picker slice: the decoder, manager, pacing and loop mechanism were
already shipped but nothing ever acquired a session, so a media Source rendered
nothing. Adds "Video File" to the (+) menu (video-filtered OpenFileDialog),
one decode session per distinct path with a HasSession guard, release when the
last scene reference goes, and a post-load reconcile. Loop is a manager-level
per-path answer ("any live reference loops") applied before StartAsync and
updated live via SetLooping. Properties panel shows the file with Change… and
Loop. Volume stays unwired: media audio rides the desktop loopback, TRAX-style.
Reference: OBS's media source (file → decode → composite into the scene) is the
established answer for this feature; the lifecycle shape mirrors this repo's
WebView2/ScreenCapture registration rather than inventing a new one.
Test: ytLive.Tests/MediaSourceAcquisitionTests.cs — add starts one decoder and
frames reach the layer, loop flip reaches the decoder, remove stops it.
Docs: task-21 closed, TASKS row + Day-4 row + stale 3·17 row corrected,
frame-pipeline acquisition note, HANDOFF rewritten. verify.sh green.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 21 Good Dog (2026-11-05): a MediaSource layer exists but nothing ever started
|
||||
/// a decode session, so a video file layer rendered nothing in a running app. This
|
||||
/// drives the real add path through <see cref="MainWindow"/> and asserts the piece the
|
||||
/// unit adds — the session lifecycle: adding a video file starts exactly one decoder,
|
||||
/// the decoded frames reach the layer, a loop flip reaches that decoder, and removing
|
||||
/// the layer stops it.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class MediaSourceAcquisitionTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public MediaSourceAcquisitionTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void AddingAVideoLayer_StartsTheDecoder_AndRemovingItStopsTheDecoder()
|
||||
{
|
||||
_app.Run(() =>
|
||||
{
|
||||
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
|
||||
var videoPath = Path.Combine(Path.GetTempPath(), "starting-soon.mp4");
|
||||
var decoders = new List<FakeMediaFrameSource>();
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
MainViewModel.MediaFrameSourceFactoryOverride = key =>
|
||||
{
|
||||
var decoder = new FakeMediaFrameSource(key);
|
||||
decoders.Add(decoder);
|
||||
return decoder;
|
||||
};
|
||||
|
||||
var window = new MainWindow();
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
window.UpdateLayout();
|
||||
var scene = vm.StagedScene!;
|
||||
|
||||
vm.AddMediaSource(videoPath);
|
||||
|
||||
var layer = scene.Elements.OfType<Source>().Single(s => s.IsMediaSource);
|
||||
Assert.Equal(videoPath, layer.MediaPath);
|
||||
Assert.Equal(SourceType.MediaSource, layer.Type);
|
||||
Assert.Single(decoders);
|
||||
|
||||
var decoder = decoders[0];
|
||||
Assert.Equal(videoPath, decoder.Key);
|
||||
Assert.Equal(1, decoder.StartCount);
|
||||
|
||||
// A decoded frame reaches the layer the preview/renderer draws from —
|
||||
// otherwise the session would be running and still invisible.
|
||||
Assert.Null(layer.DisplaySource);
|
||||
decoder.Emit(new VideoFrame(64, 36, new byte[64 * 36 * 4]));
|
||||
Assert.NotNull(layer.DisplaySource);
|
||||
|
||||
// Loop intent is per Source; the shared session is the per-path answer.
|
||||
Assert.False(decoder.Looping);
|
||||
layer.MediaIsLooping = true;
|
||||
Assert.True(decoder.Looping);
|
||||
|
||||
vm.RemoveSourceCommand.Execute(layer);
|
||||
|
||||
Assert.DoesNotContain(layer, scene.Elements);
|
||||
Assert.Equal(1, decoder.StopCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
MainViewModel.MediaFrameSourceFactoryOverride = null;
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Stands in for the ffmpeg decode process (the manager's factory seam).</summary>
|
||||
private sealed class FakeMediaFrameSource : IMediaFrameSource
|
||||
{
|
||||
public FakeMediaFrameSource(string key) => Key = key;
|
||||
|
||||
public string Key { get; }
|
||||
public bool Looping { get; set; }
|
||||
public int StartCount { get; private set; }
|
||||
public int StopCount { get; private set; }
|
||||
|
||||
public event Action<VideoFrame>? FrameAvailable;
|
||||
public event Action? Completed { add { } remove { } }
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
StartCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync()
|
||||
{
|
||||
StopCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Emit(VideoFrame frame) => FrameAvailable?.Invoke(frame);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user