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