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:
@@ -193,7 +193,12 @@ public partial class MainViewModel
|
||||
|
||||
if (e.PropertyName == nameof(Source.MediaIsLooping) && MediaSessionKey(source) is { Length: > 0 } key)
|
||||
{
|
||||
_mediaManager.SetLooping(key, AnyMediaReferenceLoops(key));
|
||||
var looping = AnyMediaReferenceLoops(key);
|
||||
_mediaManager.SetLooping(key, looping);
|
||||
// Loop is only consulted at end-of-file by a LIVE decoder, so flipping it on a clip
|
||||
// that already finished used to do nothing at all (reported 2026-11-05). Start it.
|
||||
if (looping && !_mediaManager.IsRunning(key))
|
||||
_ = RestartMediaAsync(source, key);
|
||||
}
|
||||
else if (e.PropertyName is nameof(Source.MediaVolume) or nameof(Source.MediaMuted)
|
||||
&& MediaSessionKey(source) is { Length: > 0 } audioKey)
|
||||
@@ -242,6 +247,8 @@ public partial class MainViewModel
|
||||
if (source?.MediaPath is not { } path) return;
|
||||
|
||||
if (!await _mediaManager.AcquireAsync(sessionKey, path, source.ChromaKey)) return;
|
||||
source.IsMediaRunning = true;
|
||||
source.MediaPlaybackState = MediaPlaybackState.Playing;
|
||||
|
||||
// A session can be running before this layer existed (shared look), in which
|
||||
// case the bitmap is already published and will not be raised again for us.
|
||||
@@ -263,6 +270,42 @@ public partial class MainViewModel
|
||||
|
||||
/// <summary>A clip's own soundtrack joins the desktop/game group, so the desktop fader and
|
||||
/// the voice ducker treat it exactly like game audio (TASK 21 audio slice).</summary>
|
||||
/// <summary>A clip played out: say so on the layer, so the panel stops pretending it is playing.</summary>
|
||||
private void OnMediaSessionCompleted(string sessionKey)
|
||||
{
|
||||
foreach (var source in Scenes.SelectMany(s => s.Elements).OfType<Source>())
|
||||
if (source.IsMediaSource && MediaSessionKey(source) == sessionKey)
|
||||
{
|
||||
source.IsMediaRunning = false;
|
||||
source.MediaPlaybackState = MediaPlaybackState.Stopped;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Starts a finished clip again from the top (the panel's "Play again").</summary>
|
||||
private void ReplayMedia(Source? source)
|
||||
{
|
||||
if (source is not { IsMediaSource: true }) return;
|
||||
var key = MediaSessionKey(source);
|
||||
if (key.Length == 0) return;
|
||||
|
||||
_ = RestartMediaAsync(source, key);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task RestartMediaAsync(Source source, string sessionKey)
|
||||
{
|
||||
if (!_mediaManager.HasSession(sessionKey))
|
||||
{
|
||||
EnsureMediaSession(sessionKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await _mediaManager.RestartAsync(sessionKey))
|
||||
{
|
||||
source.IsMediaRunning = true;
|
||||
source.MediaPlaybackState = MediaPlaybackState.Playing;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMediaAudioAvailable(string sessionKey, AudioSample sample)
|
||||
=> _audioMixer.EnqueueDesktopMediaAudio(sample);
|
||||
|
||||
|
||||
@@ -192,6 +192,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
public ICommand ChangeMediaFileCommand { get; }
|
||||
/// <summary>Accepts the detector's green/blue-screen offer on a clip (TASK 64).</summary>
|
||||
public ICommand AcceptChromaSuggestionCommand { get; }
|
||||
/// <summary>Starts a finished clip again from the top.</summary>
|
||||
public ICommand ReplayMediaCommand { get; }
|
||||
public ICommand RemoveSourceCommand { get; }
|
||||
public ICommand EditElementCommand { get; }
|
||||
public ICommand ToggleElementVisibilityCommand { get; }
|
||||
@@ -268,6 +270,9 @@ public partial class MainViewModel : ViewModelBase
|
||||
ChangeMediaFileCommand = new RelayCommand(
|
||||
element => ChangeMediaFile(element as Source),
|
||||
element => element is Source { IsMediaSource: true });
|
||||
ReplayMediaCommand = new RelayCommand(
|
||||
element => ReplayMedia(element as Source),
|
||||
element => element is Source { IsMediaSource: true });
|
||||
AcceptChromaSuggestionCommand = new RelayCommand(
|
||||
element => AcceptChromaSuggestion(element as Source),
|
||||
element => element is Source { HasChromaSuggestion: true });
|
||||
@@ -425,6 +430,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
_mediaManager.PreviewBitmapChanged += OnMediaPreviewBitmapChanged;
|
||||
_mediaManager.MediaFailed += OnMediaFailed;
|
||||
_mediaManager.AudioAvailable += OnMediaAudioAvailable;
|
||||
_mediaManager.SessionCompleted += OnMediaSessionCompleted;
|
||||
|
||||
_chatLayer = new ChatOverlayLayer(new ChatBoxRenderer());
|
||||
_alertLayer = new AlertOverlayLayer(
|
||||
|
||||
Reference in New Issue
Block a user