Reported 2026-11-05: "when I click Loop, the video doesn't play in the preview pane". Two causes, both real: - `VideoClipPipe.StartAsync` bailed out once it had run once (`_run != null`), so a clip that reached EOF could never be started again — it held its last frame forever. - Loop is only consulted at end-of-file by a LIVE decoder, so flipping it on a finished clip did nothing at all. Now: the pipe reports running state and is restartable after a natural end; the manager marks a session finished (keeping its last frame) and exposes `IsRunning`/`RestartAsync`; the layer shows `IsMediaRunning`/`IsMediaFinished`; flipping Loop on a finished clip restarts it. UI: `▶ Play` sits to the right of the **Video File** label and is enabled only while Loop is off (creator ruling: with Loop on the clip already runs continuously, so there is nothing to start). The panel says "Clip finished — holding the last frame." so a frozen picture explains itself. Also: verify.sh no longer runs physical-input tests by default. That test moves the real cursor (SetCursorPos + mouse_event); running it mid-gate hijacks the machine someone is using, which is exactly how it felt. Opt in with PHYSICAL_TESTS=1 when the desktop is idle; the header documents the scoped-run command for working hours. Test: `MediaPlaybackRestartTests` (pipe restart, finished state, Play again, Loop-on-finished). 8 scoped tests green. FULL SUITE NOT RUN — the machine was in use; run scripts/verify.sh in a quiet window before the next milestone.
167 lines
5.9 KiB
C#
167 lines
5.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<FakeMediaSource>();
|
|
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<Source>().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<bool> 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<VideoFrame>? FrameAvailable { add { } remove { } }
|
|
public event Action<ytLive.Services.Audio.AudioSample>? 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<VideoFrame?> ProbeAsync(string path, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult<VideoFrame?>(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<string> LocateAsync(CancellationToken ct = default) => Task.FromResult("ffmpeg.exe");
|
|
}
|
|
}
|