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");
}
}