Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions SentryDeck.Tests/Fixtures/FakeCameraPlayer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,17 @@ public async Task<bool> OpenAsync(string path)
return OpenResult;
}

/// <summary>
/// Optional hook invoked synchronously inside <see cref="PlayAsync"/>, mirroring <see cref="SeekCallback"/>.
/// 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.
/// </summary>
public Action PlayCallback { get; set; }

public Task PlayAsync()
{
PlayCount++;
CallLog.Add("play");
PlayCallback?.Invoke();
return Task.CompletedTask;
}

Expand Down
53 changes: 53 additions & 0 deletions SentryDeck.Tests/VideoPlayerControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
14 changes: 13 additions & 1 deletion SentryDeck/Playback/VideoPlayerController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
});

Expand Down Expand Up @@ -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).
Expand Down