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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user