diff --git a/SentryDeck.Tests/Fixtures/FakeCameraPlayer.cs b/SentryDeck.Tests/Fixtures/FakeCameraPlayer.cs index 6e96554..3eb1d1f 100644 --- a/SentryDeck.Tests/Fixtures/FakeCameraPlayer.cs +++ b/SentryDeck.Tests/Fixtures/FakeCameraPlayer.cs @@ -67,10 +67,17 @@ public async Task OpenAsync(string path) return OpenResult; } + /// + /// Optional hook invoked synchronously inside , mirroring . + /// Lets a test make the clip end while a play operation is still in flight, which is the ordering that used to leave the controller reporting playback on a finished clip. + /// + public Action PlayCallback { get; set; } + public Task PlayAsync() { PlayCount++; CallLog.Add("play"); + PlayCallback?.Invoke(); return Task.CompletedTask; } diff --git a/SentryDeck.Tests/VideoPlayerControllerTests.cs b/SentryDeck.Tests/VideoPlayerControllerTests.cs index 8769848..000dc2a 100644 --- a/SentryDeck.Tests/VideoPlayerControllerTests.cs +++ b/SentryDeck.Tests/VideoPlayerControllerTests.cs @@ -435,6 +435,59 @@ public async Task FrontMediaEnded_WithinTolerance_CompletesNormallyWithoutRebuil controller.IsMediaOpen.ShouldBeTrue(); } + [Fact] + public async Task PlayAsync_WhenTheClipEndsDuringTheCall_DoesNotReportPlaying() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 2); + var front = new FakeCameraPlayer(); + using var controller = CreateController(front); + + controller.LoadClips([clipFiles.Clip]); + controller.Playlist.MoveTo(0); + await WaitUntilClipOpenedAsync(controller, front); + + await controller.PauseAsync(); + var duration = controller.Duration; + + // Play pressed near the end, or queued behind a slow open: the clip runs out while the play operation is still in flight. + // The Ended handler clears IsPlaying, and the play call must not then set it back, or the transport claims to be playing a clip parked on its last frame until the user presses something else. + front.PlayCallback = () => + { + front.PlayCallback = null; + front.RaisePositionChanged(duration); + front.RaiseEnded(); + }; + + await controller.PlayAsync(); + + await Wait.UntilAsync(() => controller.Position == duration); + controller.IsPlaying.ShouldBeFalse(); + controller.Position.ShouldBe(duration); + // The clip is finished, not broken: the media stays open so the scrubber and frame-step still work. + controller.IsMediaOpen.ShouldBeTrue(); + controller.ErrorMessage.ShouldBeNull(); + } + + [Fact] + public async Task PlayAsync_WhenTheClipKeepsPlaying_ReportsPlaying() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 2); + var front = new FakeCameraPlayer(); + using var controller = CreateController(front); + + controller.LoadClips([clipFiles.Clip]); + controller.Playlist.MoveTo(0); + await WaitUntilClipOpenedAsync(controller, front); + + await controller.PauseAsync(); + controller.IsPlaying.ShouldBeFalse(); + + // The guard above must only suppress the state a finished clip already settled; an ordinary resume still reports playback. + await controller.PlayAsync(); + + controller.IsPlaying.ShouldBeTrue(); + } + [Fact] public async Task FrontMediaEnded_WithNextClip_StaysOnCurrentClipWithoutAdvancing() { diff --git a/SentryDeck/Playback/VideoPlayerController.cs b/SentryDeck/Playback/VideoPlayerController.cs index 4267e1e..e6f19fa 100644 --- a/SentryDeck/Playback/VideoPlayerController.cs +++ b/SentryDeck/Playback/VideoPlayerController.cs @@ -60,6 +60,9 @@ public sealed partial class VideoPlayerController : ObservableObject, IDisposabl private ClipMediaSource _openedMediaSource; private int _recoveryAttempts; + // Bumped every time playback ends on its own, so a play operation that is already in flight can tell that the clip finished underneath it. + private long _playbackEndedCount; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanPlayPause))] private bool _isLoading; @@ -182,8 +185,16 @@ await RunSerializedPlaybackOperationAsync(async _ => { if (IsMediaOpen && _openedClip == clip) { + // Snapshot before starting the players: a clip can reach its end while this operation is in flight, either because play was pressed close to the end or because the operation queued behind a slow open, and by the time it returns the Ended handler has already cleared IsPlaying. + // Re-asserting it here would leave the transport claiming to play a clip parked on its last frame, and nothing clears that until the user presses something else. + var endedCountBeforePlay = Interlocked.Read(ref _playbackEndedCount); + await PlayOpenPlayersAsync(); - IsPlaying = true; + + if (Interlocked.Read(ref _playbackEndedCount) == endedCountBeforePlay) + { + IsPlaying = true; + } } }); @@ -1013,6 +1024,7 @@ private async void OnPlayerEnded(object sender, EventArgs e) var wasPlaying = IsPlaying; IsPlaying = false; + Interlocked.Increment(ref _playbackEndedCount); // Deliberately NOT gated on IsLoading: the front plays (and can end or die on a corrupt first chunk) while the secondary-camera joins are still in flight, and IsLoading stays true until that whole open completes. // The request-id check above already filters the transitions IsLoading used to guard (clip changes zero _currentMediaRequestId first).