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.Services.Encoder; using ytLive.ViewModels; namespace ytLive.Tests; /// /// 2026-11-05 report: "when I click Loop, the video doesn't play in the preview pane." The switch was /// enabled and saved, but Loop is only consulted at end-of-file by a LIVE decoder — and a finished /// decoder could never be started again (`StartAsync` bailed out once it had run once). So a short clip /// froze on its last frame forever. This pins the fix: a played-out clip can be started again, and /// turning Loop on for a finished clip restarts it. /// [Collection("RealApp")] public sealed class MediaPlaybackRestartTests { private readonly RealAppHost _app; public MediaPlaybackRestartTests(RealAppHost app) => _app = app; [Fact] public async Task Pipe_CanPlayAgainAfterANaturalEnd() { var processStarts = 0; using var pipe = new VideoClipPipe( "clip.mp4", 1, 1, new FakeLocator(), () => { processStarts++; return new EmptyProcess(); }, delay: (_, _) => Task.CompletedTask, audio: false); var completions = 0; pipe.Completed += () => completions++; await pipe.StartAsync(); await WaitUntil(() => completions == 1); await pipe.StartAsync(); // a finished clip must be startable again await WaitUntil(() => completions == 2); Assert.Equal(2, completions); Assert.True(processStarts >= 2, "the second play did not spawn a decoder"); } [Fact] public void PlayedOutClip_ShowsAsFinished_AndPlayAgainRestartsIt() { _app.Run(() => { var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db"); var videoPath = Path.Combine(Path.GetTempPath(), "short-loop.mp4"); var decoders = new List(); MainViewModel.LayoutPathOverride = tempDb; MainViewModel.MediaFrameSourceFactoryOverride = key => { var decoder = new FakeMediaSource(key); decoders.Add(decoder); return decoder; }; MainViewModel.ChromaProbeOverride = new NoProbe(); var window = new MainWindow(); var vm = (MainViewModel)window.DataContext; try { window.Show(); window.UpdateLayout(); vm.AddMediaSource(videoPath); var layer = vm.StagedScene!.Elements.OfType().Single(s => s.IsMediaSource); var decoder = decoders.Single(); Assert.True(layer.IsMediaRunning, "a freshly added clip must report that it is playing"); // The clip plays out. decoder.RaiseCompleted(); Assert.False(layer.IsMediaRunning); Assert.True(layer.IsMediaFinished); // "Play again" restarts the same session from the top. vm.ReplayMediaCommand.Execute(layer); Assert.True(layer.IsMediaRunning); Assert.Equal(2, decoder.StartCount); // And turning Loop on for a finished clip restarts it — the reported bug. decoder.RaiseCompleted(); Assert.False(layer.IsMediaRunning); layer.MediaIsLooping = true; Assert.True(layer.IsMediaRunning, "turning Loop on for a finished clip must start it again"); Assert.Equal(3, decoder.StartCount); } finally { window.Close(); MainViewModel.ChromaProbeOverride = null; MainViewModel.MediaFrameSourceFactoryOverride = null; MainViewModel.LayoutPathOverride = null; SqliteConnection.ClearAllPools(); try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } } }); } private static async Task WaitUntil(Func condition) { for (var i = 0; i < 200 && !condition(); i++) await Task.Delay(10); Assert.True(condition(), "timed out waiting for the clip to finish"); } private sealed class FakeMediaSource : IMediaFrameSource { public FakeMediaSource(string key) => Key = key; public string Key { get; } public bool Looping { get; set; } public int StartCount { get; private set; } public event Action? FrameAvailable { add { } remove { } } public event Action? AudioAvailable { add { } remove { } } public event Action? Completed; public void RaiseCompleted() => Completed?.Invoke(); public Task StartAsync(CancellationToken cancellationToken = default) { StartCount++; return Task.CompletedTask; } public Task StopAsync() => Task.CompletedTask; public void Dispose() { } } private sealed class NoProbe : IChromaProbe { public Task ProbeAsync(string path, CancellationToken cancellationToken = default) => Task.FromResult(null); } private sealed class EmptyProcess : IDecodeProcess { public void Start(System.Diagnostics.ProcessStartInfo startInfo) { } public Stream StandardOutput => Stream.Null; public bool HasExited => true; public int ExitCode => 0; public void Kill() { } public Task WaitForExitAsync(CancellationToken ct = default) => Task.CompletedTask; public void Dispose() { } } private sealed class FakeLocator : IFfmpegLocator { public Task LocateAsync(CancellationToken ct = default) => Task.FromResult("ffmpeg.exe"); } }