fix(media): a played-out clip can play again; play button beside the Video File label

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.
This commit is contained in:
2026-11-05 09:15:30 -08:00
parent e37215d9c4
commit 242a13ca30
8 changed files with 327 additions and 9 deletions
+14 -4
View File
@@ -46,7 +46,7 @@ public sealed class VideoClipPipe : IDisposable
private readonly bool _audio;
private readonly ChromaKeyOptions? _key;
private readonly CancellationTokenSource _cts = new();
private IDisposable? _run;
private bool _running;
private IDecodeProcess? _activeVideo;
private IDecodeProcess? _activeAudio;
@@ -72,10 +72,13 @@ public sealed class VideoClipPipe : IDisposable
_key = chromaKey;
}
/// <summary>Runs one pass (or a loop of passes). A pass that ended naturally leaves the pipe
/// restartable — the creator can ask a finished clip to play again, which is what the Loop
/// switch and the "Play again" button need.</summary>
public Task StartAsync(CancellationToken cancellationToken = default)
{
if (_run != null) return Task.CompletedTask;
_run = _cts.Token.Register(StopInternal);
if (_running) return Task.CompletedTask;
_running = true;
_ = Task.Run(RunAsync, CancellationToken.None);
return Task.CompletedTask;
}
@@ -83,13 +86,16 @@ public sealed class VideoClipPipe : IDisposable
public Task StopAsync()
{
_cts.Cancel();
_run = null;
Kill(_activeVideo);
Kill(_activeAudio);
_running = false;
return Task.CompletedTask;
}
public void Dispose()
{
_cts.Cancel();
_running = false;
_cts.Dispose();
_activeVideo?.Dispose();
_activeAudio?.Dispose();
@@ -97,6 +103,7 @@ public sealed class VideoClipPipe : IDisposable
private void StopInternal()
{
_running = false;
Kill(_activeVideo);
Kill(_activeAudio);
}
@@ -129,6 +136,7 @@ public sealed class VideoClipPipe : IDisposable
}
catch (OperationCanceledException)
{
_running = false;
return;
}
catch (Exception ex)
@@ -136,9 +144,11 @@ public sealed class VideoClipPipe : IDisposable
// A failed decode is not fatal: the alert layer falls back to its animations,
// and a media layer just holds its last frame.
AppLog.Write($"Video clip decode failed ({Path.GetFileName(_path)}): {ex.Message}");
_running = false;
return;
}
_running = false;
if (!ct.IsCancellationRequested)
{
AppLog.Write($"Video clip end: {Path.GetFileName(_path)} frames={frames} audioChunks={chunks}");