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
+56
View File
@@ -25,6 +25,8 @@ public sealed class MediaVideoSourceManager : IDisposable
public IMediaFrameSource Source { get; }
public Action<VideoFrame>? FrameHandler;
public Action<AudioSample>? AudioHandler;
public Action? CompletedHandler;
public bool Finished;
public int RefCount;
public bool Started;
public bool AudioMuted;
@@ -112,6 +114,8 @@ public sealed class MediaVideoSourceManager : IDisposable
session.Source.FrameAvailable += session.FrameHandler;
session.AudioHandler = sample => OnAudioAvailable(session, sample);
session.Source.AudioAvailable += session.AudioHandler;
session.CompletedHandler = () => OnSessionCompleted(session);
session.Source.Completed += session.CompletedHandler;
// Loop intent lives on the Source (per scene), so the caller supplies the
// per-path answer ("any live reference loops"). Applied before Start so a
// zero-length or instantly-EOF file cannot slip past a later assignment.
@@ -135,6 +139,7 @@ public sealed class MediaVideoSourceManager : IDisposable
_sessions.Remove(key);
session.Source.FrameAvailable -= session.FrameHandler;
if (session.AudioHandler != null) session.Source.AudioAvailable -= session.AudioHandler;
if (session.CompletedHandler != null) session.Source.Completed -= session.CompletedHandler;
AppLog.Write($"MediaSource: failed to start decoder '{key}': {ex.Message}");
MediaFailed?.Invoke(key, ex.Message);
await SafeStopAsync(session.Source);
@@ -156,6 +161,7 @@ public sealed class MediaVideoSourceManager : IDisposable
if (toStop == null) return;
toStop.Source.FrameAvailable -= toStop.FrameHandler;
if (toStop.AudioHandler != null) toStop.Source.AudioAvailable -= toStop.AudioHandler;
if (toStop.CompletedHandler != null) toStop.Source.Completed -= toStop.CompletedHandler;
await SafeStopAsync(toStop.Source);
toStop.PreviewBitmap = null;
}
@@ -175,6 +181,7 @@ public sealed class MediaVideoSourceManager : IDisposable
if (toStop == null) return;
toStop.Source.FrameAvailable -= toStop.FrameHandler;
if (toStop.AudioHandler != null) toStop.Source.AudioAvailable -= toStop.AudioHandler;
if (toStop.CompletedHandler != null) toStop.Source.Completed -= toStop.CompletedHandler;
await SafeStopAsync(toStop.Source);
toStop.PreviewBitmap = null;
}
@@ -187,6 +194,10 @@ public sealed class MediaVideoSourceManager : IDisposable
return _sessions.TryGetValue(key, out var session) ? session.LatestFrame : null;
}
/// <summary>Raised when a file plays out (natural end, no loop) — the layer is then holding its
/// last frame and the creator can start it again.</summary>
public event Action<string>? SessionCompleted;
/// <summary>True while a decode session exists for the file — the caller uses this to
/// keep one acquire per path instead of stacking refcounts on every layout pass.</summary>
public bool HasSession(string key)
@@ -204,6 +215,39 @@ public sealed class MediaVideoSourceManager : IDisposable
/// <summary>Applies a loop change to a running session (the creator flipped the
/// toggle). No-op when the file has no session.</summary>
/// <summary>False once the file has played to the end. A finished session keeps its last frame
/// (and its refcount), so "play again" is a restart, not a re-acquire.</summary>
public bool IsRunning(string key)
{
lock (_gate)
return _sessions.TryGetValue(key, out var session) && session.Started && !session.Finished;
}
/// <summary>Restarts a finished file from the top (the Loop switch or "Play again"). The session is
/// kept, so the shared preview bitmap and the frame consumers stay valid.</summary>
public async Task<bool> RestartAsync(string key)
{
MediaSession? session;
lock (_gate)
{
if (!_sessions.TryGetValue(key, out session)) return false;
session.Finished = false;
session.Started = false;
}
try
{
await session.Source.StartAsync().ConfigureAwait(false);
session.Started = true;
return true;
}
catch (Exception ex)
{
AppLog.Write($"MediaSource: restarting '{key}' failed: {ex.Message}");
return false;
}
}
public void SetLooping(string key, bool looping)
{
lock (_gate)
@@ -226,6 +270,17 @@ public sealed class MediaVideoSourceManager : IDisposable
}
}
private void OnSessionCompleted(MediaSession session)
{
if (!TryGetActiveSession(session)) return;
lock (_gate)
{
if (session.Source.Looping) return; // it will restart itself
session.Finished = true;
}
SessionCompleted?.Invoke(session.Key);
}
private void OnAudioAvailable(MediaSession session, AudioSample sample)
{
if (!TryGetActiveSession(session)) return;
@@ -274,6 +329,7 @@ public sealed class MediaVideoSourceManager : IDisposable
{
session.Source.FrameAvailable -= session.FrameHandler;
if (session.AudioHandler != null) session.Source.AudioAvailable -= session.AudioHandler;
if (session.CompletedHandler != null) session.Source.Completed -= session.CompletedHandler;
_ = SafeStopAsync(session.Source);
}
}
+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}");