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
+23 -1
View File
@@ -256,7 +256,10 @@ public class Source : SceneElement
public bool MediaIsLooping
{
get => _mediaIsLooping;
set => Set(ref _mediaIsLooping, value);
set
{
if (Set(ref _mediaIsLooping, value)) Raise(nameof(CanPlayMedia));
}
}
private double _mediaVolume = 1.0;
@@ -275,6 +278,25 @@ public class Source : SceneElement
set => Set(ref _mediaMuted, value);
}
private bool _isMediaRunning;
/// <summary>True while the clip is decoding. False means it has played out and is holding its
/// last frame — the panel then offers "Play again" instead of leaving a frozen picture with no
/// explanation (the 2026-11-05 "clicking Loop did nothing" report).</summary>
public bool IsMediaRunning
{
get => _isMediaRunning;
set
{
if (Set(ref _isMediaRunning, value)) Raise(nameof(IsMediaFinished));
}
}
public bool IsMediaFinished => !IsMediaRunning;
/// <summary>Play is a manual, one-shot action: while Loop is on the clip restarts itself, so the
/// button would be a no-op (creator ruling 2026-11-05 — the button is beside the Video File label).</summary>
public bool CanPlayMedia => !MediaIsLooping;
private MediaPlaybackState _mediaPlaybackState = MediaPlaybackState.Stopped;
public MediaPlaybackState MediaPlaybackState
{