diff --git a/SentryDeck.Data/CamChunk.cs b/SentryDeck.Data/CamChunk.cs index 1e154e0..37411ea 100644 --- a/SentryDeck.Data/CamChunk.cs +++ b/SentryDeck.Data/CamChunk.cs @@ -23,28 +23,50 @@ public CamChunk(DateTime timestamp, IEnumerable files) Files = BuildFileMap(files); } - // Keyed by camera name, keeping the first file for each camera. - // A duplicate suffix at one timestamp (two files mapping to the same camera, e.g. after a rear_view -> back alias) would otherwise make ToDictionary throw -- and since CamClip.TryMap swallows that, the WHOLE clip folder would be silently dropped. - // Keep-first + log instead so one stray file can't lose a clip. + // Keyed by camera name, one file per camera. + // Several files can claim one camera at a timestamp: numbered copies ("front-2.mp4") left by copying a drive, or a rear_view -> back alias collision. + // An unguarded ToDictionary would throw, and since CamClip.TryMap swallows that, the WHOLE clip folder would be silently dropped. + // The original wins, unless it's empty and a copy isn't; ties keep enumeration order. private static IReadOnlyDictionary BuildFileMap(IEnumerable files) { var map = new Dictionary(); - foreach (var file in files) + foreach (var group in files.GroupBy(file => file.Camera)) { - if (!map.TryAdd(file.Camera, file)) + var candidates = group + .OrderBy(file => IsEmpty(file) ? 1 : 0) + .ThenBy(file => file.CopyNumber) + .ToList(); + + map[group.Key] = candidates[0]; + + if (candidates.Count > 1) { - Log.Warning( - "Duplicate camera file at one timestamp; keeping the first and ignoring the rest. Camera={Camera}; Timestamp={Timestamp}; Ignored={IgnoredPath}", - file.Camera, - file.Timestamp, - file.FullPath); + Log.Debug( + "Several files for one camera at one timestamp; using one and ignoring the rest. Camera={Camera}; Timestamp={Timestamp}; Using={UsedPath}; Ignored={IgnoredPaths}", + group.Key, + candidates[0].Timestamp, + candidates[0].FullPath, + candidates.Skip(1).Select(file => file.FullPath).ToArray()); } } return map; } + private static bool IsEmpty(CamFile file) + { + try + { + var info = new FileInfo(file.FullPath); + return info.Exists && info.Length == 0; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + /// /// Groups valid media files by timestamp and keeps chunks with front-camera video. /// diff --git a/SentryDeck.Data/CamEvent.cs b/SentryDeck.Data/CamEvent.cs index 4726fb9..c193c79 100644 --- a/SentryDeck.Data/CamEvent.cs +++ b/SentryDeck.Data/CamEvent.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using Serilog; namespace SentryDeck; @@ -137,7 +138,18 @@ public static CamEvent FromFile(string path) if (!File.Exists(path)) return null; - var json = File.ReadAllText(path); + string json; + try + { + json = File.ReadAllText(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The event metadata is optional; a bad sector or locked file here must not hide the playable footage beside it. + Log.Warning(ex, "Could not read event metadata; loading the clip without it. File={File}", path); + return null; + } + return Deserialize(json); } } diff --git a/SentryDeck.Data/CamFile.cs b/SentryDeck.Data/CamFile.cs index 9bba9a2..194860b 100644 --- a/SentryDeck.Data/CamFile.cs +++ b/SentryDeck.Data/CamFile.cs @@ -23,11 +23,17 @@ public partial record class CamFile /// public string Camera { get; private init; } - public CamFile(string path, DateTime timestamp, string camera) + /// + /// Zero for an original recording; N for a numbered copy such as ...-front-2.mp4, which appears when a drive's contents are copied or merged onto another. + /// + public int CopyNumber { get; private init; } + + public CamFile(string path, DateTime timestamp, string camera, int copyNumber = 0) { FullPath = Path.GetFullPath(path); Timestamp = timestamp; Camera = camera; + CopyNumber = copyNumber; } /// @@ -59,11 +65,14 @@ private static CamFile TryMap(string path) } // Canonicalize legacy aliases (e.g. rear_view -> back) so old and new clips share one camera vocabulary. - // The capture stays greedy on purpose: an unrecognized suffix (a future camera) is kept as-is rather than dropped. - var camera = CameraNames.Canonicalize(match.Groups["camera"].Value); - return new CamFile(path, timestamp, camera); + // An unrecognized camera name (a future camera) is kept as-is rather than dropped. + var camera = CameraNames.Canonicalize(match.Groups["camera"].Value.ToLowerInvariant()); + var copyNumber = match.Groups["copy"].Success ? int.Parse(match.Groups["copy"].Value, CultureInfo.InvariantCulture) : 0; + return new CamFile(path, timestamp, camera, copyNumber); } - [GeneratedRegex(@"(?\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})-(?.+)\.mp4")] + // Anchored on both ends: an unanchored match picked up macOS "._" resource-fork files and names like "x.mp4.tmp.mp4", and on NTFS the "._" twin sorts first and displaced the real video. + // The camera is a plain identifier followed by an optional "-N" copy suffix, so "front-2.mp4" is a copy of front rather than a camera called "front-2". + [GeneratedRegex(@"^(?\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})-(?[a-z][a-z0-9_]*?)(?:-(?\d{1,3}))?\.mp4$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex FileNameRegex(); } diff --git a/SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs b/SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs index aa1b24b..0251771 100644 --- a/SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs +++ b/SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs @@ -125,6 +125,11 @@ public ClipMediaSource Build(CamClip clip, IReadOnlySet excludedChunkIndice return new ClipMediaSource(duration, chunkStarts, playlistPaths, autoExcludedIndices, chunkTimestamps, chunkDurations, clipStartTimestamp); } + /// + /// Tesla writes chunks of about a minute; a header claiming far more is corrupt, and trusting it would stretch the clip's timeline by hours of footage that isn't there. + /// + private static readonly TimeSpan MaxPlausibleChunkDuration = TimeSpan.FromMinutes(10); + private static TimeSpan? ProbeFrontChunkDuration(CamChunk chunk) { if (!chunk.Files.TryGetValue(CameraNames.Front, out var frontFile)) @@ -136,7 +141,7 @@ public ClipMediaSource Build(CamClip clip, IReadOnlySet excludedChunkIndice } var probed = Mp4DurationReader.TryReadDuration(frontFile.FullPath); - if (probed is { } duration && duration > TimeSpan.Zero) + if (probed is { } duration && duration > TimeSpan.Zero && duration <= MaxPlausibleChunkDuration) { return duration; } @@ -150,7 +155,7 @@ public ClipMediaSource Build(CamClip clip, IReadOnlySet excludedChunkIndice private static bool IsProbeable(string path) { - return Mp4DurationReader.TryReadDuration(path) is { } duration && duration > TimeSpan.Zero; + return Mp4DurationReader.TryReadDuration(path) is { } duration && duration > TimeSpan.Zero && duration <= MaxPlausibleChunkDuration; } private static void WritePlaylist(string path, IReadOnlyList<(string FilePath, TimeSpan Duration)> entries) diff --git a/SentryDeck.Data/Playback/ICameraPlayer.cs b/SentryDeck.Data/Playback/ICameraPlayer.cs index 17fd398..9f1bff5 100644 --- a/SentryDeck.Data/Playback/ICameraPlayer.cs +++ b/SentryDeck.Data/Playback/ICameraPlayer.cs @@ -1,25 +1,41 @@ namespace SentryDeck; +/// +/// One camera's video player. +/// +/// +/// Implementations raise every event on the thread that owns the controller (the UI thread in the app), and never for media that has since been closed or replaced, so the controller can treat each event as current and handle it without locks. +/// Commands may block internally (the real player spins while its threads wind down), so implementations must keep that work off the caller's thread. +/// public interface ICameraPlayer : IDisposable { - event EventHandler Opened; event EventHandler Ended; event EventHandler Failed; event EventHandler PositionChanged; bool IsOpen { get; } + + /// + /// True while the player is parked at end of stream. + /// An ended player ignores Play until it is seeked, so callers check this before resuming. + /// + bool IsEnded { get; } + double Speed { get; set; } + TimeSpan Position { get; } Task OpenAsync(string path); + Task PlayAsync(); + Task PauseAsync(); - Task StopAsync(); + Task CloseAsync(); /// - /// Seeks to . - /// Accurate seeks (the default) decode forward to land exactly on the target frame; fast seeks jump to the nearest keyframe, which is far cheaper but can land slightly before the target -- intended for live scrubbing while the seek bar thumb is being dragged, where responsiveness matters more than frame precision. + /// Seeks to and completes once the player has presented the target frame. + /// Accurate seeks (the default) decode forward to land exactly on the target frame; fast seeks jump to the nearest keyframe, which is far cheaper and suits live scrubbing. /// Task SeekAsync(TimeSpan position, bool accurate = true); diff --git a/SentryDeck.Data/Playback/Mp4DurationReader.cs b/SentryDeck.Data/Playback/Mp4DurationReader.cs index 6d5d1a0..0ba0c87 100644 --- a/SentryDeck.Data/Playback/Mp4DurationReader.cs +++ b/SentryDeck.Data/Playback/Mp4DurationReader.cs @@ -30,12 +30,9 @@ public static class Mp4DurationReader var (mvhdStart, mvhdEnd) = mvhdBox.Value; return ReadMvhdDuration(stream, mvhdStart, mvhdEnd); } - catch (IOException) - { - return null; - } - catch (UnauthorizedAccessException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or OverflowException) { + // A corrupt header can claim box sizes or durations no real file has; any of them means "no readable duration", never a crash that takes the whole clip down with it. return null; } } @@ -72,7 +69,7 @@ private static (long ContentStart, long ContentEnd)? FindBox(FileStream stream, size = end - position; } - if (size < headerSize) + if (size < headerSize || size > end - position) return null; var contentStart = position + headerSize; diff --git a/SentryDeck.Tests/CamDiscoveryResilienceTests.cs b/SentryDeck.Tests/CamDiscoveryResilienceTests.cs index 5ceb6dc..b2c5ae1 100644 --- a/SentryDeck.Tests/CamDiscoveryResilienceTests.cs +++ b/SentryDeck.Tests/CamDiscoveryResilienceTests.cs @@ -111,4 +111,102 @@ public void Map_BackAndRearViewAtOneTimestamp_KeepsOneChunkAndDoesNotDropTheClip CamClip.Map(temp.Path).ShouldNotBeNull(); } + + [Fact] + public void FindFiles_NumberedCopySuffix_IsTheSameCameraMarkedAsACopy() + { + // Copying or merging a drive leaves "-2" twins beside the originals; they used to become bogus cameras named "front-2". + using var temp = new TempDirectory(); + Touch(temp.Path, "2023-02-23_14-14-48-front.mp4"); + Touch(temp.Path, "2023-02-23_14-14-48-front-2.mp4"); + Touch(temp.Path, "2023-02-23_14-14-48-left_repeater-2.mp4"); + + var files = CamFile.FindFiles(temp.Path).ToList(); + + files.Select(file => (file.Camera, file.CopyNumber)).ShouldBe( + [(CameraNames.Front, 0), (CameraNames.Front, 2), (CameraNames.LeftRepeater, 2)], + ignoreOrder: true); + } + + [Theory] + [InlineData("._2023-02-23_14-14-48-front.mp4")] // macOS resource fork left by copying through a Mac + [InlineData("x-2023-02-23_14-14-48-front.mp4")] + [InlineData("2023-02-23_14-14-48-front.mp4.tmp.mp4")] + public void FindFiles_NameWithExtraText_IsIgnored(string name) + { + using var temp = new TempDirectory(); + Touch(temp.Path, name); + + CamFile.FindFiles(temp.Path).ShouldBeEmpty(); + } + + [Fact] + public void FindFiles_UppercaseName_ParsesTheCanonicalCamera() + { + using var temp = new TempDirectory(); + Touch(temp.Path, "2023-02-23_14-14-48-FRONT.MP4"); + + CamFile.FindFiles(temp.Path).ShouldHaveSingleItem().Camera.ShouldBe(CameraNames.Front); + } + + [Fact] + public void Map_MacResourceForkBesideTheRealFile_KeepsTheRealFile() + { + // On NTFS the "._" twin enumerates first, so keep-first used to hand the player a 4 KB metadata file and the chunk was dropped as unreadable. + using var temp = new TempDirectory(); + File.WriteAllBytes(Path.Combine(temp.Path, "._2023-02-23_14-14-48-front.mp4"), new byte[4096]); + Touch(temp.Path, "2023-02-23_14-14-48-front.mp4"); + + var chunk = CamChunk.Map(temp.Path).ShouldHaveSingleItem(); + + Path.GetFileName(chunk.Files[CameraNames.Front].FullPath).ShouldBe("2023-02-23_14-14-48-front.mp4"); + } + + [Fact] + public void Map_OriginalAndNumberedCopy_PrefersTheOriginal() + { + using var temp = new TempDirectory(); + File.WriteAllBytes(Path.Combine(temp.Path, "2023-02-23_14-14-48-front-2.mp4"), [1]); + File.WriteAllBytes(Path.Combine(temp.Path, "2023-02-23_14-14-48-front.mp4"), [1]); + + var chunk = CamChunk.Map(temp.Path).ShouldHaveSingleItem(); + + chunk.Files.Keys.ShouldBe([CameraNames.Front]); + chunk.Files[CameraNames.Front].CopyNumber.ShouldBe(0); + } + + [Fact] + public void Map_EmptyOriginalWithANonEmptyCopy_UsesTheCopy() + { + using var temp = new TempDirectory(); + Touch(temp.Path, "2023-02-23_14-14-48-front.mp4"); + File.WriteAllBytes(Path.Combine(temp.Path, "2023-02-23_14-14-48-front-2.mp4"), [1]); + + var chunk = CamChunk.Map(temp.Path).ShouldHaveSingleItem(); + + chunk.Files[CameraNames.Front].CopyNumber.ShouldBe(2); + } + + [Fact] + public void Map_OnlyANumberedCopyOfTheFront_StillKeepsTheChunk() + { + using var temp = new TempDirectory(); + File.WriteAllBytes(Path.Combine(temp.Path, "2023-02-23_14-14-48-front-2.mp4"), [1]); + File.WriteAllBytes(Path.Combine(temp.Path, "2023-02-23_14-14-48-back.mp4"), [1]); + + var chunk = CamChunk.Map(temp.Path).ShouldHaveSingleItem(); + + chunk.Files.Keys.ShouldBe([CameraNames.Front, CameraNames.Back], ignoreOrder: true); + } + + [Fact] + public void CamEventFromFile_WhenTheFileCannotBeRead_ReturnsNullSoTheClipStillLoads() + { + using var temp = new TempDirectory(); + var path = Path.Combine(temp.Path, "event.json"); + File.WriteAllText(path, "{}"); + using var exclusive = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None); + + CamEvent.FromFile(path).ShouldBeNull(); + } } diff --git a/SentryDeck.Tests/ConverterTests.cs b/SentryDeck.Tests/ConverterTests.cs index 939022f..fab640b 100644 --- a/SentryDeck.Tests/ConverterTests.cs +++ b/SentryDeck.Tests/ConverterTests.cs @@ -1,6 +1,7 @@ using System.IO; using System.Windows; using System.Windows.Media; +using System.Windows.Media.Imaging; namespace SentryDeck.Tests; @@ -206,6 +207,19 @@ public void ThumbnailConverter_UndecodableThumbnail_YieldsNoImage() new ThumbnailConverter().Convert(thumbnail.Path, typeof(ImageSource), null, null).ShouldBeNull(); } + [Fact] + public void ThumbnailConverter_ValidThumbnail_YieldsADecodedImageAndReleasesTheFile() + { + using var thumbnail = new TempFile(BuildPng(width: 384, height: 288), ".png"); + + var image = new ThumbnailConverter().Convert(thumbnail.Path, typeof(ImageSource), null, null).ShouldBeOfType(); + + // Decoded straight to list size, and fully read up front: the file must not stay locked, or deleting the clip's folder fails. + image.PixelWidth.ShouldBe(192); + image.IsFrozen.ShouldBeTrue(); + using var exclusive = new FileStream(thumbnail.Path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + } + [Fact] public void ThumbnailConverter_FallbackParameter_IsVisibleOnlyWhenTheFileIsMissing() { @@ -236,6 +250,17 @@ private static CamClip ClipWithChunks(int chunkCount) return new CamClip(Path.GetTempPath(), "Test Clip", Moment, chunks, camEvent: null); } + private static byte[] BuildPng(int width, int height) + { + var pixels = new byte[width * height * 4]; + var source = BitmapSource.Create(width, height, 96, 96, PixelFormats.Bgra32, null, pixels, width * 4); + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(source)); + using var stream = new MemoryStream(); + encoder.Save(stream); + return stream.ToArray(); + } + private static string MissingThumbnailPath() => Path.Combine(Path.GetTempPath(), $"SentryDeckTests-{Guid.NewGuid():N}.png"); } diff --git a/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs b/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs index 4a5b1c9..7d8747b 100644 --- a/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs +++ b/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs @@ -307,6 +307,19 @@ public void Build_FrontFileProbesToZeroDuration_AutoExcludesChunk() mediaSource.Duration.ShouldBe(TimeSpan.FromSeconds(120)); } + [Fact] + public void Build_FrontFileClaimingHoursOfFootage_AutoExcludesChunk() + { + // A corrupt header can report an absurd but representable duration; trusted, it stretched the timeline by hours of footage that isn't there. + using var clipFiles = TestClipFiles.Create(chunkCount: 3); + File.WriteAllBytes(clipFiles.GetPath(1, CameraNames.Front), TestMp4.Build(version: 0, timescale: 1, duration: uint.MaxValue)); + + var mediaSource = Build(clipFiles.Clip); + + mediaSource.AutoExcludedChunkIndices.ShouldBe([1]); + mediaSource.Duration.ShouldBe(TimeSpan.FromSeconds(120)); + } + [Fact] public void Build_ChunkWithNoFrontFile_IsAutoExcluded() { diff --git a/SentryDeck.Tests/Fixtures/FakeCameraPlayer.cs b/SentryDeck.Tests/Fixtures/FakeCameraPlayer.cs index 3eb1d1f..b75ccec 100644 --- a/SentryDeck.Tests/Fixtures/FakeCameraPlayer.cs +++ b/SentryDeck.Tests/Fixtures/FakeCameraPlayer.cs @@ -3,150 +3,229 @@ namespace SentryDeck.Tests; /// /// In-memory used to drive a real in tests without Flyleaf/FFmpeg. /// +/// +/// Models the parts of the real Flyleaf player that the controller has to cope with: an ended player ignores Play until it is seeked, a failure closes the player, a closed player ignores seeks, and a disposed player throws. +/// Commands complete synchronously unless a gate holds them, so tests stay deterministic; the controller runs camera commands in parallel, so every piece of bookkeeping is behind a lock and handed out as a snapshot. +/// internal sealed class FakeCameraPlayer : ICameraPlayer { - public event EventHandler Opened; + public static readonly TimeSpan FrameDuration = TimeSpan.FromSeconds(1.0 / 36); + + private readonly Lock _lock = new(); + private readonly List _openedPaths = []; + private readonly List _calls = []; + private readonly List<(TimeSpan Position, bool Accurate)> _seeks = []; + private double _speed = 1.0; + public event EventHandler Ended; public event EventHandler Failed; public event EventHandler PositionChanged; - public List OpenedPaths { get; } = []; - public List SeekPositions { get; } = []; + public bool OpenResult { get; init; } = true; - /// Ordered log of the accurate flag passed to each call. - public List SeekAccurateFlags { get; } = []; + public bool ThrowOnClose { get; init; } - /// - /// Ordered log of play/pause/seek calls so tests can assert on call ordering (e.g. that a post-recovery resume plays before it seeks). - /// - public List CallLog { get; } = []; - public bool OpenResult { get; init; } = true; - public bool ThrowOnStop { get; init; } - public TaskCompletionSource StopGate { get; set; } - public bool IsOpen { get; private set; } - public double Speed { get; set; } = 1.0; - - /// - /// Test-controlled position, used by the controller to read a camera's "live" position (e.g. the front player's current time when joining a secondary camera mid-playback). - /// Defaults to zero and is kept in sync by so tests behave sensibly without having to poke it manually after every seek. - /// - public TimeSpan Position { get; set; } - - /// - /// Optional gate that, when set, makes await it before completing -- lets tests hold a camera's open in progress to assert on ordering/timing. - /// - public TaskCompletionSource OpenGate { get; set; } - - /// - /// Optional hook invoked synchronously inside (after recording the call, before updating ) -- lets tests interleave actions mid-seek (e.g. a new drag gesture starting while the previous release's accurate seek is still executing) without leaving the test thread. - /// + /// When set, waits on it, holding the open in progress so a test can act mid-open. + public TaskCompletionSource OpenGate { get; set; } + + /// When set, waits on it after recording the call and before playback starts. + public TaskCompletionSource PlayGate { get; set; } + + /// Invoked inside after the seek is recorded, so a test can act while a seek is executing. public Action SeekCallback { get; set; } - public int PlayCount { get; private set; } - public int PauseCount { get; private set; } - public int StopCount { get; private set; } - public int CloseCount { get; private set; } - public int DisposeCount { get; private set; } + public bool IsOpen { get; private set; } - public async Task OpenAsync(string path) - { - OpenedPaths.Add(path); + public bool IsEnded { get; private set; } - if (OpenGate is not null) - { - await OpenGate.Task; - } + public bool IsPlaying { get; private set; } - IsOpen = OpenResult; + public bool IsDisposed { get; private set; } - if (OpenResult) + public TimeSpan Position { get; private set; } + + public double Speed + { + get => _speed; + set { - Opened?.Invoke(this, EventArgs.Empty); + // The real player ignores non-positive speeds. + if (value > 0) + { + _speed = value; + } } + } - return OpenResult; + public List OpenedPaths + { + get + { + lock (_lock) + { + return [.. _openedPaths]; + } + } } - /// - /// 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; } + /// Ordered log of commands: open, play, pause, close, seek:{seconds}, scrub:{seconds}, step:forward, step:backward. + public List Calls + { + get + { + lock (_lock) + { + return [.. _calls]; + } + } + } - public Task PlayAsync() + public IReadOnlyList<(TimeSpan Position, bool Accurate)> Seeks { - PlayCount++; - CallLog.Add("play"); - PlayCallback?.Invoke(); - return Task.CompletedTask; + get + { + lock (_lock) + { + return [.. _seeks]; + } + } } - public Task PauseAsync() + public int Count(string call) => Calls.Count(entry => entry == call); + + public async Task OpenAsync(string path) { - PauseCount++; - CallLog.Add("pause"); - return Task.CompletedTask; + ThrowIfDisposed(); + Record("open", () => _openedPaths.Add(path)); + IsOpen = false; + IsEnded = false; + IsPlaying = false; + Position = TimeSpan.Zero; + + if (OpenGate is { } gate) + { + await gate.Task; + } + + IsOpen = OpenResult; + return OpenResult; } - public Task StopAsync() + public async Task PlayAsync() { - StopCount++; - if (StopGate is not null) + ThrowIfDisposed(); + Record("play"); + + if (PlayGate is { } gate) { - return StopGate.Task; + await gate.Task; } - return ThrowOnStop - ? Task.FromException(new InvalidOperationException("stop failed")) - : Task.CompletedTask; + // Flyleaf silently ignores Play on an ended player. + if (IsOpen && !IsEnded) + { + IsPlaying = true; + } + } + + public Task PauseAsync() + { + ThrowIfDisposed(); + Record("pause"); + IsPlaying = false; + return Task.CompletedTask; } public Task CloseAsync() { - CloseCount++; + ThrowIfDisposed(); + Record("close"); IsOpen = false; - return Task.CompletedTask; + IsEnded = false; + IsPlaying = false; + + return ThrowOnClose + ? Task.FromException(new InvalidOperationException("close failed")) + : Task.CompletedTask; } public Task SeekAsync(TimeSpan position, bool accurate = true) { - SeekPositions.Add(position); - SeekAccurateFlags.Add(accurate); - CallLog.Add(accurate ? $"seek:{position.TotalSeconds}" : $"scrub:{position.TotalSeconds}"); + ThrowIfDisposed(); + + // The real player drops seeks on a closed player. + if (!IsOpen) + { + return Task.CompletedTask; + } + + Record(accurate ? $"seek:{position.TotalSeconds}" : $"scrub:{position.TotalSeconds}", () => _seeks.Add((position, accurate))); SeekCallback?.Invoke(); - Position = position; - PositionChanged?.Invoke(this, new CameraPositionChangedEventArgs(position)); + IsEnded = false; + MoveTo(position); return Task.CompletedTask; } - /// Ordered log of calls: "forward" or "backward". - public List StepLog { get; } = []; - public Task StepFrameAsync(bool forward) { - StepLog.Add(forward ? "forward" : "backward"); - CallLog.Add(forward ? "step:forward" : "step:backward"); + ThrowIfDisposed(); + + if (!IsOpen) + { + return Task.CompletedTask; + } + + Record(forward ? "step:forward" : "step:backward"); + IsEnded = false; + IsPlaying = false; + MoveTo(forward ? Position + FrameDuration : Position - FrameDuration); return Task.CompletedTask; } - public void RaiseEnded() + /// Simulates playback reaching the end of the stream: the player parks there and reports it. + public void RaiseEnded(TimeSpan? at = null) { + if (at is { } position) + { + Position = position; + } + + IsEnded = true; + IsPlaying = false; Ended?.Invoke(this, EventArgs.Empty); } + /// Simulates a playback failure, which the real adapter treats as closing the media. public void RaiseFailed(Exception exception) { + IsOpen = false; + IsPlaying = false; Failed?.Invoke(this, new CameraPlaybackFailedEventArgs(exception)); } - public void RaisePositionChanged(TimeSpan position) + /// Simulates playback advancing to . + public void RaisePositionChanged(TimeSpan position) => MoveTo(position); + + public void Dispose() + { + IsDisposed = true; + IsOpen = false; + } + + private void MoveTo(TimeSpan position) { - Position = position; - PositionChanged?.Invoke(this, new CameraPositionChangedEventArgs(position)); + Position = position < TimeSpan.Zero ? TimeSpan.Zero : position; + PositionChanged?.Invoke(this, new CameraPositionChangedEventArgs(Position)); } - public void Dispose() + private void Record(string call, Action extra = null) { - DisposeCount++; + lock (_lock) + { + _calls.Add(call); + extra?.Invoke(); + } } + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(IsDisposed, this); } diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs index cb9dbab..98e9a63 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs @@ -332,16 +332,16 @@ public async Task DeleteClip_TheOpenClip_StopsPlaybackBeforeRecycling() { using var clipFiles = TestClipFiles.Create(chunkCount: 1); var (vm, _, front) = CreateViewModelWithOpenedClip(clipFiles.Clip, uiInvoker: action => action()); - var stopsBeforeDelete = front.StopCount; - var stopsWhenRecycled = -1; + var closesBeforeDelete = front.Count("close"); + var closesWhenRecycled = -1; vm.ConfirmDeleteClip = _ => true; - vm.RecycleClipFolder = _ => stopsWhenRecycled = front.StopCount; + vm.RecycleClipFolder = _ => closesWhenRecycled = front.Count("close"); vm.SeekPosition = 0.5; await vm.DeleteClipCommand.ExecuteAsync(clipFiles.Clip); // Windows can't recycle a folder whose files are still locked, so playback must already be stopped when the shell operation runs -- not merely by the time delete returns. - stopsWhenRecycled.ShouldBeGreaterThan(stopsBeforeDelete); + closesWhenRecycled.ShouldBeGreaterThan(closesBeforeDelete); vm.SeekPosition.ShouldBe(0); } diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Keyboard.cs b/SentryDeck.Tests/MainWindowViewModelTests.Keyboard.cs index dac1325..482a5ec 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Keyboard.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Keyboard.cs @@ -16,18 +16,18 @@ public void ArrowKeys_SeekFiveSecondsAndClampAtTheEnds() var (vm, controller, front) = CreateViewModelWithOpenedClip(clipFiles.Clip); RunPinnedToTestThread(() => vm.HandleKeyDownAsync(Key.Right, ModifierKeys.None)); - front.SeekPositions[^1].ShouldBe(TimeSpan.FromSeconds(5)); + front.Seeks[^1].Position.ShouldBe(TimeSpan.FromSeconds(5)); RunPinnedToTestThread(() => vm.HandleKeyDownAsync(Key.Left, ModifierKeys.None)); RunPinnedToTestThread(() => vm.HandleKeyDownAsync(Key.Left, ModifierKeys.None)); // Nudging back past the start parks on the first frame instead of seeking to a negative time. - front.SeekPositions[^1].ShouldBe(TimeSpan.Zero); + front.Seeks[^1].Position.ShouldBe(TimeSpan.Zero); controller.Position = TimeSpan.FromSeconds(60); // parked at the very end RunPinnedToTestThread(() => vm.HandleKeyDownAsync(Key.Right, ModifierKeys.None)); - front.SeekPositions[^1].ShouldBe(TimeSpan.FromSeconds(60)); + front.Seeks[^1].Position.ShouldBe(TimeSpan.FromSeconds(60)); } [Fact] @@ -35,14 +35,14 @@ public void Space_TogglesPlayPause() { using var clipFiles = TestClipFiles.Create(chunkCount: 1); var (vm, _, front) = CreateViewModelWithOpenedClip(clipFiles.Clip); - var playsAfterOpen = front.PlayCount; - var pausesAfterOpen = front.PauseCount; + var playsAfterOpen = front.Count("play"); + var pausesAfterOpen = front.Count("pause"); RunPinnedToTestThread(() => vm.HandleKeyDownAsync(Key.Space, ModifierKeys.None)); - front.PauseCount.ShouldBe(pausesAfterOpen + 1); // the clip was playing after the open + front.Count("pause").ShouldBe(pausesAfterOpen + 1); // the clip was playing after the open RunPinnedToTestThread(() => vm.HandleKeyDownAsync(Key.Space, ModifierKeys.None)); - front.PlayCount.ShouldBe(playsAfterOpen + 1); + front.Count("play").ShouldBe(playsAfterOpen + 1); } [Fact] @@ -54,7 +54,49 @@ public void CommaAndPeriod_StepFrames_OnlyWhenSeekable() RunPinnedToTestThread(() => vm.HandleKeyDownAsync(Key.OemPeriod, ModifierKeys.None)); RunPinnedToTestThread(() => vm.HandleKeyDownAsync(Key.OemComma, ModifierKeys.None)); - front.StepLog.ShouldBe(["forward", "backward"]); + front.Calls.Where(call => call.StartsWith("step:")).ShouldBe(["step:forward", "step:backward"]); + } + + [Fact] + public void FrameStepKeys_WithNothingOpen_AreNotHandled() + { + // Unhandled, the key stays available to whatever control has focus instead of being swallowed for a step that can't happen. + var vm = CreateViewModelWithController(out _, out var front); + + vm.HandleKeyDown(Key.OemPeriod, ModifierKeys.None).ShouldBeFalse(); + vm.HandleKeyDown(Key.OemComma, ModifierKeys.None).ShouldBeFalse(); + front.Calls.ShouldBeEmpty(); + } + + [Fact] + public void HandleKeyDown_Space_ReportsHandledBeforeThePlayerFinishes() + { + // The view marks the key handled from the return value, and WPF has finished routing by the time any awaited work resumes. + // Returning only once playback started let a focused button act on the same Space press (clicking itself again instead of play/pause). + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var (vm, controller, front) = CreateViewModelWithOpenedClip(clipFiles.Clip); + RunPinnedToTestThread(controller.PauseAsync); + var gate = new TaskCompletionSource(); + front.PlayGate = gate; + + var handled = vm.HandleKeyDown(Key.Space, ModifierKeys.None); + + handled.ShouldBeTrue(); + controller.IsPlaying.ShouldBeFalse(); + + gate.SetResult(); + RunPinnedToTestThread(controller.WhenIdleAsync); + controller.IsPlaying.ShouldBeTrue(); + } + + [Fact] + public void HandleKeyDown_KeyThatIsNotAShortcut_IsLeftForTheFocusedControl() + { + var vm = CreateViewModelWithController(out _, out _); + + vm.HandleKeyDown(Key.A, ModifierKeys.None).ShouldBeFalse(); + vm.HandleKeyDown(Key.Enter, ModifierKeys.None).ShouldBeFalse(); + vm.HandleKeyDown(Key.Up, ModifierKeys.None).ShouldBeFalse(); } [Fact] @@ -62,14 +104,14 @@ public void StopCommand_ClearsNowPlayingClip() { using var clipFiles = TestClipFiles.Create(chunkCount: 1); var (vm, _, front) = CreateViewModelWithOpenedClip(clipFiles.Clip); - var stopsAfterOpen = front.StopCount; + var closesAfterOpen = front.Count("close"); vm.SelectedClip = clipFiles.Clip; // sets NowPlayingClip too (see OnSelectedClipChanged) RunPinnedToTestThread(() => vm.StopCommand.ExecuteAsync(null)); // Stop is the only thing that takes the now-playing badge off the clip list; leaving it set would mark a clip as playing with nothing loaded. vm.NowPlayingClip.ShouldBeNull(); - front.StopCount.ShouldBeGreaterThan(stopsAfterOpen); + front.Count("close").ShouldBeGreaterThan(closesAfterOpen); } [Fact] diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Markers.cs b/SentryDeck.Tests/MainWindowViewModelTests.Markers.cs index bee454e..3c13c52 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Markers.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Markers.cs @@ -190,26 +190,26 @@ public void JumpToEvent_CanExecute_FollowsHasEventMarker() } [Fact] - public async Task JumpToEvent_MovesSeekPositionToMarker() + public void EventShortcut_WithAnOpenedEventClip_SeeksThePlayersToTheEventMoment() { - var vm = CreateViewModel(); - vm.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570)); - - await vm.JumpToEventCommand.ExecuteAsync(null); - - vm.SeekPosition.ShouldBe(vm.EventMarkerPosition, 0.0001); - } - - [Fact] - public async Task EventShortcut_JumpsToEvent_WhenMarkerPresent() - { - var vm = CreateViewModel(); - vm.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570)); + // The clip opens 10s before its event (80s of 180s); E must move the actual video to the event itself (90s), not just the seek bar. + using var clipFiles = TestClipFiles.Create(chunkCount: 3); + var clip = new CamClip( + clipFiles.Clip.FullPath, + clipFiles.Clip.Name, + clipFiles.Clip.Timestamp, + clipFiles.Clip.Chunks, + new CamEvent { Timestamp = clipFiles.Clip.Chunks[1].Timestamp.AddSeconds(30) }); + var (vm, controller, front) = CreateViewModelWithOpenedClip(clip); + vm.SelectedClip = clip; - var handled = await vm.HandleKeyDownAsync(Key.E, ModifierKeys.None); + var handled = vm.HandleKeyDown(Key.E, ModifierKeys.None); + RunPinnedToTestThread(controller.WhenIdleAsync); handled.ShouldBeTrue(); - vm.SeekPosition.ShouldBe(vm.EventMarkerPosition, 0.0001); + front.Seeks[^1].Position.ShouldBe(TimeSpan.FromSeconds(90)); + controller.Position.ShouldBe(TimeSpan.FromSeconds(90)); + vm.SeekPosition.ShouldBe(0.5, 0.0001); } [Fact] diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs index a7a339d..6341f12 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs @@ -77,19 +77,18 @@ public void DragSequence_IssuesFastSeeks_ReleaseIssuesAccurateSeekAtReleasePosit vm.SeekPosition = 0.5; // 30s vm.OnSeekSliderValueChanged(); - front.SeekPositions.ShouldContain(TimeSpan.FromSeconds(12)); - front.SeekPositions.ShouldContain(TimeSpan.FromSeconds(30)); + front.Seeks.ShouldContain((TimeSpan.FromSeconds(12), false)); + front.Seeks.ShouldContain((TimeSpan.FromSeconds(30), false)); // Every seek issued so far while dragging must have been fast (non-accurate). - front.SeekAccurateFlags.ShouldAllBe(accurate => accurate == false); + front.Seeks.ShouldAllBe(seek => !seek.Accurate); // Release at 0.75 (45s): EndSeekAsync must issue exactly one ACCURATE seek at the release position. vm.SeekPosition = 0.75; RunPinnedToTestThread(vm.EndSeekAsync); - front.SeekPositions[^1].ShouldBe(TimeSpan.FromSeconds(45)); - front.SeekAccurateFlags[^1].ShouldBeTrue(); + front.Seeks[^1].ShouldBe((TimeSpan.FromSeconds(45), true)); } // Synchronous for the same thread-affinity reason as DragSequence above (see RunPinnedToTestThread). @@ -129,13 +128,13 @@ public void PositionSync_WhenNotDragging_DoesNotTriggerScrubSeeks() using var clipFiles = TestClipFiles.Create(chunkCount: 1); var (vm, controller, front) = CreateViewModelWithOpenedClip(clipFiles.Clip); - front.SeekPositions.Clear(); + var seeksBefore = front.Seeks.Count; // Playback position advances on its own (not a drag): SeekPosition updates via the controller -> UpdateSeekPositionFromController path, which does not go through OnSeekSliderValueChanged, so no scrub seek should ever be issued. controller.Position = TimeSpan.FromSeconds(10); vm.OnSeekSliderValueChanged(); // the view raises ValueChanged for programmatic changes too - front.SeekPositions.ShouldBeEmpty(); + front.Seeks.Count.ShouldBe(seeksBefore); } [Fact] @@ -176,7 +175,7 @@ public void CanGoNextPrevious_ReflectControllerPlaylist() } [Fact] - public void SelectingClip_TriggersPlaybackLoading() + public void SelectingClip_NotYetOpened_ShowsLoadingWithoutAnError() { var clip = TestClips.Create(1)[0]; var vm = CreateViewModelWithController(out _, out _); @@ -202,4 +201,26 @@ public void SelectingAnEventClip_AutoFocusesTheTriggeringCamera() // Opening an incident on the angle that triggered it is the whole point of the metadata. vm.SelectedCameraView.ShouldBe(CameraNames.Back); } + + [Fact] + public void StopCommand_WhileASelectionIsWaitingToLoad_KeepsItFromPlaying() + { + // Selecting a clip yields to the UI before loading it; a stop in that window used to be undone by the load starting right after. + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var front = new FakeCameraPlayer(); + var controller = BuildFourCameraController(front); + controller.LoadClips([clipFiles.Clip]); + var loadGate = new TaskCompletionSource(); + var vm = new MainWindowViewModel(() => controller, backgroundYield: () => loadGate.Task); + vm.InitializePlayer(); + + vm.SelectedClip = clipFiles.Clip; + RunPinnedToTestThread(() => vm.StopCommand.ExecuteAsync(null)); + loadGate.SetResult(); + RunPinnedToTestThread(controller.WhenIdleAsync); + + front.OpenedPaths.ShouldBeEmpty(); + controller.IsPlaying.ShouldBeFalse(); + vm.IsLoading.ShouldBeFalse(); + } } diff --git a/SentryDeck.Tests/MainWindowViewModelTests.cs b/SentryDeck.Tests/MainWindowViewModelTests.cs index 1fedc66..30bd128 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.cs @@ -122,7 +122,7 @@ private VideoPlayerController BuildFourCameraController(FakeCameraPlayer front) built.LoadClips([clip]); built.Playlist.MoveTo(0); - Wait.UntilAsync(() => front.PlayCount > 0 && built.IsMediaOpen && !built.IsLoading).GetAwaiter().GetResult(); + built.WhenIdleAsync().GetAwaiter().GetResult(); var vm = new MainWindowViewModel( () => built, diff --git a/SentryDeck.Tests/Mp4DurationReaderTests.cs b/SentryDeck.Tests/Mp4DurationReaderTests.cs index d4f0be4..4281148 100644 --- a/SentryDeck.Tests/Mp4DurationReaderTests.cs +++ b/SentryDeck.Tests/Mp4DurationReaderTests.cs @@ -112,4 +112,25 @@ public void TryReadDuration_ZeroDuration_ReturnsZeroNotNull() duration.ShouldNotBeNull(); duration.Value.ShouldBe(TimeSpan.Zero); } + + [Fact] + public void TryReadDuration_DurationBeyondTimeSpanRange_ReturnsNullInsteadOfThrowing() + { + // A corrupt 64-bit duration with a timescale of 1 is more seconds than TimeSpan can hold; it used to throw OverflowException out of the media source build and fail the whole clip. + var bytes = TestMp4.Build(version: 1, timescale: 1, duration: ulong.MaxValue); + using var file = new TempFile(bytes); + + Mp4DurationReader.TryReadDuration(file.Path).ShouldBeNull(); + } + + [Fact] + public void TryReadDuration_BoxSizeNearTheInt64Limit_ReturnsNullInsteadOfThrowing() + { + // Behind a leading box, the next box position wraps negative, and setting the stream there threw ArgumentOutOfRangeException. + byte[] leadingFreeBox = [0, 0, 0, 16, (byte)'f', (byte)'r', (byte)'e', (byte)'e', 0, 0, 0, 0, 0, 0, 0, 0]; + var bytes = leadingFreeBox.Concat(TestMp4.BuildWithLargeSize("skip", long.MaxValue - 4)).ToArray(); + using var file = new TempFile(bytes); + + Mp4DurationReader.TryReadDuration(file.Path).ShouldBeNull(); + } } diff --git a/SentryDeck.Tests/SeekScrubCoalescerTests.cs b/SentryDeck.Tests/SeekScrubCoalescerTests.cs index 1cde96b..a3d5011 100644 --- a/SentryDeck.Tests/SeekScrubCoalescerTests.cs +++ b/SentryDeck.Tests/SeekScrubCoalescerTests.cs @@ -77,7 +77,7 @@ public void ValueWithinMinimumStep_OfLastIssuedValue_IsSkipped() } [Fact] - public async Task PendingValueWithinMinimumStep_OfLastIssuedValue_IsNotReissuedOnCompletion() + public void PendingValueWithinMinimumStep_OfLastIssuedValue_IsNotReissuedOnCompletion() { var issued = new List(); var gate = new TaskCompletionSource(); @@ -95,17 +95,15 @@ public async Task PendingValueWithinMinimumStep_OfLastIssuedValue_IsNotReissuedO // Queued while in flight, but only 100ms away from the last-issued value. coalescer.OnDragValueChanged(TimeSpan.FromMilliseconds(5100)); + // The gate's continuation (the coalescer's completion handling) runs inline, so any trailing seek would already have been issued when SetResult returns. gate.SetResult(); - // Give the continuation a chance to run; it must NOT issue a second seek. - await Task.Delay(50); - issued.ShouldBe([TimeSpan.FromSeconds(5)]); coalescer.IsSeekInFlight.ShouldBeFalse(); } [Fact] - public async Task CancelPending_DropsTheQueuedValue_SoCompletionIssuesNothing() + public void CancelPending_DropsTheQueuedValue_SoCompletionIssuesNothing() { var issued = new List(); var gate = new TaskCompletionSource(); @@ -125,10 +123,10 @@ public async Task CancelPending_DropsTheQueuedValue_SoCompletionIssuesNothing() // Mouse-up: the gesture ends, so the queued value must be dropped -- re-issuing it after the release's accurate seek would land a keyframe scrub last and move the playhead. coalescer.CancelPending(); + // The gate's continuation runs inline, so a (wrongly) re-issued trailing seek would already be recorded when SetResult returns. gate.SetResult(); - await Wait.UntilAsync(() => !coalescer.IsSeekInFlight); - await Task.Delay(50); // give a (wrongly) re-issued trailing seek a chance to show up + coalescer.IsSeekInFlight.ShouldBeFalse(); issued.ShouldBe([TimeSpan.FromSeconds(1)]); } diff --git a/SentryDeck.Tests/VideoPlayerControllerTests.Recovery.cs b/SentryDeck.Tests/VideoPlayerControllerTests.Recovery.cs index 19fb25a..4e97d01 100644 --- a/SentryDeck.Tests/VideoPlayerControllerTests.Recovery.cs +++ b/SentryDeck.Tests/VideoPlayerControllerTests.Recovery.cs @@ -1,600 +1,302 @@ -using System.IO; -using System.Runtime.CompilerServices; - namespace SentryDeck.Tests; +/// +/// End of stream, camera failures, and corrupt-chunk recovery. +/// public sealed partial class VideoPlayerControllerTests { [Fact] - public async Task FrontMediaEnded_FarBeforeDuration_ExcludesBadChunkAndResumesPlayback() + public async Task FrontEnded_AtTheEnd_ParksThereWithoutAdvancingToTheNextClip() { - using var clipFiles = TestClipFiles.Create(chunkCount: 3); - var front = new FakeCameraPlayer(); - var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - using var controller = CreateController(front, mediaSourceBuilder: mediaSourceBuilder); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - - // Duration is 3 * 60s = 180s. - // Ending partway through chunk 1 (at 90s, far short of 180s) means chunk 1 is where playback died. - // All files still probe as healthy (probe-clean corruption), so recovery first rebuilds with no new exclusions (build 2), finds nothing, then falls back to excluding the failure-position chunk (build 3). - front.RaisePositionChanged(TimeSpan.FromSeconds(90)); - front.RaiseEnded(); - - await Wait.UntilAsync(() => mediaSourceBuilder.BuildCount >= 3); - - var builds = mediaSourceBuilder.Exclusions(); - builds.Count.ShouldBe(3); - builds[1].ShouldBeEmpty(); - builds[2].ShouldBe(new HashSet { 1 }); - - // Resume position is chunk 1's start in the OLD timeline (60s), since everything before the bad chunk is unchanged. - await Wait.UntilAsync(() => front.SeekPositions.Contains(TimeSpan.FromSeconds(60))); - - // The resume must play BEFORE seeking: a seek issued while paused right after open can be swallowed by the real player, whereas seeks during active playback are reliable. - front.CallLog.LastIndexOf("play").ShouldBeGreaterThan(-1); - front.CallLog.LastIndexOf("seek:60").ShouldBeGreaterThan(front.CallLog.LastIndexOf("play")); - - controller.Position.ShouldBe(TimeSpan.FromSeconds(60)); - controller.IsPlaying.ShouldBeTrue(); - controller.ErrorMessage.ShouldBeNull(); - controller.IsMediaOpen.ShouldBeTrue(); + // Each clip is its own incident: the most likely follow-up is replaying it, so the end parks instead of jumping to the next clip. + using var rig = new Rig(chunkCount: 1); + using var secondFiles = TestClipFiles.Create(chunkCount: 1); + await rig.OpenAsync(rig.Clip, secondFiles.Clip); + rig.Back.RaisePositionChanged(ChunkDuration - TimeSpan.FromMilliseconds(100)); + + rig.Front.RaiseEnded(at: ChunkDuration); + await rig.Controller.WhenIdleAsync(); + + rig.Controller.CurrentClip.ShouldBe(rig.Clip); + rig.Controller.IsPlaying.ShouldBeFalse(); + rig.Controller.IsMediaOpen.ShouldBeTrue(); + rig.Controller.Position.ShouldBe(ChunkDuration); + rig.All.ShouldAllBe(player => !player.IsPlaying); + rig.FakeBuilder.BuildCount.ShouldBe(1); } [Fact] - public async Task FrontMediaEnded_FourthPrematureEndOnSameClip_GivesUpWithErrorMessage() + public async Task PlayAsync_AfterTheClipEnded_ReplaysEveryCameraFromTheStart() { - using var clipFiles = TestClipFiles.Create(chunkCount: 5); - var front = new FakeCameraPlayer(); - var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - using var controller = CreateController(front, mediaSourceBuilder: mediaSourceBuilder); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - - // Trigger 3 successful recoveries (chunks 0, 1, 2 excluded one at a time), each ending partway through the earliest remaining chunk so the "bad chunk" is always chunk 0 of what's left, keeping this deterministic regardless of exact resume timing. - // Every file probes as healthy here, so each recovery is probe-clean: a probe-first rebuild plus a fallback rebuild with the position-derived exclusion (2 builds per recovery). - for (var attempt = 0; attempt < 3; attempt++) + // Flyleaf ignores Play on an ended player, so every camera must be moved off the end before playing or the video stays frozen on the last frame. + using var rig = new Rig(chunkCount: 1); + await rig.OpenAsync(); + foreach (var player in rig.All) { - var expectedBuildCount = mediaSourceBuilder.BuildCount + 2; - front.RaisePositionChanged(TimeSpan.FromSeconds(30)); - front.RaiseEnded(); - await Wait.UntilAsync(() => mediaSourceBuilder.BuildCount >= expectedBuildCount); - await Wait.UntilAsync(() => front.PlayCount > attempt + 1); + player.RaiseEnded(at: ChunkDuration); } - mediaSourceBuilder.BuildCount.ShouldBe(7); - var buildCountBeforeFourth = mediaSourceBuilder.BuildCount; + await rig.Controller.WhenIdleAsync(); - // A 4th premature end must give up rather than attempt another rebuild. - front.RaisePositionChanged(TimeSpan.FromSeconds(30)); - front.RaiseEnded(); + await rig.Controller.PlayAsync(); - await Wait.UntilAsync(() => controller.ErrorMessage is not null); + foreach (var (camera, player) in rig.Players) + { + player.Calls.TakeLast(2).ShouldBe(["seek:0", "play"], camera); + player.IsPlaying.ShouldBeTrue(camera); + } - mediaSourceBuilder.BuildCount.ShouldBe(buildCountBeforeFourth); - controller.ErrorMessage.ShouldContain("too many unreadable video files"); - controller.IsMediaOpen.ShouldBeFalse(); + rig.Controller.Position.ShouldBe(TimeSpan.Zero); + rig.Controller.IsPlaying.ShouldBeTrue(); } [Fact] - public async Task SingleChunkClip_PrematureEnd_GivesUpImmediately() + public async Task PlayAsync_WhenTheFrontEndsWhilePlayIsInFlight_DoesNotReportPlaying() { - using var clipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - using var controller = CreateController(front, mediaSourceBuilder: mediaSourceBuilder); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - - // The clip's only chunk is the bad one, so excluding it would leave nothing at all to play. - // Recovery has to give up on the very first probe-clean premature end rather than spend its budget rebuilding an empty timeline. - front.RaisePositionChanged(TimeSpan.Zero); - front.RaiseEnded(); - - await Wait.UntilAsync(() => controller.ErrorMessage is not null); - - controller.ErrorMessage.ShouldContain("too many unreadable video files"); - controller.IsMediaOpen.ShouldBeFalse(); + // Play pressed just before the end: the end lands while the play command is still running, and the transport must not be left claiming playback on a parked clip. + using var rig = new Rig(chunkCount: 1); + await rig.OpenAsync(); + await rig.Controller.PauseAsync(); + rig.Front.RaisePositionChanged(ChunkDuration - TimeSpan.FromMilliseconds(500)); + var gate = new TaskCompletionSource(); + rig.Front.PlayGate = gate; + + var play = rig.Controller.PlayAsync(); + await Wait.UntilAsync(() => rig.Front.Count("play") == 2); + rig.Front.RaiseEnded(at: ChunkDuration); + gate.SetResult(); + await play; + await rig.Controller.WhenIdleAsync(); + + rig.Controller.IsPlaying.ShouldBeFalse(); + rig.Controller.Position.ShouldBe(ChunkDuration); } [Fact] - public async Task Recovery_WhenRebuildYieldsNoChunks_GivesUp() + public async Task FrontEnded_WhenAQueuedSeekMovesItOffTheEnd_IsIgnored() { - using var clipFiles = TestClipFiles.Create(chunkCount: 2); - var front = new FakeCameraPlayer(); - var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - using var controller = CreateController(front, mediaSourceBuilder: mediaSourceBuilder); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - - // Every chunk becomes unreadable AFTER the clip opened (e.g. the drive was pulled mid-playback), so the recovery rebuild's probe drops all of them and hands back an empty timeline. - // Marking them before the open instead fails the initial open with "No front camera footage found." and never reaches recovery at all. - mediaSourceBuilder.AutoExcludeChunk(0); - mediaSourceBuilder.AutoExcludeChunk(1); - - front.RaisePositionChanged(TimeSpan.Zero); - front.RaiseEnded(); - - await Wait.UntilAsync(() => controller.ErrorMessage is not null); - - // One rebuild, then give up: there is nothing left to reopen, so no second build and no reopen attempt on an empty playlist. - mediaSourceBuilder.BuildCount.ShouldBe(2); - controller.ErrorMessage.ShouldContain("too many unreadable video files"); - controller.IsMediaOpen.ShouldBeFalse(); + // The end arrived, but a seek the user made first was already queued; once that seek runs, the end no longer describes where playback is. + using var rig = new Rig(chunkCount: 2); + await rig.OpenAsync(); + await rig.Controller.PauseAsync(); + var gate = new TaskCompletionSource(); + rig.Front.PlayGate = gate; + var busy = rig.Controller.PlayAsync(); + await Wait.UntilAsync(() => rig.Front.Count("play") == 2); + + var seek = rig.Controller.SeekAsync(TimeSpan.FromSeconds(10)); + rig.Front.RaiseEnded(at: ChunkDuration * 2); + rig.Front.PlayGate = null; + gate.SetResult(); + await busy; + await seek; + await rig.Controller.WhenIdleAsync(); + + rig.Controller.Position.ShouldBe(TimeSpan.FromSeconds(10)); + rig.FakeBuilder.BuildCount.ShouldBe(1); } [Fact] - public async Task SelectingNewClip_ResetsExclusionsFromPreviousClip() + public async Task SideCameraEnded_BeforeTheFront_IsIgnored() { - using var firstClipFiles = TestClipFiles.Create(chunkCount: 3); - using var secondClipFiles = TestClipFiles.Create(chunkCount: 2); - var front = new FakeCameraPlayer(); - var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - using var controller = CreateController(front, mediaSourceBuilder: mediaSourceBuilder); - - controller.LoadClips([firstClipFiles.Clip, secondClipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - - // Trigger one (probe-clean, two-build) recovery on the first clip so it has a non-empty exclusion set. - front.RaisePositionChanged(TimeSpan.FromSeconds(90)); - front.RaiseEnded(); - await Wait.UntilAsync(() => mediaSourceBuilder.BuildCount >= 3); - await Wait.UntilAsync(() => front.PlayCount > 1); - - await controller.GoToClipAsync(secondClipFiles.Clip); - await WaitUntilClipOpenedAsync(controller, front); - - // Clip 2's open must have started from a fresh (empty) exclusion set, never clip 1's leftover {1}. - await Wait.UntilAsync(() => mediaSourceBuilder.BuildCountFor(secondClipFiles.Clip) > 0); - mediaSourceBuilder.LastExclusionsFor(secondClipFiles.Clip).ShouldBeEmpty(); - - // Ending the second clip prematurely should exclude relative to a fresh (empty) set, not carry over chunk 1 from the first clip. - // Probe-clean again: wait for both rebuilds so the fallback exclusion is recorded. - front.RaisePositionChanged(TimeSpan.FromSeconds(0)); - front.RaiseEnded(); + using var rig = new Rig(); + await rig.OpenAsync(); - await Wait.UntilAsync(() => mediaSourceBuilder.BuildCountFor(secondClipFiles.Clip) >= 3); + rig.Back.RaiseEnded(); + await rig.Controller.WhenIdleAsync(); - mediaSourceBuilder.LastExclusionsFor(secondClipFiles.Clip).ShouldBe(new HashSet { 0 }); + rig.Controller.IsPlaying.ShouldBeTrue(); + rig.Front.IsPlaying.ShouldBeTrue(); + rig.FakeBuilder.BuildCount.ShouldBe(1); } [Fact] - public async Task FrontFailure_WhileSecondaryCamerasStillJoining_StillTriggersRecovery() + public async Task SideCameraFailed_KeepsTheOtherCamerasPlaying() { - using var clipFiles = TestClipFiles.Create(chunkCount: 3); - var front = new FakeCameraPlayer(); - var back = new FakeCameraPlayer { OpenGate = new TaskCompletionSource() }; - var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - using var controller = CreateController(front, back: back, mediaSourceBuilder: mediaSourceBuilder); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - - // The front is open and playing but the back camera's open is held, so the clip-open operation is still in flight and IsLoading is still true -- the join window. - // Waiting for the back's OpenAsync call guarantees the front's opening phase has fully completed. - await Wait.UntilAsync(() => back.OpenedPaths.Count > 0); - controller.IsLoading.ShouldBeTrue(); - - // The front dies far short of Duration (180s) -- a corrupt/truncated early chunk. - // This must route into corrupt-chunk recovery, not freeze silently or show the error UI. - front.RaisePositionChanged(TimeSpan.FromSeconds(90)); - front.RaiseFailed(new InvalidOperationException("Playback stopped unexpectedly")); - - // Let the held secondary open (and with it the original open operation) finish; recovery queues behind it on the serialized operation lock. - back.OpenGate.SetResult(null); - - await Wait.UntilAsync(() => mediaSourceBuilder.BuildCount >= 2); - await Wait.UntilAsync(() => !controller.IsLoading); - - // Recovery took over cleanly: no spurious "Playback failed", the media is open again, and the loading state (owned by the superseded open) was settled by the recovery pass. - controller.ErrorMessage.ShouldBeNull(); - controller.IsMediaOpen.ShouldBeTrue(); - } - - [Fact] - public async Task FrontMediaFailed_MidClip_ProbeFindsRealBadChunk_KeepsHealthyChunk() - { - using var clipFiles = TestClipFiles.Create(chunkCount: 4); - var front = new FakeCameraPlayer(); - var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - using var controller = CreateController(front, mediaSourceBuilder: mediaSourceBuilder); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - - // Chunk 2's file becomes unreadable AFTER the clip opened (e.g. removed/truncated mid-playback); the fake's probe will auto-exclude it on the next rebuild. - mediaSourceBuilder.AutoExcludeChunk(2); + using var rig = new Rig(); + await rig.OpenAsync(); - // The demuxer reads ahead of the presentation position, so Failed fires while playback is still inside HEALTHY chunk 1 (90s). - // Probe-first recovery must find chunk 2 via the rebuild's probe and keep chunk 1 -- excluding the chunk under the failure position would throw away a healthy minute. - front.RaisePositionChanged(TimeSpan.FromSeconds(90)); - front.RaiseFailed(new InvalidOperationException("Playback stopped unexpectedly")); + rig.Back.RaiseFailed(new InvalidOperationException("decoder died")); + await rig.Controller.WhenIdleAsync(); - await Wait.UntilAsync(() => mediaSourceBuilder.BuildCount >= 2); - await Wait.UntilAsync(() => front.SeekPositions.Contains(TimeSpan.FromSeconds(60))); - - // The probe found the culprit, so exactly one rebuild happened and no Build call ever received a position-derived (healthy-chunk) exclusion. - var builds = mediaSourceBuilder.Exclusions(); - builds.Count.ShouldBe(2); - builds[1].ShouldBeEmpty(); - - // Chunks 0, 1, and 3 remain: healthy chunk 1 was NOT excluded. - controller.Duration.ShouldBe(TimeSpan.FromSeconds(180)); - - // Playback resumes from the start of the chunk containing the failure position. - controller.Position.ShouldBe(TimeSpan.FromSeconds(60)); - controller.IsPlaying.ShouldBeTrue(); - controller.ErrorMessage.ShouldBeNull(); - controller.IsMediaOpen.ShouldBeTrue(); + rig.Controller.IsPlaying.ShouldBeTrue(); + rig.Controller.ErrorMessage.ShouldBeNull(); + rig.Front.IsPlaying.ShouldBeTrue(); + rig.FakeBuilder.BuildCount.ShouldBe(1); } [Fact] - public async Task Recovery_AccountsForBuilderAutoExcludedChunks() + public async Task FrontFailed_NearTheEnd_ReportsTheFailureWithoutRecovering() { - using var clipFiles = TestClipFiles.Create(chunkCount: 3); - var front = new FakeCameraPlayer(); - var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - mediaSourceBuilder.AutoExcludeChunk(1); - using var controller = CreateController(front, mediaSourceBuilder: mediaSourceBuilder); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - - // The builder dropped chunk 1 on its own, so the opened timeline is [chunk0, chunk2] and Duration is 120s. - controller.Duration.ShouldBe(TimeSpan.FromSeconds(120)); + using var rig = new Rig(chunkCount: 1); + await rig.OpenAsync(); + rig.Front.RaisePositionChanged(ChunkDuration - TimeSpan.FromSeconds(1)); - // A premature end at 90s is inside timeline slot 1, which maps back to ORIGINAL chunk 2 (not 1) because the auto-exclusion must be accounted for in the mapping. - // Chunk 1 is already excluded, so the probe-first rebuild reports nothing new (probe-clean) and the fallback rebuild carries the position-derived exclusion. - front.RaisePositionChanged(TimeSpan.FromSeconds(90)); - front.RaiseEnded(); + rig.Front.RaiseFailed(new InvalidOperationException("boom")); + await rig.Controller.WhenIdleAsync(); - await Wait.UntilAsync(() => mediaSourceBuilder.BuildCount >= 3); - - var builds = mediaSourceBuilder.Exclusions(); - builds[1].ShouldBe(new HashSet { 1 }); - builds[2].ShouldBe(new HashSet { 1, 2 }); - - await Wait.UntilAsync(() => front.SeekPositions.Contains(TimeSpan.FromSeconds(60))); - - controller.ErrorMessage.ShouldBeNull(); - controller.IsMediaOpen.ShouldBeTrue(); + rig.Controller.ErrorMessage.ShouldBe("Playback failed: boom"); + rig.Controller.IsPlaying.ShouldBeFalse(); + rig.Controller.IsMediaOpen.ShouldBeFalse(); + rig.FakeBuilder.BuildCount.ShouldBe(1); } [Fact] - public async Task SelectingClip_FrontPlaysBeforeSlowestSideCameraFinishesOpening() + public async Task FrontEnded_FarBeforeTheEnd_ExcludesTheFailedChunkAndResumesAtItsStart() { - using var clipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - var back = new FakeCameraPlayer(); - var left = new FakeCameraPlayer(); - var right = new FakeCameraPlayer { OpenGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) }; - using var controller = CreateController(front, back, left, right); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - - // Front should start playing immediately, without waiting for the slowest side camera (right, held open via OpenGate) to finish opening. - await Wait.UntilAsync(() => front.PlayCount > 0); - - front.PlayCount.ShouldBe(1); - right.PlayCount.ShouldBe(0); - right.SeekPositions.ShouldBeEmpty(); - controller.IsPlaying.ShouldBeTrue(); + using var rig = new Rig(chunkCount: 3); + await rig.OpenAsync(); - // Release the gate; the side camera should now join in (seek + play). - right.OpenGate.SetResult(null); + // 180s of footage ending at 90s means chunk 1 died. + // Every file still probes as healthy (probe-clean corruption), so recovery first rebuilds with no new exclusions, finds nothing, then excludes the chunk under the failure position. + rig.Front.RaisePositionChanged(TimeSpan.FromSeconds(90)); + rig.Front.RaiseEnded(); + await rig.Controller.WhenIdleAsync(); - await Wait.UntilAsync(() => right.PlayCount > 0); - - right.SeekPositions.ShouldNotBeEmpty(); - right.PlayCount.ShouldBe(1); - } - - [Fact] - public async Task SelectingClip_SideCameraJoinsAtFrontsCurrentPosition() - { - using var clipFiles = TestClipFiles.Create(chunkCount: 2); - var front = new FakeCameraPlayer(); - var back = new FakeCameraPlayer { OpenGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) }; - var left = new FakeCameraPlayer(); - var right = new FakeCameraPlayer(); - using var controller = CreateController(front, back, left, right); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - - await Wait.UntilAsync(() => front.PlayCount > 0); - - // Advance the front's live position while back is still opening. - front.RaisePositionChanged(TimeSpan.FromSeconds(42)); - - back.OpenGate.SetResult(null); - - await Wait.UntilAsync(() => back.PlayCount > 0); - - back.SeekPositions.ShouldContain(TimeSpan.FromSeconds(42)); - } - - [Fact] - public async Task SelectingClip_WithEvent_AutoJumpsToShortlyBeforeTheEventMoment() - { - // A 3-chunk clip spans 0-180s of media time; an event 30s into the second chunk maps to media time 90s. - // Opening it must land the front player EventLeadIn (10s) before that -- 80s -- rather than at the top of the buffer, matching the in-car player since the 2024 Holiday Update. - using var clipFiles = TestClipFiles.Create(chunkCount: 3); - var clip = WithEvent(clipFiles.Clip, clipFiles.Clip.Chunks[1].Timestamp.AddSeconds(30)); - - var front = new FakeCameraPlayer(); - using var controller = CreateController(front, mediaSourceBuilder: new FakeClipMediaSourceBuilder()); - - controller.LoadClips([clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - await Wait.UntilAsync(() => front.SeekPositions.Contains(TimeSpan.FromSeconds(80))); - - // The auto-jump plays first, then seeks: a seek issued while paused right after open can be swallowed, so (like recovery) it must land during active playback. - // "seek:" is the accurate seek. - front.CallLog.IndexOf("play").ShouldBeLessThan(front.CallLog.IndexOf("seek:80")); - controller.Position.ShouldBe(TimeSpan.FromSeconds(80)); - controller.IsPlaying.ShouldBeTrue(); - controller.IsMediaOpen.ShouldBeTrue(); - } - - [Theory] - // No event metadata (e.g. a clip the car saved without a trigger): nothing to jump to, so the clip opens at 0:00. - [InlineData(null)] - // The event fired 5s into the clip, inside the 10s lead-in window, so there is nothing to jump back to: the clip opens at the start with no seek rather than clamping to a redundant 0. - [InlineData(5.0)] - // An event timestamped before the clip ever recorded (clock skew) has no media time, so the clip opens at the start rather than jumping to a bogus position. - [InlineData(-60.0)] - public async Task SelectingClip_WithNoJumpTarget_OpensAtTopOfBuffer(double? eventOffsetSeconds) - { - using var clipFiles = TestClipFiles.Create(chunkCount: 2); // TestClipFiles builds clips with camEvent: null - var clip = eventOffsetSeconds is null - ? clipFiles.Clip - : WithEvent(clipFiles.Clip, clipFiles.Clip.Chunks[0].Timestamp.AddSeconds(eventOffsetSeconds.Value)); - - var front = new FakeCameraPlayer(); - using var controller = CreateController(front, mediaSourceBuilder: new FakeClipMediaSourceBuilder()); - - controller.LoadClips([clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - - front.SeekPositions.ShouldBeEmpty(); - controller.Position.ShouldBe(TimeSpan.Zero); - controller.IsPlaying.ShouldBeTrue(); - } - - [Fact] - public async Task SelectingClip_WithEvent_SecondaryCamerasJoinAtTheJumpedToPosition() - { - // The auto-jump seeks the front BEFORE the side cameras join, so they join at the jumped-to position (80s) and stay in sync with the front rather than starting at 0. - using var clipFiles = TestClipFiles.Create(chunkCount: 3); - var clip = WithEvent(clipFiles.Clip, clipFiles.Clip.Chunks[1].Timestamp.AddSeconds(30)); - - var front = new FakeCameraPlayer(); - var back = new FakeCameraPlayer(); - using var controller = CreateController(front, back, mediaSourceBuilder: new FakeClipMediaSourceBuilder()); + var builds = rig.FakeBuilder.Exclusions(); + builds.Count.ShouldBe(3); + builds[1].ShouldBeEmpty(); + builds[2].ShouldBe(new HashSet { 1 }); - controller.LoadClips([clip]); - controller.Playlist.MoveTo(0); - await Wait.UntilAsync(() => back.PlayCount > 0); + // Resume at chunk 1's start (60s), where chunk 2 now begins; every camera is positioned before any of them plays. + foreach (var (camera, player) in rig.Players) + { + player.OpenedPaths.Count.ShouldBe(2, camera); + var calls = player.Calls; + calls.LastIndexOf("seek:60").ShouldBeGreaterThan(calls.LastIndexOf("open"), camera); + calls.LastIndexOf("seek:60").ShouldBeLessThan(calls.LastIndexOf("play"), camera); + player.IsPlaying.ShouldBeTrue(camera); + } - back.SeekPositions.ShouldContain(TimeSpan.FromSeconds(80)); + rig.Controller.Duration.ShouldBe(ChunkDuration * 2); + rig.Controller.Position.ShouldBe(TimeSpan.FromSeconds(60)); + rig.Controller.IsPlaying.ShouldBeTrue(); + rig.Controller.ErrorMessage.ShouldBeNull(); } [Fact] - public async Task RecoverFromPrematureEnd_OpenDoesNotPlayOrSeekBeforeCallerPositions() + public async Task FrontFailed_MidClip_WhenTheProbeFindsTheRealCulprit_KeepsTheHealthyChunk() { - using var clipFiles = TestClipFiles.Create(chunkCount: 3); - var front = new FakeCameraPlayer(); - var back = new FakeCameraPlayer(); - var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - using var controller = CreateController(front, back, mediaSourceBuilder: mediaSourceBuilder); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - - back.CallLog.Clear(); - front.CallLog.Clear(); - - // Probe-clean premature end -> recovery reopens with playAfterOpen: false. - // The reopen itself (OpenClipInternalAsync) must only pause -- no join seek/play, unlike the playAfterOpen: true path -- leaving the recovery code to position/play afterward exactly once each. - front.RaisePositionChanged(TimeSpan.FromSeconds(90)); - front.RaiseEnded(); - - await Wait.UntilAsync(() => mediaSourceBuilder.BuildCount >= 3); - await Wait.UntilAsync(() => front.SeekPositions.Contains(TimeSpan.FromSeconds(60))); - - // Exactly one play and one seek reach back (from the recovery code's own resume sequence), not a join seek/play from inside the reopen itself. - back.CallLog.Count(call => call == "play").ShouldBe(1); - back.CallLog.Count(call => call.StartsWith("seek:")).ShouldBe(1); - back.CallLog.ShouldContain("seek:60"); - back.CallLog.ShouldContain("pause"); - back.CallLog.IndexOf("pause").ShouldBeLessThan(back.CallLog.IndexOf("play")); - - controller.IsPlaying.ShouldBeTrue(); + // The demuxer reads ahead, so the failure position can sit in a healthy chunk while the corrupt file is the next one. + // A probe that now flags chunk 2 must win over the position-derived guess of chunk 1. + using var rig = new Rig(chunkCount: 3); + await rig.OpenAsync(); + rig.Front.RaisePositionChanged(TimeSpan.FromSeconds(100)); + rig.FakeBuilder.AutoExcludeChunk(2); + + rig.Front.RaiseFailed(new InvalidOperationException("Playback stopped unexpectedly")); + await rig.Controller.WhenIdleAsync(); + + rig.FakeBuilder.BuildCount.ShouldBe(2); + rig.FakeBuilder.Exclusions()[1].ShouldBeEmpty(); + rig.Controller.Duration.ShouldBe(ChunkDuration * 2); + rig.Controller.IsPlaying.ShouldBeTrue(); + rig.Controller.ErrorMessage.ShouldBeNull(); } [Fact] - public async Task StepFrameAsync_WhilePaused_StepsAllOpenPlayersInDirection() + public async Task FrontEnded_Prematurely_MapsThePositionPastChunksTheBuilderAlreadyDropped() { - using var clipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - var back = new FakeCameraPlayer(); - using var controller = CreateController(front, back); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - - await controller.PauseAsync(); - front.CallLog.Clear(); - back.CallLog.Clear(); + // The builder dropped chunk 0 on its own, so media time 0-60s is chunk 1; a failure at 30s must exclude chunk 1, not chunk 0. + using var rig = new Rig(chunkCount: 3); + rig.FakeBuilder.AutoExcludeChunk(0); + await rig.OpenAsync(); - await controller.StepFrameAsync(forward: true); + rig.Front.RaisePositionChanged(TimeSpan.FromSeconds(30)); + rig.Front.RaiseEnded(); + await rig.Controller.WhenIdleAsync(); - front.StepLog.ShouldBe(["forward"]); - back.StepLog.ShouldBe(["forward"]); - front.CallLog.ShouldNotContain("pause"); - back.CallLog.ShouldNotContain("pause"); - controller.IsPlaying.ShouldBeFalse(); - - await controller.StepFrameAsync(forward: false); - - front.StepLog.ShouldBe(["forward", "backward"]); - back.StepLog.ShouldBe(["forward", "backward"]); + rig.FakeBuilder.LastExclusionsFor(rig.Clip).ShouldBe(new HashSet { 0, 1 }); + rig.Controller.Duration.ShouldBe(ChunkDuration); } [Fact] - public async Task StepFrameAsync_WhilePlaying_PausesFirstThenSteps() + public async Task FrontEnded_PrematurelyOnASingleChunkClip_GivesUpWithAnError() { - using var clipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - var back = new FakeCameraPlayer(); - using var controller = CreateController(front, back); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); + using var rig = new Rig(chunkCount: 1); + await rig.OpenAsync(); - front.CallLog.Clear(); - back.CallLog.Clear(); + rig.Front.RaisePositionChanged(TimeSpan.FromSeconds(10)); + rig.Front.RaiseEnded(); + await rig.Controller.WhenIdleAsync(); - controller.IsPlaying.ShouldBeTrue(); - - await controller.StepFrameAsync(forward: true); - - front.PauseCount.ShouldBe(1); - back.PauseCount.ShouldBe(1); - front.StepLog.ShouldBe(["forward"]); - back.StepLog.ShouldBe(["forward"]); - front.CallLog.IndexOf("pause").ShouldBeLessThan(front.CallLog.IndexOf("step:forward")); - back.CallLog.IndexOf("pause").ShouldBeLessThan(back.CallLog.IndexOf("step:forward")); - controller.IsPlaying.ShouldBeFalse(); + rig.Controller.ErrorMessage.ShouldBe("Playback stopped: too many unreadable video files."); + rig.Controller.IsPlaying.ShouldBeFalse(); + rig.Controller.IsMediaOpen.ShouldBeFalse(); } [Fact] - public async Task StepFrameAsync_OnlyStepsOpenPlayers() + public async Task FrontEnded_PrematurelyForTheFourthTime_GivesUpWithAnError() { - using var clipFiles = TestClipFiles.Create(chunkCount: 1, omitCamerasFromChunkZero: new HashSet { CameraNames.LeftRepeater }); - var front = new FakeCameraPlayer(); - var back = new FakeCameraPlayer(); - var left = new FakeCameraPlayer(); - var right = new FakeCameraPlayer(); - using var controller = CreateController(front, back, left, right); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await Wait.UntilAsync(() => front.PlayCount > 0 && back.PlayCount > 0 && right.PlayCount > 0); + using var rig = new Rig(chunkCount: 5); + await rig.OpenAsync(); - await controller.PauseAsync(); + // Each end lands 10s into what's left, so the first remaining chunk is excluded every time. + for (var attempt = 0; attempt < 3; attempt++) + { + rig.Front.RaisePositionChanged(TimeSpan.FromSeconds(10)); + rig.Front.RaiseEnded(); + await rig.Controller.WhenIdleAsync(); + rig.Controller.ErrorMessage.ShouldBeNull($"attempt {attempt}"); + rig.Controller.IsPlaying.ShouldBeTrue($"attempt {attempt}"); + } - await controller.StepFrameAsync(forward: true); + rig.Front.RaisePositionChanged(TimeSpan.FromSeconds(10)); + rig.Front.RaiseEnded(); + await rig.Controller.WhenIdleAsync(); - front.StepLog.ShouldBe(["forward"]); - back.StepLog.ShouldBe(["forward"]); - right.StepLog.ShouldBe(["forward"]); - left.StepLog.ShouldBeEmpty(); + rig.FakeBuilder.LastExclusionsFor(rig.Clip).ShouldBe(new HashSet { 0, 1, 2 }); + rig.Controller.ErrorMessage.ShouldBe("Playback stopped: too many unreadable video files."); + rig.Controller.IsPlaying.ShouldBeFalse(); } [Fact] - public async Task StepFrameAsync_WhenNoMediaOpen_IsNoOp() + public async Task SelectingAnotherClip_AfterRecoveries_StartsWithNoExclusionsAndAFreshBudget() { - var front = new FakeCameraPlayer(); - using var controller = CreateController(front); + using var rig = new Rig(chunkCount: 3); + using var secondFiles = TestClipFiles.Create(chunkCount: 3); + await rig.OpenAsync(rig.Clip, secondFiles.Clip); + rig.Front.RaisePositionChanged(TimeSpan.FromSeconds(30)); + rig.Front.RaiseEnded(); + await rig.Controller.WhenIdleAsync(); - await controller.StepFrameAsync(forward: true); + await rig.Controller.NextAsync(); + await rig.Controller.WhenIdleAsync(); - front.StepLog.ShouldBeEmpty(); - controller.IsPlaying.ShouldBeFalse(); - } + rig.FakeBuilder.LastExclusionsFor(secondFiles.Clip).ShouldBeEmpty(); + rig.Controller.Duration.ShouldBe(ChunkDuration * 3); - [Fact] - public async Task PostRecoverySeek_ThatDidNotStick_IsReissuedOnce() - { - // A seek issued right after a reopen can be swallowed by the player, leaving the viewer back at the top of the clip after a recovery instead of where they were watching. - // The controller waits, re-reads the player's own reported position, and reissues once if it is still far short. - // Gating that wait is what makes the check observable: it holds the controller inside the window while the test reports a position the seek never reached. - using var clipFiles = TestClipFiles.Create(chunkCount: 3); - var front = new FakeCameraPlayer(); - var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - var verifyReached = new TaskCompletionSource(); - var releaseVerify = new TaskCompletionSource(); - - using var controller = CreateController( - front, - mediaSourceBuilder: mediaSourceBuilder, - postRecoverySeekVerifyDelay: _ => - { - verifyReached.TrySetResult(); - return releaseVerify.Task; - }); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - - // Same premature end as the recovery tests above: playback dies inside chunk 1, so recovery resumes at that chunk's start (60s). - front.RaisePositionChanged(TimeSpan.FromSeconds(90)); - front.RaiseEnded(); - - await verifyReached.Task; - var seeksBeforeVerify = front.SeekPositions.Count(position => position == TimeSpan.FromSeconds(60)); - - // The player reports it is still near the top of the clip: the resume seek did not take. - front.RaisePositionChanged(TimeSpan.Zero); - releaseVerify.SetResult(); - - await Wait.UntilAsync(() => front.SeekPositions.Count(position => position == TimeSpan.FromSeconds(60)) > seeksBeforeVerify); - - controller.Position.ShouldBe(TimeSpan.FromSeconds(60)); + // Three more recoveries are allowed on the new clip. + for (var attempt = 0; attempt < 2; attempt++) + { + rig.Front.RaisePositionChanged(TimeSpan.FromSeconds(10)); + rig.Front.RaiseEnded(); + await rig.Controller.WhenIdleAsync(); + rig.Controller.ErrorMessage.ShouldBeNull($"attempt {attempt}"); + } } [Fact] - public async Task PostRecoverySeek_ThatStuck_IsNotReissued() + public async Task FrontEnded_Prematurely_WhenTheClipChangesMidRecovery_AbandonsTheRecovery() { - // The counterpart: when the player does land on the resume target, reissuing would be a second visible jump for no reason. - using var clipFiles = TestClipFiles.Create(chunkCount: 3); - var front = new FakeCameraPlayer(); - var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - var verifyReached = new TaskCompletionSource(); - var releaseVerify = new TaskCompletionSource(); - - using var controller = CreateController( - front, - mediaSourceBuilder: mediaSourceBuilder, - postRecoverySeekVerifyDelay: _ => - { - verifyReached.TrySetResult(); - return releaseVerify.Task; - }); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - - front.RaisePositionChanged(TimeSpan.FromSeconds(90)); - front.RaiseEnded(); - - await verifyReached.Task; - var seeksBeforeVerify = front.SeekPositions.Count(position => position == TimeSpan.FromSeconds(60)); - - releaseVerify.SetResult(); - await Wait.UntilAsync(() => !controller.IsLoading); - - front.SeekPositions.Count(position => position == TimeSpan.FromSeconds(60)).ShouldBe(seeksBeforeVerify); + using var rig = new Rig(chunkCount: 3); + using var secondFiles = TestClipFiles.Create(chunkCount: 1); + await rig.OpenAsync(rig.Clip, secondFiles.Clip); + var gate = new TaskCompletionSource(); + rig.Front.OpenGate = gate; + + rig.Front.RaisePositionChanged(TimeSpan.FromSeconds(90)); + rig.Front.RaiseEnded(); + await Wait.UntilAsync(() => rig.Front.OpenedPaths.Count == 2); + await rig.Controller.NextAsync(); + rig.Front.OpenGate = null; + gate.SetResult(); + await rig.Controller.WhenIdleAsync(); + + rig.Controller.CurrentClip.ShouldBe(secondFiles.Clip); + rig.Front.OpenedPaths[^1].ShouldStartWith(secondFiles.RootPath); + rig.Controller.Duration.ShouldBe(ChunkDuration); + rig.Controller.IsPlaying.ShouldBeTrue(); + rig.Controller.ErrorMessage.ShouldBeNull(); } } diff --git a/SentryDeck.Tests/VideoPlayerControllerTests.cs b/SentryDeck.Tests/VideoPlayerControllerTests.cs index 000dc2a..d0e9815 100644 --- a/SentryDeck.Tests/VideoPlayerControllerTests.cs +++ b/SentryDeck.Tests/VideoPlayerControllerTests.cs @@ -4,79 +4,103 @@ namespace SentryDeck.Tests; /// /// Split across VideoPlayerControllerTests.*.cs by feature. -/// This file holds the harness plus opening, transport and playlist navigation; recovery and camera-join behaviour live in the Recovery partial. +/// This file holds opening, transport, seeking, frame stepping, and playlist navigation; end-of-stream, failures, and corrupt-chunk recovery live in the Recovery partial. /// +/// +/// Every test awaits rather than polling: it completes once all queued work (including work queued by player events) has run, so assertions see the settled state. +/// public sealed partial class VideoPlayerControllerTests { - // Clones a clip with an event at the given wall-clock instant (TestClipFiles builds event-less clips), preserving its real chunk files so the media source builds from the same footage. - private static CamClip WithEvent(CamClip clip, DateTime eventTimestamp) => - new(clip.FullPath, clip.Name, clip.Timestamp, clip.Chunks, new CamEvent { Timestamp = eventTimestamp }); + private static readonly TimeSpan ChunkDuration = FakeClipMediaSourceBuilder.ChunkDuration; - private static VideoPlayerController CreateController( - FakeCameraPlayer front = null, - FakeCameraPlayer back = null, - FakeCameraPlayer left = null, - FakeCameraPlayer right = null, - IClipMediaSourceBuilder mediaSourceBuilder = null, - Func postRecoverySeekVerifyDelay = null) + private sealed class Rig : IDisposable { - var players = new Dictionary + public Rig( + int chunkCount = 3, + FakeCameraPlayer front = null, + FakeCameraPlayer back = null, + IClipMediaSourceBuilder builder = null, + IReadOnlySet omitCamerasFromChunkZero = null) { - [CameraNames.Front] = front ?? new FakeCameraPlayer(), - [CameraNames.Back] = back ?? new FakeCameraPlayer(), - [CameraNames.LeftRepeater] = left ?? new FakeCameraPlayer(), - [CameraNames.RightRepeater] = right ?? new FakeCameraPlayer(), - }; - - return new VideoPlayerController( - players, - CameraNames.Front, - mediaSourceBuilder ?? new FakeClipMediaSourceBuilder(), - // FakeCameraPlayer reports its position the moment a seek is applied, so the real verify wait buys nothing here and every recovery test would otherwise pay it in wall-clock time. - postRecoverySeekVerifyDelay ?? (_ => Task.CompletedTask)); - } + Files = TestClipFiles.Create(chunkCount, omitCamerasFromChunkZero); + Front = front ?? new FakeCameraPlayer(); + Back = back ?? new FakeCameraPlayer(); + Builder = builder ?? new FakeClipMediaSourceBuilder(); + Players = new Dictionary + { + [CameraNames.Front] = Front, + [CameraNames.Back] = Back, + [CameraNames.LeftRepeater] = Left, + [CameraNames.RightRepeater] = Right, + }; + Controller = new VideoPlayerController(Players.ToDictionary(pair => pair.Key, pair => (ICameraPlayer)pair.Value), CameraNames.Front, Builder); + } - /// - /// Waits until a clip is fully opened: playback has started AND the open operation has completed (IsLoading cleared). - /// Events raised between Play and the end of the open operation are dropped by the controller's stale-event guards, so tests that raise front-player events must wait for this state, not just PlayCount. - /// - private static Task WaitUntilClipOpenedAsync(VideoPlayerController controller, FakeCameraPlayer front) - { - return Wait.UntilAsync(() => front.PlayCount > 0 && controller.IsMediaOpen && !controller.IsLoading); + public TestClipFiles Files { get; } + + public CamClip Clip => Files.Clip; + + public FakeCameraPlayer Front { get; } + + public FakeCameraPlayer Back { get; } + + public FakeCameraPlayer Left { get; } = new(); + + public FakeCameraPlayer Right { get; } = new(); + + public IReadOnlyDictionary Players { get; } + + public IEnumerable All => Players.Values; + + public IClipMediaSourceBuilder Builder { get; } + + public FakeClipMediaSourceBuilder FakeBuilder => (FakeClipMediaSourceBuilder)Builder; + + public VideoPlayerController Controller { get; } + + public async Task OpenAsync(params CamClip[] clips) + { + Controller.LoadClips(clips.Length == 0 ? [Clip] : clips); + Controller.Playlist.MoveTo(0); + await Controller.WhenIdleAsync(); + } + + public void Dispose() + { + Controller.Dispose(); + Files.Dispose(); + } } + // Clones a clip with an event at the given wall-clock instant (TestClipFiles builds event-less clips), preserving its real chunk files so the media source builds from the same footage. + private static CamClip WithEvent(CamClip clip, DateTime eventTimestamp) => + new(clip.FullPath, clip.Name, clip.Timestamp, clip.Chunks, new CamEvent { Timestamp = eventTimestamp }); + [Fact] - public async Task SelectingClip_OpensAndPlaysAllAvailableCameras() + public async Task SelectingClip_WithFourCameras_OpensAndPlaysEveryCamera() { - using var clipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - var back = new FakeCameraPlayer(); - var left = new FakeCameraPlayer(); - var right = new FakeCameraPlayer(); - using var controller = CreateController(front, back, left, right); + using var rig = new Rig(chunkCount: 1); - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); + await rig.OpenAsync(); - await Wait.UntilAsync(() => - front.PlayCount > 0 && - back.PlayCount > 0 && - left.PlayCount > 0 && - right.PlayCount > 0); + foreach (var (camera, player) in rig.Players) + { + player.OpenedPaths.ShouldHaveSingleItem().ShouldContain($"-{camera}.mp4"); + player.IsPlaying.ShouldBeTrue(camera); + } - front.OpenedPaths.ShouldContain(path => path.EndsWith(".ffconcat", StringComparison.OrdinalIgnoreCase) && path.Contains("-front.mp4")); - back.OpenedPaths.ShouldContain(path => path.EndsWith(".ffconcat", StringComparison.OrdinalIgnoreCase) && path.Contains("-back.mp4")); - left.OpenedPaths.ShouldContain(path => path.EndsWith(".ffconcat", StringComparison.OrdinalIgnoreCase) && path.Contains("-left_repeater.mp4")); - right.OpenedPaths.ShouldContain(path => path.EndsWith(".ffconcat", StringComparison.OrdinalIgnoreCase) && path.Contains("-right_repeater.mp4")); - controller.IsPlaying.ShouldBeTrue(); - controller.IsMediaOpen.ShouldBeTrue(); + rig.Controller.IsPlaying.ShouldBeTrue(); + rig.Controller.IsMediaOpen.ShouldBeTrue(); + rig.Controller.IsLoading.ShouldBeFalse(); + rig.Controller.Duration.ShouldBe(ChunkDuration); + rig.Controller.OpenedMediaSource.ShouldNotBeNull(); } [Fact] public async Task SelectingClip_WithPillarCameras_OpensAndPlaysEveryCamera() { // An HW4 clip carries six cameras; the camera-keyed pool must open and play all of them, not just the classic four. - using var clipFiles = TestClipFiles.Create(chunkCount: 1); // default fixture = all six cameras + using var clipFiles = TestClipFiles.Create(chunkCount: 1); var players = CameraNames.All.ToDictionary(camera => camera, _ => new FakeCameraPlayer()); using var controller = new VideoPlayerController( players.ToDictionary(pair => pair.Key, pair => (ICameraPlayer)pair.Value), @@ -85,696 +109,651 @@ public async Task SelectingClip_WithPillarCameras_OpensAndPlaysEveryCamera() controller.LoadClips([clipFiles.Clip]); controller.Playlist.MoveTo(0); + await controller.WhenIdleAsync(); - await Wait.UntilAsync(() => players.Values.All(player => player.PlayCount > 0)); - - players[CameraNames.LeftPillar].OpenedPaths.ShouldContain(path => path.Contains("-left_pillar.mp4")); - players[CameraNames.RightPillar].OpenedPaths.ShouldContain(path => path.Contains("-right_pillar.mp4")); + foreach (var (camera, player) in players) + { + player.IsPlaying.ShouldBeTrue(camera); + } } [Fact] - public async Task SelectingClip_WhenSecondaryFileMissing_PlaysRemainingCameras() + public async Task SelectingClip_WhenSideFileMissing_PlaysRemainingCameras() { - using var clipFiles = TestClipFiles.Create(chunkCount: 1, omitCamerasFromChunkZero: new HashSet { CameraNames.LeftRepeater }); - var front = new FakeCameraPlayer(); - var back = new FakeCameraPlayer(); - var left = new FakeCameraPlayer(); - var right = new FakeCameraPlayer(); - using var controller = CreateController(front, back, left, right); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); + using var rig = new Rig(chunkCount: 1, omitCamerasFromChunkZero: new HashSet { CameraNames.LeftRepeater }); - await Wait.UntilAsync(() => - front.PlayCount > 0 && - back.PlayCount > 0 && - right.PlayCount > 0); + await rig.OpenAsync(); - left.OpenedPaths.ShouldBeEmpty(); - left.PlayCount.ShouldBe(0); - controller.ErrorMessage.ShouldBeNull(); - controller.IsPlaying.ShouldBeTrue(); - controller.IsMediaOpen.ShouldBeTrue(); + rig.Left.OpenedPaths.ShouldBeEmpty(); + rig.Left.IsPlaying.ShouldBeFalse(); + rig.Front.IsPlaying.ShouldBeTrue(); + rig.Back.IsPlaying.ShouldBeTrue(); + rig.Controller.ErrorMessage.ShouldBeNull(); + rig.Controller.IsPlaying.ShouldBeTrue(); } [Fact] - public async Task SelectingClip_WhenFrontFileMissing_ReportsOpenFailure() + public async Task SelectingClip_WhenFrontFileMissing_ReportsNoFootage() { - using var clipFiles = TestClipFiles.Create(chunkCount: 1, omitCamerasFromChunkZero: new HashSet { CameraNames.Front }); - var front = new FakeCameraPlayer(); - using var controller = CreateController(front); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); + using var rig = new Rig(chunkCount: 1, omitCamerasFromChunkZero: new HashSet { CameraNames.Front }); - await Wait.UntilAsync(() => controller.ErrorMessage is not null); + await rig.OpenAsync(); - controller.ErrorMessage.ShouldBe("No front camera footage found."); - controller.IsPlaying.ShouldBeFalse(); - front.OpenedPaths.ShouldBeEmpty(); + rig.Controller.ErrorMessage.ShouldBe("No front camera footage found."); + rig.Controller.IsPlaying.ShouldBeFalse(); + rig.Controller.IsLoading.ShouldBeFalse(); + rig.All.ShouldAllBe(player => player.OpenedPaths.Count == 0); } [Fact] public async Task SelectingClip_WhenAllFilesAreEncrypted_ExplainsTheEncryptionToggle() { - // A drive written by Tesla software 2026.20+ with "Encrypt Dashcam Recordings" on: every file exists but none is a playable MP4. The real builder probes and excludes every chunk, and the error must point at the encryption toggle, not claim missing footage. - using var clipFiles = TestClipFiles.Create(chunkCount: 2); - foreach (var chunk in clipFiles.Clip.Chunks) + // A drive written by Tesla software 2026.20+ with "Encrypt Dashcam Recordings" on: every file exists but none is a playable MP4. + // The real builder probes and excludes every chunk, and the error must point at the encryption toggle, not claim missing footage. + using var playlists = new TestPlaylistDirectory(); + using var rig = new Rig(chunkCount: 2, builder: playlists.CreateBuilder()); + foreach (var file in rig.Clip.Chunks.SelectMany(chunk => chunk.Files.Values)) { - foreach (var file in chunk.Files.Values) - { - File.WriteAllBytes(file.FullPath, TestMp4.EncryptedLookingBytes); - } + File.WriteAllBytes(file.FullPath, TestMp4.EncryptedLookingBytes); } - var front = new FakeCameraPlayer(); - using var playlists = new TestPlaylistDirectory(); - using var controller = CreateController(front, mediaSourceBuilder: playlists.CreateBuilder()); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); + await rig.OpenAsync(); - await Wait.UntilAsync(() => controller.ErrorMessage is not null); - - controller.ErrorMessage.ShouldBe(VideoPlayerController.EncryptedClipMessage); - controller.ErrorMessage.ShouldContain("Encrypt Dashcam Recordings"); - controller.IsPlaying.ShouldBeFalse(); - front.OpenedPaths.ShouldBeEmpty(); + rig.Controller.ErrorMessage.ShouldBe(VideoPlayerController.EncryptedClipMessage); + rig.Controller.IsPlaying.ShouldBeFalse(); + rig.Front.OpenedPaths.ShouldBeEmpty(); } [Fact] - public async Task SelectingClip_WhenAllFilesAreGarbage_ButNotEncrypted_KeepsTheCorruptMessage() + public async Task SelectingClip_WhenAllFilesAreTruncated_ReportsNoFootageRatherThanEncryption() { // Same all-unreadable shape, but the files still carry MP4 headers (truncated writes): that's ordinary corruption and must NOT be blamed on encryption. - using var clipFiles = TestClipFiles.Create(chunkCount: 1); + using var playlists = new TestPlaylistDirectory(); + using var rig = new Rig(chunkCount: 1, builder: playlists.CreateBuilder()); var truncated = TestMp4.BuildWithDuration(TimeSpan.FromSeconds(60))[..12]; - foreach (var file in clipFiles.Clip.Chunks[0].Files.Values) + foreach (var file in rig.Clip.Chunks[0].Files.Values) { File.WriteAllBytes(file.FullPath, truncated); } - var front = new FakeCameraPlayer(); - using var playlists = new TestPlaylistDirectory(); - using var controller = CreateController(front, mediaSourceBuilder: playlists.CreateBuilder()); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - - await Wait.UntilAsync(() => controller.ErrorMessage is not null); + await rig.OpenAsync(); - controller.ErrorMessage.ShouldBe("No front camera footage found."); - controller.IsPlaying.ShouldBeFalse(); + rig.Controller.ErrorMessage.ShouldBe("No front camera footage found."); + rig.Controller.IsPlaying.ShouldBeFalse(); } [Fact] - public async Task PrimaryCameraFailsToOpen_ReportsFailureWithoutPlaying() + public async Task SelectingClip_WhenFrontRefusesToOpen_ReportsFailureAndPlaysNothing() { - // The playlist exists and is handed to the player, but the player itself refuses it (a codec/handle failure inside Flyleaf). - // Unlike the missing-footage cases above, the open WAS attempted -- and nothing past it may happen: no play, and no secondary cameras. - using var clipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer { OpenResult = false }; - var back = new FakeCameraPlayer(); - using var controller = CreateController(front, back); + // The playlist exists and is handed to the player, but the player itself refuses it (a codec or handle failure inside Flyleaf). + using var rig = new Rig(chunkCount: 1, front: new FakeCameraPlayer { OpenResult = false }); - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); + await rig.OpenAsync(); - await Wait.UntilAsync(() => controller.ErrorMessage is not null); - - controller.ErrorMessage.ShouldBe("Failed to open front camera video."); - front.OpenedPaths.Count.ShouldBe(1); - front.PlayCount.ShouldBe(0); - back.OpenedPaths.ShouldBeEmpty(); - controller.IsPlaying.ShouldBeFalse(); - controller.IsMediaOpen.ShouldBeFalse(); + rig.Controller.ErrorMessage.ShouldBe("Failed to open front camera video."); + rig.Front.OpenedPaths.Count.ShouldBe(1); + rig.All.ShouldAllBe(player => player.Count("play") == 0); + rig.Controller.IsPlaying.ShouldBeFalse(); + rig.Controller.IsMediaOpen.ShouldBeFalse(); + rig.Controller.IsLoading.ShouldBeFalse(); } [Fact] - public async Task PauseSeekAndStop_ControlOpenPlayers() + public async Task SelectingClip_WithEvent_PositionsEveryCameraBeforeTheEventThenPlays() { - using var clipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - var back = new FakeCameraPlayer(); - using var controller = CreateController(front, back); + // A 3-chunk clip spans 0-180s of media time; an event 30s into the second chunk maps to media time 90s, so the clip opens 10s earlier at 80s. + // Every camera must be seeked while paused and only then played, so they all start from the same frame. + using var rig = new Rig(chunkCount: 3); + var clip = WithEvent(rig.Clip, rig.Clip.Chunks[1].Timestamp.AddSeconds(30)); - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await Wait.UntilAsync(() => front.PlayCount > 0 && back.PlayCount > 0); + await rig.OpenAsync(clip); - await controller.PauseAsync(); - await controller.SeekAsync(TimeSpan.FromSeconds(12)); - await controller.StopAsync(); + foreach (var (camera, player) in rig.Players) + { + var calls = player.Calls; + calls.IndexOf("seek:80").ShouldBeGreaterThan(-1, camera); + calls.IndexOf("seek:80").ShouldBeLessThan(calls.IndexOf("play"), camera); + player.Position.ShouldBe(TimeSpan.FromSeconds(80), camera); + } - front.PauseCount.ShouldBe(1); - back.PauseCount.ShouldBe(1); - front.SeekPositions.ShouldContain(TimeSpan.FromSeconds(12)); - back.SeekPositions.ShouldContain(TimeSpan.FromSeconds(12)); - controller.Position.ShouldBe(TimeSpan.Zero); - controller.Duration.ShouldBe(TimeSpan.Zero); - controller.IsPlaying.ShouldBeFalse(); - controller.IsMediaOpen.ShouldBeFalse(); + rig.Controller.Position.ShouldBe(TimeSpan.FromSeconds(80)); + rig.Controller.IsPlaying.ShouldBeTrue(); } - [Fact] - public async Task PlayAsync_OnTheAlreadyOpenClip_ResumesWithoutRebuilding() + [Theory] + // No event metadata (e.g. a clip the car saved without a trigger): nothing to jump to, so the clip opens at 0:00. + [InlineData(null)] + // The event fired 5s into the clip, inside the 10s lead-in window, so there is nothing to jump back to. + [InlineData(5.0)] + // An event timestamped before the clip ever recorded (clock skew) has no media time. + [InlineData(-60.0)] + public async Task SelectingClip_WithNoJumpTarget_OpensAtTopOfBufferWithoutSeeking(double? eventOffsetSeconds) { - using var clipFiles = TestClipFiles.Create(chunkCount: 2); - var front = new FakeCameraPlayer(); - var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - using var controller = CreateController(front, mediaSourceBuilder: mediaSourceBuilder); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - - var openCountBeforeResume = front.OpenedPaths.Count; - await controller.PauseAsync(); + using var rig = new Rig(chunkCount: 2); + var clip = eventOffsetSeconds is null + ? rig.Clip + : WithEvent(rig.Clip, rig.Clip.Chunks[0].Timestamp.AddSeconds(eventOffsetSeconds.Value)); - await controller.PlayAsync(); + await rig.OpenAsync(clip); - // Resuming the clip that's already open must take the resume fast path: no rebuild, no reopen, just play. - // Rebuilding here would restart the clip from scratch on every pause. - mediaSourceBuilder.BuildCount.ShouldBe(1); - front.OpenedPaths.Count.ShouldBe(openCountBeforeResume); - front.PlayCount.ShouldBe(2); - controller.IsPlaying.ShouldBeTrue(); + rig.All.ShouldAllBe(player => player.Seeks.Count == 0); + rig.Controller.Position.ShouldBe(TimeSpan.Zero); + rig.Controller.IsPlaying.ShouldBeTrue(); } [Fact] - public async Task PlayAsync_AtEndOfClip_RestartsFromZero() + public async Task SelectingClip_WhileAnotherIsStillOpening_OnlyTheLatestPlays() { - using var clipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - using var controller = CreateController(front); + // Arrowing quickly through the list: the first clip's open is still in flight when the second is picked. + // The first must never start playing, and the second must end up open on every camera. + using var rig = new Rig(chunkCount: 1); + using var secondFiles = TestClipFiles.Create(chunkCount: 1); + var gate = new TaskCompletionSource(); + rig.Front.OpenGate = gate; - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); + rig.Controller.LoadClips([rig.Clip, secondFiles.Clip]); + rig.Controller.Playlist.MoveTo(0); + await Wait.UntilAsync(() => rig.Front.OpenedPaths.Count == 1); - // Playback parks at the end of a finished clip rather than advancing, so pressing play there has to mean "replay" -- otherwise the button does nothing at all. - front.RaisePositionChanged(controller.Duration); + await rig.Controller.GoToClipAsync(secondFiles.Clip); + rig.Front.OpenGate = null; + gate.SetResult(); + await rig.Controller.WhenIdleAsync(); - await controller.PlayAsync(); - - front.SeekPositions.ShouldContain(TimeSpan.Zero); - controller.Position.ShouldBe(TimeSpan.Zero); - controller.IsPlaying.ShouldBeTrue(); + rig.Front.OpenedPaths.Count.ShouldBe(2); + rig.Front.OpenedPaths[^1].ShouldStartWith(secondFiles.RootPath); + rig.Front.Calls.Count(call => call == "play").ShouldBe(1); + rig.Front.Calls.LastIndexOf("open").ShouldBeLessThan(rig.Front.Calls.IndexOf("play")); + rig.Controller.CurrentClip.ShouldBe(secondFiles.Clip); + rig.Controller.IsPlaying.ShouldBeTrue(); + rig.Controller.IsLoading.ShouldBeFalse(); } [Fact] - public async Task ScrubSeekAsync_IssuesFastSeeksToOpenPlayers() + public async Task SelectingClip_WhileAClipIsPlaying_ClosesItBeforeOpeningTheNext() { - using var clipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - var back = new FakeCameraPlayer(); - using var controller = CreateController(front, back); + using var rig = new Rig(chunkCount: 1); + using var secondFiles = TestClipFiles.Create(chunkCount: 1); + await rig.OpenAsync(rig.Clip, secondFiles.Clip); - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await Wait.UntilAsync(() => front.PlayCount > 0 && back.PlayCount > 0); + await rig.Controller.NextAsync(); + await rig.Controller.WhenIdleAsync(); - await controller.ScrubSeekAsync(TimeSpan.FromSeconds(12)); + foreach (var (camera, player) in rig.Players) + { + var calls = player.Calls; + calls.LastIndexOf("close").ShouldBeGreaterThan(calls.IndexOf("play"), camera); + calls.LastIndexOf("open").ShouldBeGreaterThan(calls.LastIndexOf("close"), camera); + player.OpenedPaths[^1].ShouldStartWith(secondFiles.RootPath, customMessage: camera); + } - front.SeekPositions.ShouldContain(TimeSpan.FromSeconds(12)); - back.SeekPositions.ShouldContain(TimeSpan.FromSeconds(12)); - front.SeekAccurateFlags[^1].ShouldBeFalse(); - back.SeekAccurateFlags[^1].ShouldBeFalse(); - controller.Position.ShouldBe(TimeSpan.FromSeconds(12)); + rig.Controller.CurrentClip.ShouldBe(secondFiles.Clip); } [Fact] - public async Task SeekAsync_IssuesAccurateSeeksToOpenPlayers() + public async Task PauseAsync_WhilePlaying_PausesEveryCamera() { - using var clipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - using var controller = CreateController(front); + using var rig = new Rig(); + await rig.OpenAsync(); - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await Wait.UntilAsync(() => front.PlayCount > 0); - - await controller.SeekAsync(TimeSpan.FromSeconds(12)); + await rig.Controller.PauseAsync(); - front.SeekAccurateFlags[^1].ShouldBeTrue(); + rig.All.ShouldAllBe(player => !player.IsPlaying); + rig.Controller.IsPlaying.ShouldBeFalse(); + rig.Controller.IsMediaOpen.ShouldBeTrue(); } [Fact] - public async Task StopAsync_ClosesPlayersEvenWhenStopFails() + public async Task PlayAsync_OnPausedClip_ResumesEveryCameraWithoutReopening() { - using var clipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer { ThrowOnStop = true }; - var back = new FakeCameraPlayer(); - using var controller = CreateController(front, back); + using var rig = new Rig(); + await rig.OpenAsync(); + await rig.Controller.PauseAsync(); + var buildsBefore = rig.FakeBuilder.BuildCount; - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await Wait.UntilAsync(() => front.PlayCount > 0); - - await controller.StopAsync(); + await rig.Controller.PlayAsync(); - front.StopCount.ShouldBeGreaterThan(0); - front.CloseCount.ShouldBeGreaterThan(0); - back.CloseCount.ShouldBeGreaterThan(0); - controller.IsMediaOpen.ShouldBeFalse(); - controller.IsPlaying.ShouldBeFalse(); + rig.FakeBuilder.BuildCount.ShouldBe(buildsBefore); + rig.All.ShouldAllBe(player => player.OpenedPaths.Count == 1 && player.IsPlaying); + rig.Controller.IsPlaying.ShouldBeTrue(); } [Fact] - public async Task FrontMediaFailed_NearEndOfClip_ReportsPlaybackFailure() + public async Task PlayAsync_WhenSideCameraDriftedWhilePaused_RealignsItOntoTheFront() { - using var clipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - using var controller = CreateController(front); + using var rig = new Rig(); + await rig.OpenAsync(); + await rig.Controller.PauseAsync(); + rig.Front.RaisePositionChanged(TimeSpan.FromSeconds(30)); + rig.Back.RaisePositionChanged(TimeSpan.FromSeconds(29)); + rig.Left.RaisePositionChanged(TimeSpan.FromSeconds(30.05)); + rig.Right.RaisePositionChanged(TimeSpan.FromSeconds(30)); - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); + await rig.Controller.PlayAsync(); - // A failure within the premature-end tolerance of Duration is not a corrupt-chunk candidate, so it must surface as a plain playback error. - front.RaisePositionChanged(controller.Duration - TimeSpan.FromSeconds(1)); - front.RaiseFailed(new InvalidOperationException("decode failed")); + rig.Back.Calls.ShouldContain("seek:30"); + rig.Back.Calls.IndexOf("seek:30").ShouldBeLessThan(rig.Back.Calls.LastIndexOf("play")); - controller.ErrorMessage.ShouldContain("decode failed"); - controller.IsPlaying.ShouldBeFalse(); - controller.IsMediaOpen.ShouldBeFalse(); + // Within a frame or two is just where each camera's pause landed; reseeking those would only slow resume down. + rig.Left.Seeks.ShouldBeEmpty(); + rig.Right.Seeks.ShouldBeEmpty(); } [Fact] - public async Task SecondaryCameraFailure_DoesNotStopPrimaryPlayback() + public async Task PlayAsync_AfterStop_ReopensTheClip() { - using var clipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - var back = new FakeCameraPlayer(); - using var controller = CreateController(front, back); + using var rig = new Rig(); + await rig.OpenAsync(); + await rig.Controller.StopAsync(); - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await Wait.UntilAsync(() => front.PlayCount > 0 && back.PlayCount > 0); - - back.RaiseFailed(new InvalidOperationException("secondary failed")); + await rig.Controller.PlayAsync(); - controller.ErrorMessage.ShouldBeNull(); - controller.IsPlaying.ShouldBeTrue(); - controller.IsMediaOpen.ShouldBeTrue(); + rig.Front.OpenedPaths.Count.ShouldBe(2); + rig.Controller.IsPlaying.ShouldBeTrue(); + rig.Controller.IsMediaOpen.ShouldBeTrue(); } [Fact] - public async Task SecondaryCameraEnded_DoesNotStopOrRecover() + public async Task PlayAsync_WhileTheClipIsStillOpening_DoesNotRestartTheOpen() { - using var clipFiles = TestClipFiles.Create(chunkCount: 3); - var front = new FakeCameraPlayer(); - var back = new FakeCameraPlayer(); - var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - using var controller = CreateController(front, back, mediaSourceBuilder: mediaSourceBuilder); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - - var buildCountBeforeEnded = mediaSourceBuilder.BuildCount; - var positionBeforeEnded = controller.Position; + using var rig = new Rig(); + var gate = new TaskCompletionSource(); + rig.Front.OpenGate = gate; + rig.Controller.LoadClips([rig.Clip]); + rig.Controller.Playlist.MoveTo(0); + await Wait.UntilAsync(() => rig.Front.OpenedPaths.Count == 1); - // A secondary camera with fewer usable chunks runs out of footage long before the front does. - // Only the primary drives the timeline, so this must neither park playback at the end nor start corrupt-chunk recovery -- the front is still mid-clip. - back.RaiseEnded(); + await rig.Controller.PlayAsync(); + rig.Front.OpenGate = null; + gate.SetResult(); + await rig.Controller.WhenIdleAsync(); - controller.IsPlaying.ShouldBeTrue(); - controller.Position.ShouldBe(positionBeforeEnded); - mediaSourceBuilder.BuildCount.ShouldBe(buildCountBeforeEnded); - controller.ErrorMessage.ShouldBeNull(); - controller.IsMediaOpen.ShouldBeTrue(); + rig.Front.OpenedPaths.Count.ShouldBe(1); + rig.FakeBuilder.BuildCount.ShouldBe(1); + rig.Controller.IsPlaying.ShouldBeTrue(); } [Fact] - public async Task FrontMediaEnded_WithinTolerance_CompletesNormallyWithoutRebuilding() + public async Task StopAsync_WhenOneCameraFailsToClose_StillClosesTheRestAndResets() { - using var clipFiles = TestClipFiles.Create(chunkCount: 2); - var front = new FakeCameraPlayer(); - var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - using var controller = CreateController(front, mediaSourceBuilder: mediaSourceBuilder); + using var rig = new Rig(back: new FakeCameraPlayer { ThrowOnClose = true }); + await rig.OpenAsync(); - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); + await rig.Controller.StopAsync(); - var openCountBeforeEnded = front.OpenedPaths.Count; - var duration = controller.Duration; + rig.All.ShouldAllBe(player => player.Calls.Last() == "close"); + rig.Front.IsOpen.ShouldBeFalse(); + rig.Controller.IsPlaying.ShouldBeFalse(); + rig.Controller.IsMediaOpen.ShouldBeFalse(); + rig.Controller.Position.ShouldBe(TimeSpan.Zero); + rig.Controller.Duration.ShouldBe(TimeSpan.Zero); + rig.Controller.OpenedMediaSource.ShouldBeNull(); + rig.Controller.ErrorMessage.ShouldBeNull(); + } - // A genuine end-of-clip: position reaches Duration before Ended fires. - front.RaisePositionChanged(duration); - front.RaiseEnded(); + [Fact] + public async Task StopAsync_WhileAClipIsOpening_LeavesNothingPlaying() + { + using var rig = new Rig(); + var gate = new TaskCompletionSource(); + rig.Front.OpenGate = gate; + rig.Controller.LoadClips([rig.Clip]); + rig.Controller.Playlist.MoveTo(0); + await Wait.UntilAsync(() => rig.Front.OpenedPaths.Count == 1); - await Wait.UntilAsync(() => controller.Position == duration && !controller.IsPlaying); + var stop = rig.Controller.StopAsync(); + gate.SetResult(); + await stop; - // The whole clip is one playlist per camera opened once; hitting the end of the playlist must not trigger another OpenAsync call (that would be the old per-chunk stall). - front.OpenedPaths.Count.ShouldBe(openCountBeforeEnded); - mediaSourceBuilder.BuildCount.ShouldBe(1); - controller.Position.ShouldBe(duration); - controller.IsPlaying.ShouldBeFalse(); - // The media stays open at the end so the scrubber and frame-step remain usable. - controller.IsMediaOpen.ShouldBeTrue(); + rig.All.ShouldAllBe(player => player.Count("play") == 0); + rig.Controller.IsPlaying.ShouldBeFalse(); + rig.Controller.IsLoading.ShouldBeFalse(); + rig.Controller.IsMediaOpen.ShouldBeFalse(); } [Fact] - public async Task PlayAsync_WhenTheClipEndsDuringTheCall_DoesNotReportPlaying() + public async Task SeekAsync_WhilePlaying_PausesSeeksAndResumesEveryCamera() { - using var clipFiles = TestClipFiles.Create(chunkCount: 2); - var front = new FakeCameraPlayer(); - using var controller = CreateController(front); + // Seeking a playing Flyleaf player hands the seek to its play thread, which lets cameras land at different times; they must be paused first and restarted together. + using var rig = new Rig(); + await rig.OpenAsync(); - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); + await rig.Controller.SeekAsync(TimeSpan.FromSeconds(42)); - 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 = () => + foreach (var (camera, player) in rig.Players) { - front.PlayCallback = null; - front.RaisePositionChanged(duration); - front.RaiseEnded(); - }; - - await controller.PlayAsync(); + player.Calls.TakeLast(3).ShouldBe(["pause", "seek:42", "play"], camera); + player.IsPlaying.ShouldBeTrue(camera); + } - 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(); + rig.Controller.Position.ShouldBe(TimeSpan.FromSeconds(42)); + rig.Controller.IsPlaying.ShouldBeTrue(); } [Fact] - public async Task PlayAsync_WhenTheClipKeepsPlaying_ReportsPlaying() + public async Task SeekAsync_WhilePaused_SeeksWithoutResuming() { - 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(); + using var rig = new Rig(); + await rig.OpenAsync(); + await rig.Controller.PauseAsync(); - // The guard above must only suppress the state a finished clip already settled; an ordinary resume still reports playback. - await controller.PlayAsync(); + await rig.Controller.SeekAsync(TimeSpan.FromSeconds(42)); - controller.IsPlaying.ShouldBeTrue(); + rig.All.ShouldAllBe(player => player.Calls.Last() == "seek:42" && !player.IsPlaying); + rig.Controller.IsPlaying.ShouldBeFalse(); } [Fact] - public async Task FrontMediaEnded_WithNextClip_StaysOnCurrentClipWithoutAdvancing() + public async Task SeekAsync_BeyondDuration_ClampsToDuration() { - using var firstClipFiles = TestClipFiles.Create(chunkCount: 2); - using var secondClipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - using var controller = CreateController(front, mediaSourceBuilder: mediaSourceBuilder); + using var rig = new Rig(chunkCount: 3); + await rig.OpenAsync(); + await rig.Controller.PauseAsync(); - controller.LoadClips([firstClipFiles.Clip, secondClipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); + await rig.Controller.SeekAsync(TimeSpan.FromMinutes(10)); - var duration = controller.Duration; + rig.Front.Seeks[^1].Position.ShouldBe(ChunkDuration * 3); + rig.Controller.Position.ShouldBe(ChunkDuration * 3); + } - // A genuine end-of-clip: position reaches (within tolerance of) Duration before Ended fires. - front.RaisePositionChanged(duration); - front.RaiseEnded(); + [Fact] + public async Task SeekAsync_BurstQueuedBehindBusyOperation_RunsOnlyTheLatest() + { + // A held arrow key or fast clicks queue many seeks; each one pauses, seeks, and resumes every camera, so replaying them all would keep the video jumping for seconds after the input stops. + using var rig = new Rig(); + await rig.OpenAsync(); + await rig.Controller.PauseAsync(); + var gate = new TaskCompletionSource(); + rig.Front.PlayGate = gate; + var busy = rig.Controller.PlayAsync(); - await Wait.UntilAsync(() => controller.Position == duration && !controller.IsPlaying); + var seeks = new[] { 10, 20, 30 }.Select(seconds => rig.Controller.SeekAsync(TimeSpan.FromSeconds(seconds))).ToList(); + rig.Front.PlayGate = null; + gate.SetResult(); + await busy; + await Task.WhenAll(seeks); - // No auto-advance: the user stays on the finished clip (most likely to replay it), and the next clip is never opened or built. - // Next remains an explicit action. - controller.CurrentClip.ShouldBe(firstClipFiles.Clip); - mediaSourceBuilder.BuildCountFor(secondClipFiles.Clip).ShouldBe(0); - controller.Position.ShouldBe(duration); - controller.IsPlaying.ShouldBeFalse(); - controller.CanGoNext.ShouldBeTrue(); - // The media stays open at the end so the scrubber and frame-step remain usable. - controller.IsMediaOpen.ShouldBeTrue(); + rig.Front.Seeks.Select(seek => seek.Position).ShouldBe([TimeSpan.FromSeconds(30)]); + rig.Controller.Position.ShouldBe(TimeSpan.FromSeconds(30)); } [Fact] - public async Task SeekAsync_PastOldChunkBoundary_SeeksOpenPlayersWithoutReopening() + public async Task SeekByAsync_BurstQueuedBehindBusyOperation_AddsEveryOffset() { - using var clipFiles = TestClipFiles.Create(chunkCount: 2); - var front = new FakeCameraPlayer(); - var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - using var controller = CreateController(front, mediaSourceBuilder: mediaSourceBuilder); + // Coalescing must not swallow presses: three +5s requests queued together still move 15s, measured from where the queued seek was headed. + using var rig = new Rig(); + await rig.OpenAsync(); + await rig.Controller.PauseAsync(); + await rig.Controller.SeekAsync(TimeSpan.FromSeconds(20)); + var gate = new TaskCompletionSource(); + rig.Front.PlayGate = gate; + var busy = rig.Controller.PlayAsync(); - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await Wait.UntilAsync(() => front.PlayCount > 0); + var seeks = Enumerable.Range(0, 3).Select(_ => rig.Controller.SeekByAsync(TimeSpan.FromSeconds(5))).ToList(); + rig.Front.PlayGate = null; + gate.SetResult(); + await busy; + await Task.WhenAll(seeks); + + rig.Front.Seeks[^1].Position.ShouldBe(TimeSpan.FromSeconds(35)); + rig.Front.Seeks.Count.ShouldBe(2); + } - var openCountBeforeSeek = front.OpenedPaths.Count; + [Fact] + public async Task SeekByAsync_PastEitherEnd_ClampsToTheClip() + { + using var rig = new Rig(chunkCount: 1); + await rig.OpenAsync(); + await rig.Controller.PauseAsync(); - // 75s is past the old 60s per-chunk boundary; the clip is now a single continuous playlist, so this must be a plain seek with no reopen. - await controller.SeekAsync(TimeSpan.FromSeconds(75)); + await rig.Controller.SeekByAsync(TimeSpan.FromSeconds(-5)); + rig.Controller.Position.ShouldBe(TimeSpan.Zero); - front.OpenedPaths.Count.ShouldBe(openCountBeforeSeek); - mediaSourceBuilder.BuildCount.ShouldBe(1); - front.SeekPositions.ShouldContain(TimeSpan.FromSeconds(75)); - controller.Position.ShouldBe(TimeSpan.FromSeconds(75)); - controller.IsPlaying.ShouldBeTrue(); + await rig.Controller.SeekByAsync(TimeSpan.FromMinutes(5)); + rig.Controller.Position.ShouldBe(ChunkDuration); } [Fact] - public async Task SeekAsync_BeyondDuration_ClampsToDuration() + public async Task ScrubGesture_WhilePlaying_HoldsPausedThenResumesAtTheReleasePoint() { - using var clipFiles = TestClipFiles.Create(chunkCount: 2); - var front = new FakeCameraPlayer(); - using var controller = CreateController(front); + using var rig = new Rig(); + await rig.OpenAsync(); - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await Wait.UntilAsync(() => front.PlayCount > 0); + await rig.Controller.BeginScrubAsync(); + rig.All.ShouldAllBe(player => !player.IsPlaying); - await controller.SeekAsync(TimeSpan.FromSeconds(999)); + await rig.Controller.ScrubSeekAsync(TimeSpan.FromSeconds(10)); + await rig.Controller.ScrubSeekAsync(TimeSpan.FromSeconds(20)); + rig.All.ShouldAllBe(player => !player.IsPlaying); - controller.Position.ShouldBe(controller.Duration); - front.SeekPositions.ShouldContain(controller.Duration); + await rig.Controller.EndScrubAsync(TimeSpan.FromSeconds(25)); + + foreach (var (camera, player) in rig.Players) + { + player.Calls.TakeLast(4).ShouldBe(["scrub:10", "scrub:20", "seek:25", "play"], camera); + player.IsPlaying.ShouldBeTrue(camera); + } + + rig.Controller.IsPlaying.ShouldBeTrue(); + rig.Controller.Position.ShouldBe(TimeSpan.FromSeconds(25)); } [Fact] - public async Task PositionChanged_ReportsFrontPlayerPositionDirectly() + public async Task ScrubGesture_WhilePaused_StaysPausedAfterRelease() { - 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 Wait.UntilAsync(() => front.PlayCount > 0); + using var rig = new Rig(); + await rig.OpenAsync(); + await rig.Controller.PauseAsync(); - front.RaisePositionChanged(TimeSpan.FromSeconds(68)); + await rig.Controller.BeginScrubAsync(); + await rig.Controller.ScrubSeekAsync(TimeSpan.FromSeconds(10)); + await rig.Controller.EndScrubAsync(TimeSpan.FromSeconds(12)); - controller.Position.ShouldBe(TimeSpan.FromSeconds(68)); + rig.All.ShouldAllBe(player => !player.IsPlaying && player.Calls.Last() == "seek:12"); + rig.Controller.IsPlaying.ShouldBeFalse(); } [Fact] - public async Task PlaybackSpeed_AppliesToExistingAndFuturePlayers() + public async Task ScrubGesture_PausedDuringTheDrag_DoesNotResumeOnRelease() { - using var clipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - var back = new FakeCameraPlayer(); - using var controller = CreateController(front, back); + using var rig = new Rig(); + await rig.OpenAsync(); - controller.PlaybackSpeed = 2.0; - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); + await rig.Controller.BeginScrubAsync(); + await rig.Controller.PauseAsync(); + await rig.Controller.EndScrubAsync(TimeSpan.FromSeconds(12)); - await Wait.UntilAsync(() => front.PlayCount > 0 && back.PlayCount > 0); + rig.All.ShouldAllBe(player => !player.IsPlaying); + rig.Controller.IsPlaying.ShouldBeFalse(); + } - front.Speed.ShouldBe(2.0); - back.Speed.ShouldBe(2.0); + [Fact] + public async Task StepFrameAsync_WhilePlaying_PausesEveryCameraThenSteps() + { + using var rig = new Rig(); + await rig.OpenAsync(); - controller.PlaybackSpeed = 0; + await rig.Controller.StepFrameAsync(forward: true); - controller.PlaybackSpeed.ShouldBe(1.0); - front.Speed.ShouldBe(1.0); - back.Speed.ShouldBe(1.0); + rig.All.ShouldAllBe(player => !player.IsPlaying && player.Calls.Contains("step:forward")); + rig.Controller.IsPlaying.ShouldBeFalse(); + rig.Controller.Position.ShouldBe(FakeCameraPlayer.FrameDuration); } [Fact] - public async Task GoToClipAsync_ShowsLoadingWhileCurrentClipStops() + public async Task StepFrameAsync_ForwardWhenASideCameraSlipped_ReseeksOnlyThatCameraOntoTheFront() { - using var firstClipFiles = TestClipFiles.Create(chunkCount: 1); - using var secondClipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - using var controller = CreateController(front); + // Each camera drops frames in different places, so stepping them independently lets them drift apart; a camera more than a frame and a half off is pulled back onto the front's frame. + using var rig = new Rig(); + await rig.OpenAsync(); + await rig.Controller.PauseAsync(); + rig.Back.RaisePositionChanged(TimeSpan.FromMilliseconds(200)); - controller.LoadClips([firstClipFiles.Clip, secondClipFiles.Clip]); - controller.Playlist.MoveTo(0); - await Wait.UntilAsync(() => front.PlayCount > 0); - - front.StopGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await rig.Controller.StepFrameAsync(forward: true); - var changeClipTask = controller.GoToClipAsync(secondClipFiles.Clip); + var anchor = rig.Front.Position; + rig.Back.Seeks.ShouldHaveSingleItem().Position.ShouldBe(anchor); + rig.Left.Seeks.ShouldBeEmpty(); + rig.Right.Seeks.ShouldBeEmpty(); + } - await Wait.UntilAsync(() => front.StopCount > 0); + [Fact] + public async Task StepFrameAsync_Backward_SideCamerasFollowTheFrontsNewFrame() + { + using var rig = new Rig(); + await rig.OpenAsync(); + await rig.Controller.PauseAsync(); + await rig.Controller.SeekAsync(TimeSpan.FromSeconds(10)); - controller.IsLoading.ShouldBeTrue(); + await rig.Controller.StepFrameAsync(forward: false); - front.StopGate.SetResult(null); - await changeClipTask; - await Wait.UntilAsync(() => controller.CurrentClip == secondClipFiles.Clip && !controller.IsLoading); + var anchor = TimeSpan.FromSeconds(10) - FakeCameraPlayer.FrameDuration; + rig.Front.Calls[^1].ShouldBe("step:backward"); + rig.Front.Position.ShouldBe(anchor); + foreach (var player in new[] { rig.Back, rig.Left, rig.Right }) + { + player.Calls.ShouldNotContain("step:backward"); + player.Position.ShouldBe(anchor); + } - controller.CurrentClip.ShouldBe(secondClipFiles.Clip); - controller.IsLoading.ShouldBeFalse(); + rig.Controller.Position.ShouldBe(anchor); } [Fact] - public async Task NextAsync_MovesToTheNextClipAndStopsTheCurrentOne() + public async Task StepFrameAsync_WhenNothingIsOpen_DoesNothing() { - using var firstClipFiles = TestClipFiles.Create(chunkCount: 1); - using var secondClipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - using var controller = CreateController(front); + using var rig = new Rig(); - controller.LoadClips([firstClipFiles.Clip, secondClipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); + await rig.Controller.StepFrameAsync(forward: true); + + rig.All.ShouldAllBe(player => player.Calls.Count == 0); + } - var stopCountBeforeNext = front.StopCount; + [Fact] + public async Task PositionChanged_FromTheFront_UpdatesPositionButSideCamerasDoNot() + { + using var rig = new Rig(); + await rig.OpenAsync(); - await controller.NextAsync(); - await WaitUntilClipOpenedAsync(controller, front); + rig.Front.RaisePositionChanged(TimeSpan.FromSeconds(12)); + rig.Back.RaisePositionChanged(TimeSpan.FromSeconds(99)); - // The outgoing clip is torn down before the playlist moves, so the new clip never opens on top of players still holding the old one's playlist. - controller.CurrentClip.ShouldBe(secondClipFiles.Clip); - front.StopCount.ShouldBeGreaterThan(stopCountBeforeNext); + rig.Controller.Position.ShouldBe(TimeSpan.FromSeconds(12)); } [Fact] - public async Task PreviousAsync_MovesToThePreviousClip() + public async Task PlaybackSpeed_ChangedWhilePlaying_AppliesToEveryCameraAndSurvivesTheNextOpen() { - using var firstClipFiles = TestClipFiles.Create(chunkCount: 1); - using var secondClipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - using var controller = CreateController(front); + using var rig = new Rig(); + using var secondFiles = TestClipFiles.Create(chunkCount: 1); + await rig.OpenAsync(rig.Clip, secondFiles.Clip); - controller.LoadClips([firstClipFiles.Clip, secondClipFiles.Clip]); - controller.Playlist.MoveTo(1); - await WaitUntilClipOpenedAsync(controller, front); + rig.Controller.PlaybackSpeed = 2.0; + rig.All.ShouldAllBe(player => player.Speed == 2.0); - var stopCountBeforePrevious = front.StopCount; + foreach (var player in rig.All) + { + player.Speed = 1.0; + } - await controller.PreviousAsync(); - await WaitUntilClipOpenedAsync(controller, front); + await rig.Controller.NextAsync(); + await rig.Controller.WhenIdleAsync(); - controller.CurrentClip.ShouldBe(firstClipFiles.Clip); - front.StopCount.ShouldBeGreaterThan(stopCountBeforePrevious); + rig.All.ShouldAllBe(player => player.Speed == 2.0); } [Fact] - public async Task NextAsync_AtTheEndOfThePlaylist_IsANoOp() + public void PlaybackSpeed_WhenNotPositive_FallsBackToNormalSpeed() { - using var clipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - using var controller = CreateController(front, mediaSourceBuilder: mediaSourceBuilder); + using var rig = new Rig(); - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - - // The only clip is also the last one. - // Next must bail out before the teardown, not stop what's playing to then go nowhere. - await controller.NextAsync(); + rig.Controller.PlaybackSpeed = 0; - controller.CanGoNext.ShouldBeFalse(); - controller.CurrentClip.ShouldBe(clipFiles.Clip); - mediaSourceBuilder.BuildCount.ShouldBe(1); - controller.IsMediaOpen.ShouldBeTrue(); + rig.Controller.PlaybackSpeed.ShouldBe(1.0); } [Fact] - public async Task GoToClipAsync_ByIndex_MovesAndIgnoresOutOfRangeIndices() + public async Task NextAsync_WithAnotherClip_OpensItAndPreviousAsyncReturns() { - using var firstClipFiles = TestClipFiles.Create(chunkCount: 1); - using var secondClipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - using var controller = CreateController(front); - - controller.LoadClips([firstClipFiles.Clip, secondClipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); + using var rig = new Rig(chunkCount: 1); + using var secondFiles = TestClipFiles.Create(chunkCount: 1); + await rig.OpenAsync(rig.Clip, secondFiles.Clip); + rig.Controller.CanGoNext.ShouldBeTrue(); + rig.Controller.CanGoPrevious.ShouldBeFalse(); - await controller.GoToClipAsync(1); - await WaitUntilClipOpenedAsync(controller, front); + await rig.Controller.NextAsync(); + await rig.Controller.WhenIdleAsync(); + rig.Controller.CurrentClip.ShouldBe(secondFiles.Clip); + rig.Front.OpenedPaths[^1].ShouldStartWith(secondFiles.RootPath); + rig.Controller.CanGoNext.ShouldBeFalse(); - controller.CurrentClip.ShouldBe(secondClipFiles.Clip); + await rig.Controller.PreviousAsync(); + await rig.Controller.WhenIdleAsync(); + rig.Controller.CurrentClip.ShouldBe(rig.Clip); + rig.Front.OpenedPaths[^1].ShouldStartWith(rig.Files.RootPath); + rig.Controller.IsPlaying.ShouldBeTrue(); + } - // An index that no longer addresses a clip (a stale selection from a list that has since shrunk) must leave playback exactly where it is. - // CurrentClip alone doesn't prove that: ClipPlaylist.MoveTo rejects the bad index on its own, so the controller could still have torn playback down on the way there. - // The stop count and the open/loading flags are what pin the controller's own guard. - var stopCountBeforeBadIndex = front.StopCount; + [Fact] + public async Task NextAsync_AtTheEndOfThePlaylist_LeavesTheCurrentClipPlaying() + { + using var rig = new Rig(chunkCount: 1); + await rig.OpenAsync(); - await controller.GoToClipAsync(-1); - controller.CurrentClip.ShouldBe(secondClipFiles.Clip); - front.StopCount.ShouldBe(stopCountBeforeBadIndex); - controller.IsMediaOpen.ShouldBeTrue(); - controller.IsLoading.ShouldBeFalse(); + await rig.Controller.NextAsync(); + await rig.Controller.WhenIdleAsync(); - await controller.GoToClipAsync(99); - controller.CurrentClip.ShouldBe(secondClipFiles.Clip); - front.StopCount.ShouldBe(stopCountBeforeBadIndex); - controller.IsMediaOpen.ShouldBeTrue(); - controller.IsLoading.ShouldBeFalse(); + rig.Front.OpenedPaths.Count.ShouldBe(1); + rig.Controller.CurrentClip.ShouldBe(rig.Clip); + rig.Controller.IsPlaying.ShouldBeTrue(); } [Fact] - public async Task Dispose_WhileOperationInFlight_DoesNotThrow() + public async Task GoToClipAsync_ByIndex_MovesAndIgnoresOutOfRangeIndices() { - using var firstClipFiles = TestClipFiles.Create(chunkCount: 1); - using var secondClipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - using var controller = CreateController(front); + using var rig = new Rig(chunkCount: 1); + using var secondFiles = TestClipFiles.Create(chunkCount: 1); + await rig.OpenAsync(rig.Clip, secondFiles.Clip); - controller.LoadClips([firstClipFiles.Clip, secondClipFiles.Clip]); - controller.Playlist.MoveTo(0); - await Wait.UntilAsync(() => front.PlayCount > 0); + await rig.Controller.GoToClipAsync(5); + await rig.Controller.GoToClipAsync(-1); + await rig.Controller.WhenIdleAsync(); + rig.Controller.CurrentClip.ShouldBe(rig.Clip); - // Hold the clip-change operation in flight -- it stops the current clip inside the serialized operation lock, so the lock is held while we dispose. - front.StopGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var changeClipTask = controller.GoToClipAsync(secondClipFiles.Clip); - await Wait.UntilAsync(() => front.StopCount > 0); + await rig.Controller.GoToClipAsync(1); + await rig.Controller.WhenIdleAsync(); + rig.Controller.CurrentClip.ShouldBe(secondFiles.Clip); + } - // Closing the window disposes the controller (and its operation lock) mid-operation. - controller.Dispose(); + [Fact] + public async Task LoadClipsAsync_WhilePlaying_StopsPlaybackAndClearsTheSelection() + { + using var rig = new Rig(chunkCount: 1); + await rig.OpenAsync(); - // Let the in-flight operation finish. - // Before the fix, releasing the now-disposed operation lock threw ObjectDisposedException, which surfaced through the awaited task. - front.StopGate.SetResult(null); - await changeClipTask; + await rig.Controller.LoadClipsAsync(TestClips.Create(2)); - front.DisposeCount.ShouldBe(1); + rig.All.ShouldAllBe(player => !player.IsOpen && !player.IsPlaying); + rig.Controller.CurrentClip.ShouldBeNull(); + rig.Controller.Playlist.Clips.Count.ShouldBe(2); + rig.Controller.IsPlaying.ShouldBeFalse(); + rig.Controller.IsMediaOpen.ShouldBeFalse(); } [Fact] - public async Task LoadClipsAsync_StopsCurrentPlaybackAndResetsSelection() + public async Task Dispose_WhileAnOpenIsInFlight_DisposesPlayersAndNeverPlays() { - using var firstClipFiles = TestClipFiles.Create(chunkCount: 1); - using var secondClipFiles = TestClipFiles.Create(chunkCount: 1); - var front = new FakeCameraPlayer(); - using var controller = CreateController(front); - - controller.LoadClips([firstClipFiles.Clip]); - controller.Playlist.MoveTo(0); - await Wait.UntilAsync(() => front.PlayCount > 0); + var rig = new Rig(); + var gate = new TaskCompletionSource(); + rig.Front.OpenGate = gate; + rig.Controller.LoadClips([rig.Clip]); + rig.Controller.Playlist.MoveTo(0); + await Wait.UntilAsync(() => rig.Front.OpenedPaths.Count == 1); - await controller.LoadClipsAsync([secondClipFiles.Clip]); + rig.Dispose(); + gate.SetResult(); + await rig.Controller.WhenIdleAsync(); - controller.CurrentClip.ShouldBeNull(); - controller.Playlist.Clips.ShouldBe([secondClipFiles.Clip]); - controller.IsPlaying.ShouldBeFalse(); - controller.Duration.ShouldBe(TimeSpan.Zero); - front.CloseCount.ShouldBeGreaterThan(0); + rig.All.ShouldAllBe(player => player.IsDisposed && player.Count("play") == 0); } } diff --git a/SentryDeck/ClipConverters.cs b/SentryDeck/ClipConverters.cs index ad1e0ab..5e6ecbf 100644 --- a/SentryDeck/ClipConverters.cs +++ b/SentryDeck/ClipConverters.cs @@ -152,12 +152,15 @@ public object Convert(object value, Type targetType, object parameter, CultureIn try { + // Decode from a stream, not UriSource: for a URI, WPF first asks URLMON which security zone the file belongs to, and that COM round trip cost the UI thread about a second across the first screen of the clip list at startup. + // OnLoad reads the whole image during EndInit, so the stream can be closed right after. + // No IgnoreImageCache: the image cache is keyed by URI, and with only a stream WPF throws trying to evict a null key. + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); var bitmap = new BitmapImage(); bitmap.BeginInit(); bitmap.CacheOption = BitmapCacheOption.OnLoad; - bitmap.CreateOptions = BitmapCreateOptions.IgnoreImageCache; bitmap.DecodePixelWidth = 192; - bitmap.UriSource = new Uri(path); + bitmap.StreamSource = stream; bitmap.EndInit(); bitmap.Freeze(); return bitmap; diff --git a/SentryDeck/MainWindow.xaml b/SentryDeck/MainWindow.xaml index 55bff56..d36c960 100644 --- a/SentryDeck/MainWindow.xaml +++ b/SentryDeck/MainWindow.xaml @@ -13,7 +13,6 @@ Closing="Window_Closing" ContentRendered="Window_ContentRendered" Foreground="{DynamicResource TextControlForeground}" - KeyDown="Window_KeyDown" PreviewKeyDown="Window_PreviewKeyDown" PreviewMouseDown="Window_PreviewMouseDown" mc:Ignorable="d"> @@ -36,7 +35,7 @@ @@ -980,7 +979,7 @@ - @@ -999,7 +998,7 @@ Keyboard.Focus(VideoContainer))); } - private async void Window_KeyDown(object sender, KeyEventArgs e) - { - // While typing in the search box, keys are text (space, digits, arrows), so don't hijack them for playback/camera shortcuts. - if (SearchBox.IsKeyboardFocused) - { - return; - } - - if (await _viewModel.HandleKeyDownAsync(e.Key, Keyboard.Modifiers)) - { - e.Handled = true; - } - } - private void SeekSlider_PreviewMouseDown(object sender, MouseButtonEventArgs e) { _viewModel.BeginSeek(); @@ -212,7 +206,7 @@ private void HookCameraClick(FlyleafHost host, string cameraView) { _viewModel.SelectCameraViewCommand.Execute(cameraView); - // The click moved Win32 focus to the native Flyleaf surface, which would swallow every keyboard shortcut (they're handled in Window_KeyDown). + // The click moved Win32 focus to the native Flyleaf surface, which never routes keys back into WPF, so every keyboard shortcut would go dead. // Pull it back onto the video container, a neutral focusable element that consumes no shortcut keys (see its remarks in the XAML). Activate(); Keyboard.Focus(VideoContainer); diff --git a/SentryDeck/MainWindowViewModel.cs b/SentryDeck/MainWindowViewModel.cs index d922489..2195a39 100644 --- a/SentryDeck/MainWindowViewModel.cs +++ b/SentryDeck/MainWindowViewModel.cs @@ -758,7 +758,11 @@ private async Task StopAsync() if (_playerController is null) return; + // A selection that is still waiting to load would otherwise start playing right after the stop. + _selectionCts?.Cancel(); + await _playerController.StopAsync(); + IsLoading = false; SeekPosition = 0; NowPlayingClip = null; } @@ -1173,19 +1177,60 @@ public void RequestSearchFocus() SearchBoxFocusRequested?.Invoke(this, EventArgs.Empty); } + /// + /// Handles an app-wide shortcut and reports synchronously whether the key was one. + /// The view calls this from a tunneling key handler and must mark the key handled before any await, or a focused button would also act on it (Space clicks whatever button was last clicked). + /// + public bool HandleKeyDown(Key key, ModifierKeys modifiers) + { + var action = ResolveKeyAction(key, modifiers); + if (action is null) + { + return false; + } + + _ = RunKeyActionAsync(action, key); + return true; + } + + /// Awaitable form of for callers (tests) that need the shortcut's work to have finished. public async Task HandleKeyDownAsync(Key key, ModifierKeys modifiers) + { + var action = ResolveKeyAction(key, modifiers); + if (action is null) + { + return false; + } + + await action(); + return true; + } + + private static async Task RunKeyActionAsync(Func action, Key key) + { + try + { + await action(); + } + catch (Exception ex) + { + Log.Error(ex, "Keyboard shortcut failed. Key={Key}", key); + } + } + + // Returns the work a shortcut performs, or null when the key isn't a shortcut in the current state. + private Func ResolveKeyAction(Key key, ModifierKeys modifiers) { if (IsSearchFocusShortcut(key, modifiers)) { - RequestSearchFocus(); - return true; + return Run(RequestSearchFocus); } // The About/Help page replaces the player, so playback/camera/trim shortcuts must not act on the hidden player behind it. // (F1/Esc page navigation lives in the view's PreviewKeyDown, and the search shortcut above intentionally still leaves the page.) if (ShowAboutPage) { - return false; + return null; } // Number keys switch camera views: 1 is the grid, then one key per camera tile in strip order (2 = Front, ... up to 7 on six-camera HW4 clips). @@ -1208,109 +1253,83 @@ public async Task HandleKeyDownAsync(Key key, ModifierKeys modifiers) var numberedOption = CameraViewOptions.FirstOrDefault(option => option.ShortcutNumber == shortcutNumber); if (numberedOption is not null) { - SelectCameraView(numberedOption.ViewId); - return true; + return Run(() => SelectCameraView(numberedOption.ViewId)); } if (key == Key.E && HasEventMarker) { - await JumpToEventAsync(); - return true; + return JumpToEventAsync; } if (key == Key.I && CanSeek) { - MarkSelectionStart(); - return true; + return Run(MarkSelectionStart); } if (key == Key.O && CanSeek) { - MarkSelectionEnd(); - return true; + return Run(MarkSelectionEnd); } if (key == Key.Escape && IsTrimming) { - CancelTrim(); - return true; + return Run(CancelTrim); } } if (key == Key.E && modifiers == ModifierKeys.Control && CanExportSelection) { - await ExportSelectionAsync(); - return true; + return ExportSelectionAsync; } - // Shift+, / Shift+. - // (i.e. < / >) step the playback speed, YouTube-style. + // Shift+, / Shift+. (i.e. < / >) step the playback speed, YouTube-style. // Unmodified , / . remain frame-step below. if (modifiers == ModifierKeys.Shift) { if (key == Key.OemComma) { - DecreaseSpeed(); - return true; + return Run(DecreaseSpeed); } if (key == Key.OemPeriod) { - IncreaseSpeed(); - return true; + return Run(IncreaseSpeed); } } - if (_playerController is null) + var controller = _playerController; + if (controller is null) { - return false; + return null; } - switch (key) + return key switch { - case Key.Space: - await _playerController.TogglePlayPauseAsync(); - return true; - - case Key.OemComma when modifiers == ModifierKeys.None && CanSeek: - await _playerController.StepFrameAsync(forward: false); - return true; - - case Key.OemPeriod when modifiers == ModifierKeys.None && CanSeek: - await _playerController.StepFrameAsync(forward: true); - return true; - - case Key.Left: - if (modifiers == ModifierKeys.Control && CanGoPrevious) - { - await _playerController.PreviousAsync(); - SelectedClip = _playerController.CurrentClip; - } - else if (CanSeek) - { - var position = _playerController.Position - TimeSpan.FromSeconds(5); - await _playerController.SeekAsync(position < TimeSpan.Zero ? TimeSpan.Zero : position); - } - - return true; + Key.Space => controller.TogglePlayPauseAsync, + Key.OemComma when modifiers == ModifierKeys.None && CanSeek => () => controller.StepFrameAsync(forward: false), + Key.OemPeriod when modifiers == ModifierKeys.None && CanSeek => () => controller.StepFrameAsync(forward: true), + Key.Left when modifiers == ModifierKeys.Control => CanGoPrevious ? PreviousAsync : Run(() => { }), + Key.Right when modifiers == ModifierKeys.Control => CanGoNext ? NextAsync : Run(() => { }), + Key.Left => () => SeekRelativeAsync(TimeSpan.FromSeconds(-5)), + Key.Right => () => SeekRelativeAsync(TimeSpan.FromSeconds(5)), + _ => null, + }; - case Key.Right: - if (modifiers == ModifierKeys.Control && CanGoNext) - { - await _playerController.NextAsync(); - SelectedClip = _playerController.CurrentClip; - } - else if (CanSeek) - { - var duration = _playerController.Duration; - var position = _playerController.Position + TimeSpan.FromSeconds(5); - await _playerController.SeekAsync(position > duration ? duration : position); - } + static Func Run(Action action) => () => + { + action(); + return Task.CompletedTask; + }; + } - return true; + private Task SeekRelativeAsync(TimeSpan offset) + { + if (_playerController is not { } controller || !CanSeek) + { + return Task.CompletedTask; } - return false; + return controller.SeekByAsync(offset); } public void BeginSeek() @@ -1320,6 +1339,10 @@ public void BeginSeek() _seekGeneration++; _isSeeking = true; _scrubCoalescer.Reset(); + + // Holds playback paused for the gesture, so each scrub is one cheap paused seek and the release resumes every camera together. + // The controller serializes this ahead of the first scrub seek, so it needs no await here. + _ = _playerController.BeginScrubAsync(); } } @@ -1337,8 +1360,9 @@ public async Task EndSeekAsync() // When the in-flight scrub completes it would otherwise re-issue that value as a keyframe seek AFTER the accurate seek below (both queue on the controller's serialized-operation lock in that order), leaving the playhead on a keyframe instead of the release point. _scrubCoalescer.CancelPending(); - // _isSeeking stays true until after the accurate seek below completes, so a scrub seek still winding down from the drag doesn't race it: SeekToCurrentPositionAsync's SeekAsync shares the controller's serialized-operation lock with ScrubSeekAsync, so it naturally waits behind (and thus supersedes the effect of) any in-flight scrub seek issued by the coalescer rather than racing it. - await SeekToCurrentPositionAsync(); + // _isSeeking stays true until the release seek completes, so the position sync can't pull the thumb back while a scrub seek winds down. + // The release seek queues behind any in-flight scrub on the controller's serialized-operation lock, so it always lands last. + await _playerController.EndScrubAsync(CurrentSeekTargetPosition()); // Only the latest gesture's completion may end the seeking state: if the user has already grabbed the thumb again, this completion is stale and their new drag owns the flag. if (generation == _seekGeneration) @@ -1781,7 +1805,8 @@ private void InvokeOnDispatcher(Action action) return; } - _dispatcher.Invoke(action); + // Never block the caller: a synchronous hop from a background thread while the UI thread waits on that thread is a deadlock. + _dispatcher.BeginInvoke(action); } partial void OnSelectedClipChanged(CamClip value) diff --git a/SentryDeck/Playback/FlyleafCameraPlayer.cs b/SentryDeck/Playback/FlyleafCameraPlayer.cs index abdef0f..fdc0d70 100644 --- a/SentryDeck/Playback/FlyleafCameraPlayer.cs +++ b/SentryDeck/Playback/FlyleafCameraPlayer.cs @@ -1,46 +1,92 @@ using System.ComponentModel; -using System.Diagnostics; using System.Windows.Media; +using System.Windows.Threading; using FlyleafLib; using FlyleafLib.Controls.WPF; using FlyleafLib.MediaPlayer; +using Serilog; namespace SentryDeck; /// /// Flyleaf-backed player for one camera view. /// +/// +/// Flyleaf's Play, Pause, Stop, and Open block the calling thread until its worker threads wind down, and it raises PlaybackStopped from its own play thread. +/// Calling those on the UI thread while a play-thread callback waits on the UI thread deadlocks the app, so every command runs on the thread pool and every event is posted back to the UI dispatcher. +/// Commands for one camera are serialized, because Flyleaf's own locking assumes one caller at a time. +/// internal sealed class FlyleafCameraPlayer : ICameraPlayer { + /// + /// How long a seek waits for Flyleaf to report the target frame before returning anyway. + /// A real seek completes within tens of milliseconds; the cap only keeps a seek that Flyleaf silently drops from stalling the caller. + /// + private static readonly TimeSpan SeekCompletionTimeout = TimeSpan.FromSeconds(3); + + /// + /// Fallback frame rate for the backward-step seek when the open stream doesn't report one. + /// + private const double FallbackStepFps = 30.0; + + /// + /// Safety factor applied to the backward-step target so PTS rounding can't make the accurate seek land back on the frame currently displayed. + /// Accurate seeks present the frame at or before the target, so overshooting slightly into the previous frame is what we want. + /// + private const double BackwardStepPtsGuard = 1.1; + + /// + /// Backward-step distances in frames, tried in order until the position moves. + /// Only the first normally runs; the wider ones cross gaps where the camera dropped frames. + /// + private static readonly double[] BackwardStepAttempts = [BackwardStepPtsGuard, BackwardStepPtsGuard + 1, BackwardStepPtsGuard + 3]; + + /// + /// Serializes Stop across every camera. + /// Stop resets the player's renderer, and the players share one D3D device, so two resets at once crash the process inside the swap chain release. + /// + private static readonly SemaphoreSlim RendererResetGate = new(1, 1); + private readonly FlyleafHost _host; private readonly Player _player; - private bool _isDisposed; - private bool _isOpen; - private bool _isStopping; + private readonly Dispatcher _dispatcher; + private readonly SemaphoreSlim _commandGate = new(1, 1); + private TaskCompletionSource _pendingSeek; + private volatile bool _isDisposed; + private volatile bool _isOpen; + + // True from an open attempt until the next close; a player that never loaded anything has nothing to stop, and stopping it would still pay for a renderer reset. + private volatile bool _hasMedia; + + // Bumped on every open and close. + // Events captured under an older generation belong to media that is gone, so they are dropped instead of reaching the controller. + private int _mediaGeneration; public FlyleafCameraPlayer(FlyleafHost host) { _host = host ?? throw new ArgumentNullException(nameof(host)); + _dispatcher = host.Dispatcher; _player = new Player(CreateConfig()); // All shortcuts are app-wide and act on every camera at once; Flyleaf's default bindings (space, arrows, …) would pause/seek only the player whose surface has focus. - // Must run AFTER the Player ctor: KeysConfig.SetPlayer force-loads the defaults into any empty binding list, so clearing the config up front is undone (and RemoveAll on a fresh config NREs, since Keys is null until SetPlayer runs). - // Belt-and-braces with FlyleafHost.KeyBindings=None on the hosts. + // Must run AFTER the Player ctor: KeysConfig.SetPlayer force-loads the defaults into any empty binding list, so clearing the config up front is undone. _player.Config.Player.KeyBindings.RemoveAll(); _host.Player = _player; _player.PlaybackStopped += OnPlaybackStopped; _player.PropertyChanged += OnPropertyChanged; + _player.SeekCompleted += OnSeekCompleted; } - public event EventHandler Opened; public event EventHandler Ended; public event EventHandler Failed; public event EventHandler PositionChanged; public bool IsOpen => _isOpen; + public bool IsEnded => _isOpen && _player.Status == Status.Ended; + public TimeSpan Position => TimeSpan.FromTicks(_player.CurTime); public double Speed @@ -60,11 +106,13 @@ public async Task OpenAsync(string path) ArgumentException.ThrowIfNullOrWhiteSpace(path); ThrowIfDisposed(); - _isStopping = false; + Interlocked.Increment(ref _mediaGeneration); + _isOpen = false; + _hasMedia = true; try { - var result = await Task.Run(() => _player.Open( + var result = await RunCommandAsync(() => _player.Open( path, defaultPlaylistItem: true, defaultVideo: true, @@ -72,137 +120,123 @@ public async Task OpenAsync(string path) defaultSubtitles: false, forceSubtitles: false)); - _isOpen = result.Success; - if (result.Success) - { - Opened?.Invoke(this, EventArgs.Empty); - } - else + if (result is null || !result.Success) { - RaiseFailed(result.Error); + Log.Warning("Flyleaf failed to open media. File={File}; Error={Error}", path, result?.Error); + return false; } - return result.Success; + _isOpen = true; + return true; } - catch (Exception ex) + catch (Exception ex) when (ex is not ObjectDisposedException) { - _isOpen = false; - Failed?.Invoke(this, new CameraPlaybackFailedEventArgs(ex)); + Log.Warning(ex, "Flyleaf threw while opening media. File={File}", path); return false; } } - public Task PlayAsync() + public Task PlayAsync() => RunCommandAsync(() => { - ThrowIfDisposed(); - _player.Play(); - return Task.CompletedTask; - } - - public Task PauseAsync() - { - ThrowIfDisposed(); - _player.Pause(); - return Task.CompletedTask; - } - - public Task StopAsync() - { - return Task.Run(StopAndClose); - } + if (_isOpen) + { + _player.Play(); + } + }); - public Task CloseAsync() + public Task PauseAsync() => RunCommandAsync(() => { - return Task.Run(StopAndClose); - } - - /// - /// How long a seek issued from the end of a clip waits for Flyleaf to leave its Ended state before returning anyway. - /// The flip normally lands within a few milliseconds; the cap only keeps a seek that never runs from stalling the caller. - /// - private static readonly TimeSpan LeaveEndedTimeout = TimeSpan.FromSeconds(1); + if (_isOpen) + { + _player.Pause(); + } + }); - public async Task SeekAsync(TimeSpan position, bool accurate = true) + public async Task CloseAsync() { - ThrowIfDisposed(); - - if (!_isOpen) + if (_isDisposed || !_hasMedia) { return; } - var milliseconds = (int)Math.Clamp(position.TotalMilliseconds, 0, int.MaxValue); - - // Flyleaf's Play() silently does nothing while the player is Ended, and a seek issued from the end only moves it back to Paused later, from the background task that performs the seek. - // Returning before that happens lets the very next Play() land on Ended and be dropped, leaving the video frozen while the transport reports playback. - // That is exactly what pressing play on a finished clip does, since PlayAsync seeks to the start and plays back to back. - var seekingFromEnded = _player.Status == Status.Ended; + Interlocked.Increment(ref _mediaGeneration); + _isOpen = false; + _hasMedia = false; - if (accurate) + await RendererResetGate.WaitAsync(); + try { - _player.SeekAccurate(milliseconds); + await RunCommandAsync(_player.Stop); } - else + finally { - // Keyframe seek: jumps to the nearest preceding keyframe instead of decoding forward to the exact frame. - // Far cheaper, so it's used for live scrubbing while dragging the seek bar; the final release seek always goes through the accurate path above. - _player.Seek(milliseconds, forward: false); + RendererResetGate.Release(); } + } - if (seekingFromEnded) + public async Task SeekAsync(TimeSpan position, bool accurate = true) + { + ThrowIfDisposed(); + + await _commandGate.WaitAsync(); + try { - var started = Stopwatch.GetTimestamp(); - while (!_isDisposed && _player.Status == Status.Ended && Stopwatch.GetElapsedTime(started) < LeaveEndedTimeout) - { - await Task.Delay(5); - } + await SeekCoreAsync(position, accurate); + } + finally + { + _commandGate.Release(); } } - /// - /// Fallback frame rate for the backward-step seek when the open stream doesn't report one. - /// - private const double FallbackStepFps = 30.0; - - /// - /// Safety factor applied to the backward-step target so PTS rounding can't make the accurate seek land back on the frame currently displayed (accurate seeks present the frame at or before the target, so overshooting slightly INTO the previous frame is what we want). - /// - private const double BackwardStepPtsGuard = 1.1; - - public Task StepFrameAsync(bool forward) + public async Task StepFrameAsync(bool forward) { ThrowIfDisposed(); - if (!_isOpen) + await _commandGate.WaitAsync(); + try { - return Task.CompletedTask; - } + if (!_isOpen) + { + return; + } - return Task.Run(() => - { if (forward) { - _player.ShowFrameNext(); + await Task.Run(_player.ShowFrameNext); + return; } - else + + // Do NOT "simplify" this back to Flyleaf's ShowFramePrev. + // On our ffconcat (FFmpeg concat demuxer) playlists it is a silent no-op on footage with no audio track, which is the footage this app opens. + // A quick test on a sample file that does carry audio will suggest this workaround is unnecessary; it is not. + // Backward stepping is therefore a small accurate seek: one frame duration back, padded by a PTS-rounding guard so the seek presents the previous frame rather than re-presenting the current one. + var fps = _player.Video?.FPS ?? 0; + if (fps <= 0 || double.IsNaN(fps)) + { + fps = FallbackStepFps; + } + + var frameTicks = (long)(TimeSpan.TicksPerSecond / fps); + var startTicks = _player.CurTime; + + // An accurate seek presents the first frame no earlier than half a frame before the target. + // Where the camera dropped a frame, the gap is wider than one frame, so the one-frame step lands back on the current frame; widen the step until the position actually moves. + foreach (var framesBack in BackwardStepAttempts) { - // Do NOT "simplify" this back to Flyleaf's ShowFramePrev. - // On our ffconcat (FFmpeg concat demuxer) playlists it is a silent no-op -- CurTime never moves, nothing throws -- and on FlyleafLib 3.10.4 it also poisoned the decoder so the NEXT ShowFrameNext jumped ahead by 0.5s+ instead of one frame (verified against real footage, 2026-07). - // Re-measured on 3.11.3 after its seek and frame-stepping lock fix (2026-08): the poisoning is gone, and a following ShowFrameNext advances exactly one frame again, but the backward step itself still moved 0 of 8 attempts on footage with no audio track, which is the footage this app opens. - // It does step correctly on 3.11.3 when the media happens to carry an audio track, so a quick test on the wrong sample file will suggest this workaround is unnecessary; it is not. - // Backward stepping is therefore a small accurate seek: one frame duration back, padded by a PTS-rounding guard so the seek reliably presents the PREVIOUS frame rather than re-presenting the current one. - // Accurate seeks are proven reliable on these playlists, including while paused. - var fps = _player.Video?.FPS ?? 0; - if (fps <= 0 || double.IsNaN(fps)) + var targetTicks = Math.Max(0, startTicks - (long)(framesBack * frameTicks)); + await SeekCoreAsync(TimeSpan.FromTicks(targetTicks), accurate: true); + + if (_player.CurTime < startTicks || targetTicks == 0) { - fps = FallbackStepFps; + break; } - - var frameTicks = (long)(TimeSpan.TicksPerSecond / fps); - var targetTicks = Math.Max(0, _player.CurTime - (long)(BackwardStepPtsGuard * frameTicks)); - _player.SeekAccurate((int)(targetTicks / TimeSpan.TicksPerMillisecond)); } - }); + } + finally + { + _commandGate.Release(); + } } public void Dispose() @@ -211,8 +245,12 @@ public void Dispose() return; _isDisposed = true; + Interlocked.Increment(ref _mediaGeneration); + _isOpen = false; _player.PlaybackStopped -= OnPlaybackStopped; _player.PropertyChanged -= OnPropertyChanged; + _player.SeekCompleted -= OnSeekCompleted; + _pendingSeek?.TrySetResult(false); _host.Player = null; _player.Dispose(); } @@ -232,6 +270,10 @@ private static Config CreateConfig() { AutoPlay = false, SeekAccurate = true, + + // The default only publishes CurTime when the whole second changes, so the seek bar stepped once a second and end-of-clip checks worked from a position up to a second stale. + // The engine's refresh tick publishes every player's time in one batched UI update instead. + UICurTime = UIRefreshType.PerUIRefreshInterval, }, Video = { @@ -254,60 +296,149 @@ private static Config CreateConfig() // Clip playlists are ffconcat files with absolute paths; "safe=0" tells FFmpeg's concat demuxer to allow them (it refuses absolute/outside-directory paths by default). config.Demuxer.FormatOpt["safe"] = "0"; + // Every seek force-interrupts the demuxer's in-flight read. + // When that read is the concat demuxer opening the next chunk file, FFmpeg is left holding a half-initialized input and the next av_seek_frame dereferences it and kills the process with an access violation. + // A scrub stress run crashed 3 times in 8 with interrupts on and 0 in 12 with them off, and a single-file clip never crashed either way. + // Interrupts only help cut short slow network reads; every input here is a local file. + config.Demuxer.AllowReadInterrupts = false; + return config; } - private void StopAndClose() + private async Task RunCommandAsync(Func command) { - if (_isDisposed) - return; + ThrowIfDisposed(); + await _commandGate.WaitAsync(); try { - _isStopping = true; - _player.Stop(); + return await Task.Run(command); } finally { - _isOpen = false; + _commandGate.Release(); } } - private void OnPlaybackStopped(object sender, PlaybackStoppedArgs e) + private async Task RunCommandAsync(Action command) { - if (_isStopping || _isDisposed) - return; + ThrowIfDisposed(); + + await _commandGate.WaitAsync(); + try + { + await Task.Run(command); + } + finally + { + _commandGate.Release(); + } + } - if (!string.IsNullOrWhiteSpace(e.Error)) + // Callers hold the command gate, so at most one seek is pending per player. + private async Task SeekCoreAsync(TimeSpan position, bool accurate) + { + if (!_isOpen || !_player.CanPlay) { - _isOpen = false; - RaiseFailed(e.Error); return; } - if (_player.Status == Status.Ended) + var milliseconds = (int)Math.Clamp(position.TotalMilliseconds, 0, int.MaxValue); + + // A seek issued while playing is executed by Flyleaf's play thread, which never raises SeekCompleted, so there is nothing to wait for. + // The controller pauses before seeking, so this is only a fallback. + var awaitCompletion = _player.Status != Status.Playing; + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (awaitCompletion) + { + _pendingSeek = completion; + } + + try + { + if (accurate) + { + _player.SeekAccurate(milliseconds); + } + else + { + _player.Seek(milliseconds, forward: false); + } + + if (awaitCompletion) + { + await completion.Task.WaitAsync(SeekCompletionTimeout); + } + } + catch (TimeoutException) + { + Log.Debug("Seek did not report completion in time. Target={Target}; Status={Status}", position, _player.Status); + } + finally + { + Interlocked.CompareExchange(ref _pendingSeek, null, completion); + } + } + + private void OnSeekCompleted(object sender, int milliseconds) + { + // Flyleaf raises -1 for an intermediate or failed step; only a real landing position ends the wait. + if (milliseconds >= 0) { - // Reaching end-of-stream does NOT close the media: Flyleaf keeps the demuxer open at EOF, so playback can still be replayed, scrubbed, or frame-stepped from the parked end position. - // Clearing _isOpen here made every open-player-gated operation (PlayAsync's replay, SeekAsync, StepFrameAsync) a silent no-op once a clip finished, freezing it on its last frame while the controller still reported IsPlaying. - // Leave it open; the real close happens on StopAndClose (user Stop / clip change / dispose). - Ended?.Invoke(this, EventArgs.Empty); + _pendingSeek?.TrySetResult(true); } } + private void OnPlaybackStopped(object sender, PlaybackStoppedArgs e) + { + // Raised on Flyleaf's play thread; capture what happened now and let the UI thread decide whether it still matters. + var generation = Volatile.Read(ref _mediaGeneration); + var error = e.Error; + var ended = _player.Status == Status.Ended; + + PostIfCurrent(generation, () => + { + if (!string.IsNullOrWhiteSpace(error)) + { + _isOpen = false; + Failed?.Invoke(this, new CameraPlaybackFailedEventArgs(new InvalidOperationException(error))); + } + else if (ended) + { + // Reaching end-of-stream does NOT close the media: Flyleaf keeps the demuxer open at EOF, so playback can still be replayed, scrubbed, or frame-stepped from the parked end position. + Ended?.Invoke(this, EventArgs.Empty); + } + }); + } + private void OnPropertyChanged(object sender, PropertyChangedEventArgs e) { - if (_isDisposed || !_isOpen || e.PropertyName != nameof(Player.CurTime)) + if (e.PropertyName != nameof(Player.CurTime)) return; - PositionChanged?.Invoke(this, new CameraPositionChangedEventArgs(TimeSpan.FromTicks(_player.CurTime))); + var generation = Volatile.Read(ref _mediaGeneration); + PostIfCurrent(generation, () => + PositionChanged?.Invoke(this, new CameraPositionChangedEventArgs(TimeSpan.FromTicks(_player.CurTime)))); } - private void RaiseFailed(string error) + private void PostIfCurrent(int generation, Action action) { - var exception = new InvalidOperationException(string.IsNullOrWhiteSpace(error) - ? "Flyleaf failed to play the media." - : error); - Failed?.Invoke(this, new CameraPlaybackFailedEventArgs(exception)); + void RunIfCurrent() + { + if (!_isDisposed && generation == Volatile.Read(ref _mediaGeneration)) + { + action(); + } + } + + if (_dispatcher.CheckAccess()) + { + RunIfCurrent(); + } + else + { + _dispatcher.BeginInvoke(RunIfCurrent); + } } private void ThrowIfDisposed() diff --git a/SentryDeck/Playback/VideoPlayerController.cs b/SentryDeck/Playback/VideoPlayerController.cs index e6f19fa..e65c2ec 100644 --- a/SentryDeck/Playback/VideoPlayerController.cs +++ b/SentryDeck/Playback/VideoPlayerController.cs @@ -6,13 +6,19 @@ namespace SentryDeck; /// -/// Coordinates Flyleaf camera players, playing each camera's chunk sequence as a single continuous ffconcat playlist so clip playback never stalls at chunk boundaries. +/// Coordinates the camera players, playing each camera's chunk sequence as a single continuous ffconcat playlist so clip playback never stalls at chunk boundaries. /// +/// +/// The controller belongs to one thread (the UI thread in the app), and the players raise their events on it, so state is only ever touched from there. +/// Anything that talks to the players runs as a serialized operation, so a seek never interleaves with a clip change or a recovery, and events that arrive mid-operation queue behind it. +/// Every opened clip is a ; replacing or stopping it cancels its token, and queued work for a session that is no longer current does nothing. +/// Cameras stay in lockstep because every reposition pauses all players, seeks them all to the same instant, and only then resumes them together. +/// public sealed partial class VideoPlayerController : ObservableObject, IDisposable { /// - /// How far short of the front player's position can be when it ends before we treat that as a premature stop (a corrupt/truncated chunk) rather than a normal clip completion. - /// Probed chunk durations are exact, so a genuine end lands within about a frame of Duration; the imprecise case is a fallback-estimated (unprobeable) chunk, and files we can't probe are exactly the files likely to be corrupt. + /// How far short of the front player can stop before that counts as a premature stop (a corrupt or truncated chunk) rather than the real end of the clip. + /// Probed chunk durations are exact, so a genuine end lands within about a frame of Duration. /// private static readonly TimeSpan PrematureEndTolerance = TimeSpan.FromSeconds(3); @@ -26,42 +32,38 @@ public sealed partial class VideoPlayerController : ObservableObject, IDisposabl "This clip appears to be encrypted by the vehicle (Tesla software 2026.20 and later encrypts dashcam recordings by default). To record playable clips, turn off Controls > Safety > Encrypt Dashcam Recordings. Already-encrypted clips can be viewed at dashcam.tesla.com."; /// - /// One-shot guard for the reopen/seek race after a recovery: how long to wait after issuing the resume seek before verifying the front player actually landed near the target, and how far below the target the reported position may sit before the seek is reissued once. + /// How far before a clip's event moment a freshly opened clip starts playing, so the approach to the incident is visible instead of dropping the viewer straight onto the trigger frame. + /// Mirrors the in-car player, which since Tesla's 2024 Holiday Update opens each recording at the event rather than at the top of the buffer. /// - private static readonly TimeSpan DefaultPostRecoverySeekVerifyDelay = TimeSpan.FromMilliseconds(500); + private static readonly TimeSpan EventLeadIn = TimeSpan.FromSeconds(10); - private readonly Func _postRecoverySeekVerifyDelay; + /// + /// How far a paused side camera may sit from the front before resuming realigns it. + /// Pausing stops each camera's play thread independently, so they park a frame or two apart; anything beyond that means a camera fell behind and would stay behind. + /// + private static readonly TimeSpan ResumeAlignmentTolerance = TimeSpan.FromMilliseconds(100); - private static readonly TimeSpan PostRecoverySeekTolerance = TimeSpan.FromSeconds(5); + /// + /// How far a side camera may sit from the front after a forward frame step before it is reseeked onto the front's frame. + /// About one and a half frames at Tesla's 36 fps: a single-frame difference is just two cameras' clocks straddling a frame, anything more is a camera that skipped. + /// + private static readonly TimeSpan StepAlignmentTolerance = TimeSpan.FromMilliseconds(42); /// - /// How far before a clip's event moment a freshly opened clip starts playing, so the approach to the incident is visible instead of dropping the viewer straight onto the trigger frame. - /// Mirrors the in-car player, which since Tesla's 2024 Holiday Update opens each recording at the event rather than at the top of the buffer. - /// A clip with no locatable event opens at the start as before. + /// A play request this close to the end restarts the clip, so pressing play on a finished clip replays it. /// - private static readonly TimeSpan EventLeadIn = TimeSpan.FromSeconds(10); + private static readonly TimeSpan ReplayFromEndWindow = TimeSpan.FromMilliseconds(250); private readonly string _primaryCamera; private readonly ICameraPlayer _primaryPlayer; private readonly IReadOnlyDictionary _players; private readonly IClipMediaSourceBuilder _mediaSourceBuilder; private readonly SemaphoreSlim _operationLock = new(1, 1); - private readonly HashSet _excludedChunkIndices = []; - - // _excludedChunkIndices is mutated under the operation lock (a clip change clears it, recovery adds to it) but is ALSO read outside that lock on a Flyleaf callback thread (recovery maps a failure position back to an original chunk index). - // Guard every access with this lock so a concurrent clear can't throw "Collection was modified" out of an async void player handler. - private readonly Lock _excludedChunkIndicesLock = new(); - private CancellationTokenSource _playbackCts; + private Session _session; + private QueuedSeek _queuedSeek; + private bool _isScrubbing; + private bool _resumeAfterScrub; private bool _isDisposed; - private bool _isOpeningMedia; - private long _activeRequestId; - private long _currentMediaRequestId; - private CamClip _openedClip; - 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))] @@ -86,14 +88,13 @@ public sealed partial class VideoPlayerController : ObservableObject, IDisposabl private bool _isMediaOpen; /// Camera players keyed by camera name. - /// Every present camera is played; the clip decides which are actually opened. The camera that drives the shared clock, is required to open, and anchors corrupt-chunk recovery (front on a real Tesla). - /// Waits before the post-recovery seek is verified. - /// Defaults to a real delay; overridable for tests, which would otherwise pay it in wall-clock time on every recovery test and could not control when the verification runs. + /// Every present camera is played; the clip decides which are actually opened. + /// The camera that drives the shared clock, is required to open, and anchors corrupt-chunk recovery (front on a real Tesla). + /// Builds each clip's playlists; defaults to the ffconcat builder. public VideoPlayerController( IReadOnlyDictionary players, string primaryCamera, - IClipMediaSourceBuilder mediaSourceBuilder = null, - Func postRecoverySeekVerifyDelay = null) + IClipMediaSourceBuilder mediaSourceBuilder = null) { ArgumentNullException.ThrowIfNull(players); ArgumentException.ThrowIfNullOrEmpty(primaryCamera); @@ -112,7 +113,6 @@ public VideoPlayerController( _primaryPlayer = primaryPlayer; _players = players; _mediaSourceBuilder = mediaSourceBuilder ?? new FfconcatMediaSourceBuilder(); - _postRecoverySeekVerifyDelay = postRecoverySeekVerifyDelay ?? (token => Task.Delay(DefaultPostRecoverySeekVerifyDelay, token)); Playlist = new ClipPlaylist(); Playlist.CurrentClipChanged += OnCurrentClipChanged; @@ -120,7 +120,6 @@ public VideoPlayerController( foreach (var player in _players.Values) { - player.Opened += OnPlayerOpened; player.Ended += OnPlayerEnded; player.Failed += OnPlayerFailed; player.PositionChanged += OnPositionChanged; @@ -157,10 +156,9 @@ public static VideoPlayerController Create(IReadOnlyList<(string Camera, Flyleaf /// /// The media source backing the currently opened clip, or null when nothing is open. - /// Exposed (read-only) so callers can map wall-clock instants (e.g. event timestamps) onto the actual playing media time via and read , rather than re-deriving a timeline estimate of their own. - /// Changes alongside and . + /// Exposed so callers can map wall-clock instants (e.g. event timestamps) onto the actual playing media time via and read , rather than re-deriving a timeline estimate of their own. /// - public ClipMediaSource OpenedMediaSource => _openedMediaSource; + public ClipMediaSource OpenedMediaSource => _session is { IsOpen: true } session ? session.Source : null; public bool CanPlayPause => CurrentClip is not null && !IsLoading; @@ -168,219 +166,169 @@ public static VideoPlayerController Create(IReadOnlyList<(string Camera, Flyleaf public bool CanGoPrevious => Playlist.HasPrevious; - public async Task PlayAsync() + private bool IsSessionOpen => _session is { IsOpen: true }; + + public Task PlayAsync() { var clip = CurrentClip; if (clip is null) - return; + return Task.CompletedTask; - if (IsMediaOpen && _openedClip == clip) + if (_session?.Clip == clip && _session.IsOpen) { - if (Duration > TimeSpan.Zero && Position >= Duration - TimeSpan.FromMilliseconds(250)) - { - await SeekAsync(TimeSpan.Zero); - } - - 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(); - - if (Interlocked.Read(ref _playbackEndedCount) == endedCountBeforePlay) - { - IsPlaying = true; - } - } - }); + return RunOperationAsync(_session, "Playback error", ResumeCoreAsync); + } - return; + // The clip is still opening and plays as soon as it's ready; restarting the open would only throw that work away. + if (_session?.Clip == clip && IsLoading) + { + return Task.CompletedTask; } - var requestId = BeginNewRequest(); - await PlayInternalAsync(requestId, clip); + // Nothing usable is open for this clip (stopped, or the open failed), so start it from scratch. + return OpenClipAsync(clip); } - public async Task PauseAsync() + public Task PauseAsync() => RunOperationAsync(_session, "Playback error", async _ => { - await RunSerializedPlaybackOperationAsync(async _ => - { - Log.Debug( - "Pausing playback. ClipName={ClipName}; ClipPath={ClipPath}; Position={Position}", - CurrentClip?.Name, - CurrentClip?.FullPath, - Position); - await PauseOpenPlayersAsync(); - IsPlaying = false; - }); - } + _resumeAfterScrub = false; + await ForEachOpenPlayerAsync(player => player.PauseAsync()); + IsPlaying = false; + Log.Debug("Paused playback. ClipName={ClipName}; Position={Position}", CurrentClip?.Name, Position); + }); - public async Task TogglePlayPauseAsync() - { - if (IsPlaying) - { - await PauseAsync(); - } - else - { - await PlayAsync(); - } - } + public Task TogglePlayPauseAsync() => IsPlaying ? PauseAsync() : PlayAsync(); public async Task StopAsync() { - BeginNewRequest(); - await RunSerializedPlaybackOperationAsync(async _ => + var session = _session; + _session = null; + session?.Cancel(); + + await RunOperationAsync(null, "Playback error", async _ => { - Log.Debug( - "Stopping playback. ClipName={ClipName}; ClipPath={ClipPath}; Position={Position}", - CurrentClip?.Name, - CurrentClip?.FullPath, - Position); - CancelAndDisposePlaybackCts(); - await StopPlaybackInternalAsync(resetPlaybackState: true); + Log.Debug("Stopping playback. ClipName={ClipName}; Position={Position}", CurrentClip?.Name, Position); + await CloseAllPlayersAsync(); + ResetPlaybackState(); + IsLoading = false; }); } - public Task SeekAsync(TimeSpan position) => SeekInternalAsync(position, accurate: true); - /// - /// Like but issues fast keyframe seeks to every open player instead of accurate ones -- intended to be called repeatedly and cheaply while the seek bar thumb is being dragged, so the video keeps up in near-real-time. - /// Same clamping and serialized-operation infrastructure as ; only the seek mode differs. + /// Seeks every camera to , landing exactly on the frame at that time. + /// Playback resumes afterwards if it was running. /// - public Task ScrubSeekAsync(TimeSpan position) => SeekInternalAsync(position, accurate: false); + public Task SeekAsync(TimeSpan position) => QueueSeek(position, accurate: true); - private async Task SeekInternalAsync(TimeSpan position, bool accurate) + /// + /// Seeks relative to where playback is headed: a seek still waiting to run counts as the starting point, so holding an arrow key moves five seconds per press instead of collapsing onto the stale on-screen position. + /// + public Task SeekByAsync(TimeSpan offset) { - if (CurrentClip is null || _isDisposed || !IsMediaOpen || Duration <= TimeSpan.Zero) - return; + var origin = _queuedSeek is { } queued && ReferenceEquals(queued.Session, _session) ? queued.Target : Position; + return QueueSeek(Clamp(origin + offset, TimeSpan.Zero, Duration), accurate: true); + } - try - { - await RunSerializedPlaybackOperationAsync(async _ => - { - if (!IsMediaOpen || _openedClip != CurrentClip) - { - return; - } + /// + /// Like but jumps to the nearest keyframe, which is far cheaper. + /// Intended to be called repeatedly while the seek bar thumb is being dragged, so the video keeps up in near-real-time. + /// + public Task ScrubSeekAsync(TimeSpan position) => QueueSeek(position, accurate: false); - var clampedPosition = Clamp(position, TimeSpan.Zero, Duration); - await SeekOpenPlayersAsync(clampedPosition, accurate); - Position = clampedPosition; - }); - } - catch (OperationCanceledException) + /// + /// Starts a scrub gesture: playback is held paused until so each scrub seek is one cheap paused seek rather than a pause, seek, and resume. + /// + public Task BeginScrubAsync() => RunOperationAsync(_session, "Seek error", async _ => + { + _isScrubbing = true; + _resumeAfterScrub = IsPlaying; + if (IsPlaying) { + await ForEachOpenPlayerAsync(player => player.PauseAsync()); } - catch (Exception ex) + }); + + /// + /// Ends a scrub gesture with an accurate seek to the release point, then resumes playback if it was running when the gesture began. + /// + public Task EndScrubAsync(TimeSpan position) => RunOperationAsync(_session, "Seek error", async _ => + { + // Still in scrub mode here, so the release seek lands on the already-paused players without another pause. + var resume = _isScrubbing && _resumeAfterScrub && IsPlaying; + await RepositionAsync(position, accurate: true); + _isScrubbing = false; + _resumeAfterScrub = false; + + if (resume) { - Log.Error( - ex, - "Seek error. ClipName={ClipName}; ClipPath={ClipPath}; RequestedPosition={RequestedPosition}; Accurate={Accurate}", - CurrentClip?.Name, - CurrentClip?.FullPath, - position, - accurate); - ErrorMessage = $"Seek error: {ex.Message}"; + await PlayAllAsync(); } - } + }); /// - /// Steps every open player one frame forward or backward -- for frame-by-frame incident review. - /// Stepping only makes sense paused, so playback is paused first if active; all open players are stepped in the same direction to keep the four cameras in sync (they share the same frame rate). + /// Steps one frame forward or backward, for frame-by-frame incident review. + /// Stepping only makes sense paused, so playback is paused first. /// - public async Task StepFrameAsync(bool forward) + /// + /// The front steps and the side cameras follow its clock. + /// Letting every camera step on its own drifted them apart, because each camera drops frames in different places: ten steps left the rear a third of a second off the front. + /// Stepping forward is cheap, so side cameras step too and are only reseeked when they've slipped; a backward step is a seek anyway, so they seek straight to the front's new frame. + /// + public Task StepFrameAsync(bool forward) => RunOperationAsync(_session, "Frame step error", async _ => { - if (CurrentClip is null || _isDisposed || !IsMediaOpen) + if (!IsSessionOpen) return; - try + if (IsPlaying) { - await RunSerializedPlaybackOperationAsync(async _ => - { - if (!IsMediaOpen || _openedClip != CurrentClip) - { - return; - } - - if (IsPlaying) - { - await PauseOpenPlayersAsync(); - IsPlaying = false; - } - - var positionBeforeStep = _primaryPlayer.Position; + await ForEachOpenPlayerAsync(player => player.PauseAsync()); + IsPlaying = false; + } - foreach (var player in _players.Values.Where(player => player.IsOpen)) - { - await player.StepFrameAsync(forward); - } + _resumeAfterScrub = false; - // Flyleaf raises PositionChanged (via CurTime) when a stepped frame shows, even while paused, so Position normally updates on its own via OnPositionChanged. - // This is a belt-and-suspenders sync from the front player in case that event doesn't fire for a given step. - Position = Clamp(_primaryPlayer.Position, TimeSpan.Zero, Duration); - - Log.Debug( - "Stepped frame. Forward={Forward}; PositionBefore={PositionBefore}; PositionAfter={PositionAfter}; ClipName={ClipName}", - forward, - positionBeforeStep, - _primaryPlayer.Position, - CurrentClip?.Name); - }); - } - catch (OperationCanceledException) + if (forward) { + await ForEachOpenPlayerAsync(player => player.StepFrameAsync(forward: true)); } - catch (Exception ex) + else { - Log.Error( - ex, - "Frame step error. ClipName={ClipName}; ClipPath={ClipPath}; Forward={Forward}", - CurrentClip?.Name, - CurrentClip?.FullPath, - forward); - ErrorMessage = $"Frame step error: {ex.Message}"; + await _primaryPlayer.StepFrameAsync(forward: false); } - } - public async Task NextAsync() - { - if (!Playlist.HasNext) - return; + var anchor = _primaryPlayer.Position; + await Task.WhenAll(SecondaryPlayers() + .Where(player => !forward || (player.Position - anchor).Duration() > StepAlignmentTolerance) + .Select(player => player.SeekAsync(anchor))); - await PrepareForClipChangeAsync(); + Position = Clamp(anchor, TimeSpan.Zero, Duration); + }); + + private IEnumerable SecondaryPlayers() => + _players.Values.Where(player => !ReferenceEquals(player, _primaryPlayer) && player.IsOpen).ToList(); + + public Task NextAsync() + { Playlist.MoveNext(); + return Task.CompletedTask; } - public async Task PreviousAsync() + public Task PreviousAsync() { - if (!Playlist.HasPrevious) - return; - - await PrepareForClipChangeAsync(); Playlist.MovePrevious(); + return Task.CompletedTask; } - public async Task GoToClipAsync(CamClip clip) + public Task GoToClipAsync(CamClip clip) { - if (clip is null || clip == Playlist.CurrentClip || !Playlist.Clips.Contains(clip)) - return; - - await PrepareForClipChangeAsync(); Playlist.MoveTo(clip); + return Task.CompletedTask; } - public async Task GoToClipAsync(int index) + public Task GoToClipAsync(int index) { - if (index == Playlist.CurrentIndex || index < 0 || index >= Playlist.Clips.Count) - return; - - await PrepareForClipChangeAsync(); Playlist.MoveTo(index); + return Task.CompletedTask; } public async Task LoadClipsAsync(IEnumerable clips) @@ -400,372 +348,211 @@ public void LoadClips(IEnumerable clips) /// public void RemoveClip(CamClip clip) => Playlist.RemoveClip(clip); + /// + /// Completes once every operation queued so far has finished, including work queued by player events. + /// Lets tests await the outcome of a clip change or an end-of-stream instead of polling for it. + /// + internal Task WhenIdleAsync() => RunOperationAsync(null, "Playback error", _ => Task.CompletedTask); + public void Dispose() { if (_isDisposed) return; _isDisposed = true; - CancelAndDisposePlaybackCts(); + _session?.Cancel(); + _session = null; Playlist.CurrentClipChanged -= OnCurrentClipChanged; Playlist.PlaylistChanged -= OnPlaylistChanged; foreach (var player in _players.Values) { - player.Opened -= OnPlayerOpened; player.Ended -= OnPlayerEnded; player.Failed -= OnPlayerFailed; player.PositionChanged -= OnPositionChanged; - player.Dispose(); - } - - _operationLock.Dispose(); - } - - private long BeginNewRequest() - { - return Interlocked.Increment(ref _activeRequestId); - } - - private bool IsRequestActive(long requestId) - { - return requestId == Volatile.Read(ref _activeRequestId); - } - - private async Task RunSerializedPlaybackOperationAsync(Func operation, bool replacePlaybackCts = false) - { - if (_isDisposed) - return; - - var acquired = false; - - try - { - await _operationLock.WaitAsync(); - acquired = true; - - var token = replacePlaybackCts - ? ReplacePlaybackCts().Token - : _playbackCts?.Token ?? CancellationToken.None; - await operation(token); - } - catch (ObjectDisposedException) when (_isDisposed) - { - // The controller was disposed (window closed) while this operation was in flight, so the lock or a player is already gone. - // We're shutting down; nothing to recover. - } - finally - { - if (acquired) + try { - // Dispose() can run on the UI thread while this operation is mid-flight and then dispose the semaphore; releasing it afterwards would throw. - // Benign at shutdown. - try - { - _operationLock.Release(); - } - catch (ObjectDisposedException) - { - } + player.Dispose(); + } + catch (Exception ex) + { + Log.Debug(ex, "Failed to dispose a camera player"); } } } - private CancellationTokenSource ReplacePlaybackCts() - { - CancelAndDisposePlaybackCts(); - _playbackCts = new CancellationTokenSource(); - return _playbackCts; - } - - private void CancelAndDisposePlaybackCts() + private async Task OpenClipAsync(CamClip clip) { - var cts = _playbackCts; - _playbackCts = null; - - if (cts is null) - return; + _session?.Cancel(); + var session = new Session(clip); + _session = session; + _isScrubbing = false; + _resumeAfterScrub = false; - cts.Cancel(); - cts.Dispose(); - } - - private async Task PrepareForClipChangeAsync() - { - BeginNewRequest(); - CancelAndDisposePlaybackCts(); ErrorMessage = null; IsLoading = true; - await Task.Yield(); - - await RunSerializedPlaybackOperationAsync(async _ => + await RunOperationAsync(session, "Playback error", async token => { - await StopPlaybackInternalAsync(resetPlaybackState: true, clearLoading: false); + try + { + await OpenClipCoreAsync(session, token); + } + finally + { + if (ReferenceEquals(_session, session)) + { + IsLoading = false; + } + } }); } - private async Task PlayInternalAsync(long requestId, CamClip clip) + private async Task OpenClipCoreAsync(Session session, CancellationToken token) { - if (clip is null) - return; + var clip = session.Clip; - ErrorMessage = null; - IsLoading = true; + // Stop what was playing before anything slow happens, so switching clips halts the old footage immediately. + await CloseAllPlayersAsync(); + ResetPlaybackState(); - try + if (clip.Chunks.Count == 0) { - await RunSerializedPlaybackOperationAsync(async ct => - { - if (!IsRequestActive(requestId) || clip != CurrentClip) - return; - - await StopPlaybackInternalAsync(resetPlaybackState: false); - - // A freshly selected clip starts with no known-bad chunks and a clean recovery budget, regardless of what happened on the previously playing clip. - lock (_excludedChunkIndicesLock) - { - _excludedChunkIndices.Clear(); - } + Log.Warning("Clip has no playable chunks. ClipName={ClipName}; ClipPath={ClipPath}", clip.Name, clip.FullPath); + ErrorMessage = "No playable footage found."; + return; + } - _recoveryAttempts = 0; + var mediaSource = await Task.Run(() => _mediaSourceBuilder.Build(clip), token); + token.ThrowIfCancellationRequested(); - if (clip.Chunks.Count == 0) - { - Log.Warning( - "Clip has no playable chunks. ClipName={ClipName}; ClipPath={ClipPath}", - clip.Name, - clip.FullPath); - ErrorMessage = "No playable footage found."; - return; - } + Log.Information( + "Starting clip playback. ClipName={ClipName}; ClipPath={ClipPath}; ChunkCount={ChunkCount}; Duration={Duration}", + clip.Name, + clip.FullPath, + clip.Chunks.Count, + mediaSource.Duration); - var mediaSource = await Task.Run(() => _mediaSourceBuilder.Build(clip), ct); - Duration = mediaSource.Duration; - - Log.Information( - "Starting clip playback. ClipName={ClipName}; ClipPath={ClipPath}; ClipIndex={ClipIndex}; ClipCount={ClipCount}; ChunkCount={ChunkCount}; Duration={Duration}; RequestId={RequestId}", - clip.Name, - clip.FullPath, - Playlist.CurrentIndex, - Playlist.Clips.Count, - clip.Chunks.Count, - Duration, - requestId); - await OpenClipInternalAsync(clip, mediaSource, playAfterOpen: true, requestId, ct); - }, replacePlaybackCts: true); - } - catch (OperationCanceledException) - { - } - catch (Exception ex) - { - Log.Error( - ex, - "Playback error. ClipName={ClipName}; ClipPath={ClipPath}; RequestId={RequestId}", - clip.Name, - clip.FullPath, - requestId); - ErrorMessage = $"Playback error: {ex.Message}"; - } - finally - { - if (IsRequestActive(requestId)) - { - IsLoading = false; - } - } + await OpenSourceAsync(session, mediaSource, ResolveEventStartPosition(clip, mediaSource), play: true, token); } - private async Task StopPlaybackInternalAsync(bool resetPlaybackState, bool clearLoading = true) + /// + /// Opens every camera on , positions them all at while paused, and then starts them together. + /// Shared by the first open of a clip and by corrupt-chunk recovery, which reopens a rebuilt source. + /// + private async Task OpenSourceAsync(Session session, ClipMediaSource mediaSource, TimeSpan startPosition, bool play, CancellationToken token) { - Volatile.Write(ref _currentMediaRequestId, 0); - await StopAndClosePlayersAsync(); - - IsMediaOpen = false; - _openedClip = null; - _openedMediaSource = null; + var clip = session.Clip; + session.IsOpen = false; + session.Source = null; OnPropertyChanged(nameof(OpenedMediaSource)); + IsMediaOpen = false; - if (resetPlaybackState) - { - if (clearLoading) - { - IsLoading = false; - } - - IsPlaying = false; - Position = TimeSpan.Zero; - Duration = TimeSpan.Zero; - } - } - - private async Task StopAndClosePlayersAsync() - { - foreach (var player in _players.Values) + // The builder may have dropped unreadable chunks on its own; fold those into the exclusion set so position-to-chunk mapping during recovery stays aligned with the shrunken timeline. + session.ExcludedChunks.UnionWith(mediaSource.AutoExcludedChunkIndices); + if (mediaSource.AutoExcludedChunkIndices.Count > 0) { - try - { - await player.StopAsync(); - } - catch (Exception ex) - { - Log.Debug(ex, "Failed to stop media player during cleanup"); - } - - try - { - await player.CloseAsync(); - } - catch (Exception ex) - { - Log.Debug(ex, "Failed to close media player during cleanup"); - } + Log.Warning( + "Builder auto-excluded unreadable chunks. ClipName={ClipName}; AutoExcludedChunkIndices={AutoExcludedChunkIndices}", + clip.Name, + mediaSource.AutoExcludedChunkIndices); } - } - - private async Task OpenClipInternalAsync( - CamClip clip, - ClipMediaSource mediaSource, - bool playAfterOpen, - long requestId, - CancellationToken cancellationToken) - { - if (clip is null || mediaSource is null) - return; if (!mediaSource.CameraPlaylistPaths.ContainsKey(_primaryCamera)) { Log.Warning( - "Cannot open clip because the primary camera is missing. PrimaryCamera={PrimaryCamera}; ClipName={ClipName}; ClipPath={ClipPath}; Cameras={Cameras}; RequestId={RequestId}", + "Cannot open clip because the primary camera is missing. PrimaryCamera={PrimaryCamera}; ClipName={ClipName}; Cameras={Cameras}", _primaryCamera, clip.Name, - clip.FullPath, - mediaSource.CameraPlaylistPaths.Keys.Order().ToArray(), - requestId); + mediaSource.CameraPlaylistPaths.Keys.Order().ToArray()); - // A fully encrypted clip lands here too: the builder probes every chunk's front file, finds no readable moov in any of them, and excludes them all, indistinguishable from "no footage" without sniffing the files themselves. + // A fully encrypted clip lands here too: the builder finds no readable moov in any front file and excludes every chunk. ErrorMessage = EncryptedClipDetector.LooksEncrypted(clip) ? EncryptedClipMessage : $"No {CameraNames.DisplayName(_primaryCamera)} camera footage found."; return; } - _isOpeningMedia = true; - - try + // A first open finds every player already closed; a recovery reopen still has the failed media loaded. + if (_players.Values.Any(player => player.IsOpen)) { - await StopAndClosePlayersAsync(); - IsMediaOpen = false; - - cancellationToken.ThrowIfCancellationRequested(); + await CloseAllPlayersAsync(); + token.ThrowIfCancellationRequested(); + } - var primaryOpened = await OpenCameraPlayerAsync( - _primaryCamera, - _primaryPlayer, - mediaSource, - required: true, - requestId, - cancellationToken); + Duration = mediaSource.Duration; - if (!primaryOpened) - { - IsPlaying = false; - return; - } + var opens = _players.Select(async pair => (pair.Key, Opened: await OpenCameraAsync(pair.Key, pair.Value, mediaSource))).ToList(); + var results = await Task.WhenAll(opens); + token.ThrowIfCancellationRequested(); - cancellationToken.ThrowIfCancellationRequested(); + if (!results.Single(result => result.Key == _primaryCamera).Opened) + { + ErrorMessage = $"Failed to open {CameraNames.DisplayName(_primaryCamera)} camera video."; + return; + } - _openedClip = clip; - _openedMediaSource = mediaSource; - OnPropertyChanged(nameof(OpenedMediaSource)); + ApplyPlaybackSpeed(); - // The builder may have dropped unreadable chunks on its own; fold those into the exclusion set so position-to-chunk mapping during recovery stays aligned with the shrunken timeline. - // They are not recovery attempts and don't count toward the cap. - if (mediaSource.AutoExcludedChunkIndices.Count > 0) - { - Log.Warning( - "Builder auto-excluded unreadable chunks. ClipName={ClipName}; ClipPath={ClipPath}; AutoExcludedChunkIndices={AutoExcludedChunkIndices}", - clip.Name, - clip.FullPath, - mediaSource.AutoExcludedChunkIndices); - lock (_excludedChunkIndicesLock) - { - _excludedChunkIndices.UnionWith(mediaSource.AutoExcludedChunkIndices); - } - } + var start = Clamp(startPosition, TimeSpan.Zero, Duration); + if (start > TimeSpan.Zero) + { + await ForEachOpenPlayerAsync(player => player.SeekAsync(start)); + token.ThrowIfCancellationRequested(); + } - IsMediaOpen = _primaryPlayer.IsOpen; + session.Source = mediaSource; + session.IsOpen = true; + OnPropertyChanged(nameof(OpenedMediaSource)); + Position = start; + IsMediaOpen = true; - ApplyPlaybackSpeed(); + if (play) + { + await PlayAllAsync(); + } - Position = TimeSpan.Zero; - Volatile.Write(ref _currentMediaRequestId, requestId); + Log.Information( + "Opened clip playback. ClipName={ClipName}; Duration={Duration}; Start={Start}; IsPlaying={IsPlaying}; Cameras={Cameras}", + clip.Name, + mediaSource.Duration, + start, + IsPlaying, + results.Where(result => result.Opened).Select(result => result.Key).Order().ToArray()); + } - if (playAfterOpen) - { - // Get the user watching video as soon as the front camera (the authoritative, required source) is ready, rather than gating first-frame on the slowest of four opens. - // Side cameras join in progress once their own opens complete, below. - await _primaryPlayer.PlayAsync(); - IsPlaying = true; - - // Position events can now flow: the front player is genuinely playing, so this is no different from a fully-completed open as far as Ended/Failed/PositionChanged are concerned. - // Side opens below still run under _isOpeningMedia's other protections indirectly -- those handlers only special-case the front player. - _isOpeningMedia = false; - - // Jump to just before the event moment on open, matching the in-car player. - // Seek AFTER Play (never before): a seek issued while paused right after open can be swallowed by the player, whereas seeks during active playback are reliable -- the same ordering the recovery resume relies on. - // The secondary cameras join at this position below, since they read _primaryPlayer.Position after the seek lands. - var eventStartPosition = ResolveEventStartPosition(clip, mediaSource); - if (eventStartPosition > TimeSpan.Zero) - { - await _primaryPlayer.SeekAsync(eventStartPosition); - Position = eventStartPosition; - } + private async Task OpenCameraAsync(string camera, ICameraPlayer player, ClipMediaSource mediaSource) + { + if (!mediaSource.CameraPlaylistPaths.TryGetValue(camera, out var playlistPath) || !File.Exists(playlistPath)) + { + return false; + } - await OpenAndJoinSecondaryCamerasAsync(mediaSource, requestId, cancellationToken); - } - else + try + { + if (await player.OpenAsync(playlistPath)) { - await OpenSecondaryCamerasAsync(mediaSource, requestId, cancellationToken); - - cancellationToken.ThrowIfCancellationRequested(); - - await PauseOpenPlayersAsync(); - IsPlaying = false; + return true; } - - Log.Information( - "Opened clip playback. ClipName={ClipName}; ClipPath={ClipPath}; Duration={Duration}; IsPlaying={IsPlaying}; Cameras={Cameras}; RequestId={RequestId}", - clip.Name, - clip.FullPath, - mediaSource.Duration, - IsPlaying, - mediaSource.CameraPlaylistPaths.Keys.Order().ToArray(), - requestId); - } - catch (OperationCanceledException) - { - await StopAndClosePlayersAsync(); - IsMediaOpen = false; - throw; } - finally + catch (Exception ex) when (ex is not ObjectDisposedException) { - _isOpeningMedia = false; + Log.Warning(ex, "Camera player threw while opening. Camera={Camera}; File={File}", camera, playlistPath); } + + Log.Warning("Failed to open camera video. Camera={Camera}; File={File}", camera, playlistPath); + return false; } /// - /// The media-time position a freshly opened clip should start playing at: before its event moment when one is locatable within the built media, or otherwise (no event metadata, or an event that falls outside the recorded footage). - /// Uses the same wall-clock-to-media-time mapping as the seek-bar event marker (), so the auto-jump lands consistently with the marker the user sees. + /// The media-time position a freshly opened clip should start at: before its event moment when one is locatable within the built media, or zero otherwise. + /// Uses the same wall-clock-to-media-time mapping as the seek-bar event marker, so the auto-jump lands consistently with the marker the user sees. /// private static TimeSpan ResolveEventStartPosition(CamClip clip, ClipMediaSource mediaSource) { - var camEvent = clip.Event; - if (camEvent is null || camEvent.Timestamp == default) + if (clip.Event is not { } camEvent || camEvent.Timestamp == default) { return TimeSpan.Zero; } @@ -779,174 +566,180 @@ private static TimeSpan ResolveEventStartPosition(CamClip clip, ClipMediaSource return start < TimeSpan.Zero ? TimeSpan.Zero : start; } - /// - /// Opens the three non-front cameras in parallel (as before) but, unlike the pre-playback path, joins each one in as soon as ITS OWN open completes rather than waiting for all three: seeks it to the front player's current (live) position and starts it playing, so the user isn't blocked on the slowest secondary camera to see the front feed. - /// - private async Task OpenAndJoinSecondaryCamerasAsync( - ClipMediaSource mediaSource, - long requestId, - CancellationToken cancellationToken) + private async Task ResumeCoreAsync(CancellationToken token) { - var joinTasks = _players - .Where(cameraPlayer => !ReferenceEquals(cameraPlayer.Value, _primaryPlayer)) - .Select(cameraPlayer => OpenAndJoinSecondaryCameraAsync( - cameraPlayer.Key, - cameraPlayer.Value, - mediaSource, - requestId, - cancellationToken)); - - await Task.WhenAll(joinTasks); + if (!IsSessionOpen) + return; + + _resumeAfterScrub = false; + + if (_primaryPlayer.IsEnded || (Duration > TimeSpan.Zero && Position >= Duration - ReplayFromEndWindow)) + { + // An ended player ignores Play until it is moved off the end, and a finished clip should replay from the top. + await ForEachOpenPlayerAsync(player => player.SeekAsync(TimeSpan.Zero)); + Position = TimeSpan.Zero; + } + else + { + await AlignSecondaryCamerasAsync(); + } + + token.ThrowIfCancellationRequested(); + await PlayAllAsync(); } /// - /// Opens a single secondary camera and, once open, joins it into the already-playing front stream: seeks to the front's current position and plays. - /// Performs a single-shot correction afterward if the join seek's own latency let the gap grow further, so the camera doesn't visibly trail the front by much more than one seek's worth of drift. + /// Queues a reposition, or retargets one that is already queued and hasn't started. + /// Each reposition pauses, seeks, and resumes every camera, so a burst of requests (a held arrow key) would otherwise replay one by one for seconds after the keys stop. /// - private async Task OpenAndJoinSecondaryCameraAsync( - string camera, - ICameraPlayer player, - ClipMediaSource mediaSource, - long requestId, - CancellationToken cancellationToken) + private Task QueueSeek(TimeSpan target, bool accurate) { - var opened = await OpenCameraPlayerAsync(camera, player, mediaSource, required: false, requestId, cancellationToken); - - if (!opened || cancellationToken.IsCancellationRequested) + if (_queuedSeek is { } queued && ReferenceEquals(queued.Session, _session)) { - return; + queued.Target = target; + queued.Accurate |= accurate; + return queued.Completion; } - var joinPosition = _primaryPlayer.Position; - - Log.Debug( - "Joining secondary camera to in-progress playback. Camera={Camera}; JoinPosition={JoinPosition}; RequestId={RequestId}", - camera, - joinPosition, - requestId); + var seek = new QueuedSeek(_session, target, accurate); + _queuedSeek = seek; + seek.Completion = RunOperationAsync(_session, "Seek error", _ => + { + // From here on a new request queues a fresh seek instead of retargeting this one. + if (ReferenceEquals(_queuedSeek, seek)) + { + _queuedSeek = null; + } - await player.SeekAsync(joinPosition); - await player.PlayAsync(); + return RepositionAsync(seek.Target, seek.Accurate); + }); - if (cancellationToken.IsCancellationRequested) + if (ReferenceEquals(_queuedSeek, seek) && seek.Completion.IsCompleted) { - return; + // The operation was skipped outright (no session, or it was replaced), so nothing will clear the slot. + _queuedSeek = null; } - // The seek above takes some non-zero time, during which the front kept playing; do one single-shot correction if the gap grew meaningfully rather than looping/polling. - var driftAfterJoin = _primaryPlayer.Position - joinPosition; - if (driftAfterJoin > TimeSpan.FromMilliseconds(250)) + return seek.Completion; + } + + /// + /// Moves every camera to . + /// Running players are paused first and resumed after: seeking a playing Flyleaf player hands the seek to its play thread, which lets cameras land at different times and drift apart. + /// + private async Task RepositionAsync(TimeSpan position, bool accurate) + { + if (!IsSessionOpen || Duration <= TimeSpan.Zero) + return; + + var target = Clamp(position, TimeSpan.Zero, Duration); + var playersRunning = IsPlaying && !_isScrubbing; + + if (playersRunning) { - var correctedPosition = _primaryPlayer.Position; + await ForEachOpenPlayerAsync(player => player.PauseAsync()); + } - Log.Debug( - "Secondary camera join drifted; reissuing seek. Camera={Camera}; DriftAfterJoin={DriftAfterJoin}; CorrectedPosition={CorrectedPosition}; RequestId={RequestId}", - camera, - driftAfterJoin, - correctedPosition, - requestId); + await ForEachOpenPlayerAsync(player => player.SeekAsync(target, accurate)); + Position = target; - await player.SeekAsync(correctedPosition); + if (playersRunning) + { + await PlayAllAsync(); } } /// - /// Opens the three non-front cameras in parallel without playing or seeking them -- used by the recovery path, which stays paused until the caller positions and plays everything. + /// Seeks any paused side camera that has fallen out of step with the front back onto the front's frame. /// - private async Task OpenSecondaryCamerasAsync( - ClipMediaSource mediaSource, - long requestId, - CancellationToken cancellationToken) + private Task AlignSecondaryCamerasAsync() { - var secondaryOpenTasks = _players - .Where(cameraPlayer => !ReferenceEquals(cameraPlayer.Value, _primaryPlayer)) - .Select(cameraPlayer => OpenCameraPlayerAsync( - cameraPlayer.Key, - cameraPlayer.Value, - mediaSource, - required: false, - requestId, - cancellationToken)); - - await Task.WhenAll(secondaryOpenTasks); + var anchor = _primaryPlayer.Position; + var drifted = SecondaryPlayers() + .Where(player => player.IsEnded || (player.Position - anchor).Duration() > ResumeAlignmentTolerance) + .ToList(); + + if (drifted.Count == 0) + { + return Task.CompletedTask; + } + + Log.Debug("Realigning side cameras before resuming. Count={Count}; Anchor={Anchor}", drifted.Count, anchor); + return Task.WhenAll(drifted.Select(player => player.SeekAsync(anchor))); } - private async Task OpenCameraPlayerAsync( - string camera, - ICameraPlayer player, - ClipMediaSource mediaSource, - bool required, - long requestId, - CancellationToken cancellationToken) + private async Task PlayAllAsync() { - cancellationToken.ThrowIfCancellationRequested(); + ApplyPlaybackSpeed(); + await ForEachOpenPlayerAsync(player => player.PlayAsync()); - if (!mediaSource.CameraPlaylistPaths.TryGetValue(camera, out var playlistPath) || !File.Exists(playlistPath)) - { - Log.Debug( - "Camera playlist not available. Camera={Camera}; RequestId={RequestId}", - camera, - requestId); + // Play can land exactly as the front reaches its end, in which case the player is parked and the transport must not claim playback. + IsPlaying = !_primaryPlayer.IsEnded; + } + + private Task ForEachOpenPlayerAsync(Func action) => + Task.WhenAll(_players.Values.Where(player => player.IsOpen).Select(action)); - if (required) + private Task CloseAllPlayersAsync() => + Task.WhenAll(_players.Select(async pair => + { + try { - ErrorMessage = $"Failed to open {CameraNames.DisplayName(camera)} camera video."; + await pair.Value.CloseAsync(); } + catch (Exception ex) when (ex is not ObjectDisposedException || !_isDisposed) + { + Log.Debug(ex, "Failed to close camera player. Camera={Camera}", pair.Key); + } + })); - return false; - } + private void ResetPlaybackState() + { + IsMediaOpen = false; + OnPropertyChanged(nameof(OpenedMediaSource)); + IsPlaying = false; + Position = TimeSpan.Zero; + Duration = TimeSpan.Zero; + } - var opened = await player.OpenAsync(playlistPath); - if (!opened) - { - var messageTemplate = required - ? "Failed to open required camera video. Camera={Camera}; File={File}; RequestId={RequestId}" - : "Failed to open secondary camera video. Camera={Camera}; File={File}; RequestId={RequestId}"; + /// + /// Runs once every earlier operation has finished, unless has been replaced in the meantime. + /// Pass a null session for work that applies regardless of which clip is open (stop). + /// + private async Task RunOperationAsync(Session session, string errorPrefix, Func operation) + { + if (_isDisposed) + return; - Log.Warning( - messageTemplate, - camera, - playlistPath, - requestId); + await _operationLock.WaitAsync(); - if (required) + try + { + if (_isDisposed || (session is not null && (!ReferenceEquals(session, _session) || session.Token.IsCancellationRequested))) { - ErrorMessage = $"Failed to open {CameraNames.DisplayName(camera)} camera video."; + return; } - return false; + await operation(session?.Token ?? CancellationToken.None); } - - cancellationToken.ThrowIfCancellationRequested(); - - player.Speed = PlaybackSpeed; - - return true; - } - - private async Task PlayOpenPlayersAsync() - { - ApplyPlaybackSpeed(); - - foreach (var player in _players.Values.Where(player => player.IsOpen)) + catch (OperationCanceledException) { - await player.PlayAsync(); } - } - - private async Task PauseOpenPlayersAsync() - { - foreach (var player in _players.Values.Where(player => player.IsOpen)) + catch (ObjectDisposedException) when (_isDisposed) { - await player.PauseAsync(); } - } + catch (Exception ex) + { + Log.Error(ex, "{ErrorPrefix}. ClipName={ClipName}; ClipPath={ClipPath}", errorPrefix, CurrentClip?.Name, CurrentClip?.FullPath); - private async Task SeekOpenPlayersAsync(TimeSpan offset, bool accurate = true) - { - foreach (var player in _players.Values.Where(player => player.IsOpen)) + if (session is null || ReferenceEquals(session, _session)) + { + ErrorMessage = $"{errorPrefix}: {ex.Message}"; + } + } + finally { - await player.SeekAsync(offset, accurate); + _operationLock.Release(); } } @@ -960,11 +753,7 @@ private void ApplyPlaybackSpeed() } catch (Exception ex) { - Log.Debug( - ex, - "Failed to apply playback speed. Camera={Camera}; PlaybackSpeed={PlaybackSpeed}", - camera, - PlaybackSpeed); + Log.Debug(ex, "Failed to apply playback speed. Camera={Camera}; PlaybackSpeed={PlaybackSpeed}", camera, PlaybackSpeed); } } } @@ -988,17 +777,17 @@ private void OnCurrentClipChanged(object sender, CamClip clip) OnPropertyChanged(nameof(CanGoNext)); OnPropertyChanged(nameof(CanGoPrevious)); - if (clip is not null) - { - Log.Debug( - "Current clip changed. ClipName={ClipName}; ClipPath={ClipPath}; ClipIndex={ClipIndex}; ClipCount={ClipCount}", - clip.Name, - clip.FullPath, - Playlist.CurrentIndex, - Playlist.Clips.Count); - var requestId = BeginNewRequest(); - _ = PlayInternalAsync(requestId, clip); - } + if (clip is null || _isDisposed) + return; + + Log.Debug( + "Current clip changed. ClipName={ClipName}; ClipPath={ClipPath}; ClipIndex={ClipIndex}; ClipCount={ClipCount}", + clip.Name, + clip.FullPath, + Playlist.CurrentIndex, + Playlist.Clips.Count); + + _ = OpenClipAsync(clip); } private void OnPlaylistChanged(object sender, EventArgs e) @@ -1009,250 +798,162 @@ private void OnPlaylistChanged(object sender, EventArgs e) OnPropertyChanged(nameof(CanGoPrevious)); } - private void OnPlayerOpened(object sender, EventArgs e) + private void OnPlayerEnded(object sender, EventArgs e) { - if (ReferenceEquals(sender, _primaryPlayer)) + if (!ReferenceEquals(sender, _primaryPlayer) || _session is not { IsOpen: true } session) + return; + + _ = RunOperationAsync(session, "Playback error", async token => { - IsMediaOpen = true; - } - } + // Queued behind whatever was running when the end arrived; if that moved the front off its end (a replay, a seek back), this end no longer applies. + if (!session.IsOpen || !_primaryPlayer.IsEnded) + return; - private async void OnPlayerEnded(object sender, EventArgs e) - { - if (!ReferenceEquals(sender, _primaryPlayer) || _isOpeningMedia) - return; + var wasPlaying = IsPlaying; + var endPosition = _primaryPlayer.Position; + IsPlaying = false; - var wasPlaying = IsPlaying; - IsPlaying = false; - Interlocked.Increment(ref _playbackEndedCount); + // Side cameras can run a few frames longer than the front; park them with it. + await ForEachOpenPlayerAsync(player => player.PauseAsync()); - // 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). - var mediaRequestId = Volatile.Read(ref _currentMediaRequestId); - if (mediaRequestId == 0 || mediaRequestId != Volatile.Read(ref _activeRequestId)) + if (Duration - endPosition > PrematureEndTolerance) + { + await RecoverAsync(session, endPosition, wasPlaying, token); + return; + } + + // Deliberately no auto-advance to the next clip: each clip is its own incident, and the most likely follow-up is replaying it. + // The media stays open so the scrubber and frame-step remain usable to review the final moments, and play replays from the start. + Position = Duration; + }); + } + + private void OnPlayerFailed(object sender, CameraPlaybackFailedEventArgs e) + { + if (!ReferenceEquals(sender, _primaryPlayer)) { + Log.Warning(e.ErrorException, "Side camera playback failed. Camera={Camera}; ClipName={ClipName}", CameraNameOf(sender), CurrentClip?.Name); return; } - if (_openedMediaSource is not null && Duration - Position > PrematureEndTolerance) - { - await RecoverFromPrematureEndAsync(wasPlaying); + if (_session is not { IsOpen: true } session) return; - } - // Deliberately no auto-advance to the next clip: each clip is its own incident, and the most likely follow-up to watching one is replaying it, not being yanked to the next. - // Playback simply parks at the end. - // The media stays open (IsMediaOpen unchanged) so the scrubber and frame-step remain usable to review the final moments, and PlayAsync replays from the start when pressed at the end. - // Next/Previous remain explicit user actions. - Position = Duration; + var failurePosition = Position; + + _ = RunOperationAsync(session, "Playback error", async token => + { + if (!session.IsOpen) + return; + + var wasPlaying = IsPlaying; + IsPlaying = false; + + // A chunk whose moov is intact but whose media data is truncated makes Flyleaf fail rather than end when the concat demuxer dies mid-clip. + // That gets the same recovery as a premature end; only a failure at the very end is reported as-is. + if (Duration - failurePosition > PrematureEndTolerance) + { + Log.Warning(e.ErrorException, "Front camera playback failed mid-clip; attempting recovery. ClipName={ClipName}; Position={Position}", session.Clip.Name, failurePosition); + await RecoverAsync(session, failurePosition, wasPlaying, token); + return; + } + + Log.Error(e.ErrorException, "Media playback failed. ClipName={ClipName}; ClipPath={ClipPath}", session.Clip.Name, session.Clip.FullPath); + ErrorMessage = $"Playback failed: {e.ErrorException?.Message}"; + session.IsOpen = false; + IsMediaOpen = false; + }); } /// - /// Handles the front player ending (or failing) well short of , which means the concat demuxer hit a corrupt/truncated chunk and stopped early rather than reaching the real end of the clip. - /// Recovery is probe-first: rebuild with the current exclusions and let the builder's per-file probe find the culprit (the demuxer reads ahead of the presentation position, so the failure position can sit inside a healthy chunk); only when the probe finds nothing new is the chunk containing the failure position excluded. - /// Gives up after attempts on the same clip. + /// Handles the front camera stopping well short of , which means the concat demuxer hit a corrupt or truncated chunk. + /// Recovery is probe-first: rebuild with the current exclusions and let the builder's per-file probe find the culprit (the demuxer reads ahead of the presentation position, so the failure position can sit inside a healthy chunk). + /// Only when the probe finds nothing new is the chunk containing the failure position excluded. + /// Playback resumes at the start of the chunk that failed, and gives up after attempts on one clip. /// - private async Task RecoverFromPrematureEndAsync(bool wasPlaying) + private async Task RecoverAsync(Session session, TimeSpan failurePosition, bool wasPlaying, CancellationToken token) { - var clip = _openedClip; - var mediaSource = _openedMediaSource; + var clip = session.Clip; + var mediaSource = session.Source; + failurePosition = Clamp(failurePosition, TimeSpan.Zero, Duration); - if (clip is null || mediaSource is null) - { - return; - } - - var failurePosition = Clamp(Position, TimeSpan.Zero, Duration); - - // Find the last chunk boundary at or before the failure position; that's the chunk the failure happened inside, and where playback should resume. - // Map it from the (possibly already-shrunk) opened timeline back to the original clip's chunk index. var badChunkTimelineIndex = 0; - for (var i = 0; i < mediaSource.ChunkStarts.Count; i++) + for (var i = 0; i < mediaSource.ChunkStarts.Count && mediaSource.ChunkStarts[i] <= failurePosition; i++) { - if (mediaSource.ChunkStarts[i] <= failurePosition) - { - badChunkTimelineIndex = i; - } - else - { - break; - } + badChunkTimelineIndex = i; } - var resumePosition = mediaSource.ChunkStarts.Count > badChunkTimelineIndex - ? mediaSource.ChunkStarts[badChunkTimelineIndex] - : TimeSpan.Zero; + var resumePosition = mediaSource.ChunkStarts.Count > 0 ? mediaSource.ChunkStarts[badChunkTimelineIndex] : TimeSpan.Zero; + var positionDerivedIndex = MapTimelineIndexToOriginalChunkIndex(clip, session.ExcludedChunks, badChunkTimelineIndex); - var positionDerivedIndex = MapTimelineIndexToOriginalChunkIndex(clip, badChunkTimelineIndex); - - if (_recoveryAttempts >= MaxRecoveryAttemptsPerClip) + if (session.RecoveryAttempts >= MaxRecoveryAttemptsPerClip) { - GiveUpOnClip(clip, positionDerivedIndex); + GiveUpOnClip(session, positionDerivedIndex); return; } - _recoveryAttempts++; + session.RecoveryAttempts++; Log.Warning( - "Premature end of playback detected; attempting corrupt-chunk recovery. ClipName={ClipName}; ClipPath={ClipPath}; FailurePosition={FailurePosition}; Duration={Duration}; Attempt={Attempt}", + "Premature end of playback detected; attempting corrupt-chunk recovery. ClipName={ClipName}; FailurePosition={FailurePosition}; Duration={Duration}; Attempt={Attempt}", clip.Name, - clip.FullPath, failurePosition, Duration, - _recoveryAttempts); + session.RecoveryAttempts); - var requestId = BeginNewRequest(); + var excludedBefore = session.ExcludedChunks.ToHashSet(); + var rebuilt = await Task.Run(() => _mediaSourceBuilder.Build(clip, excludedBefore), token); - try + if (rebuilt.AutoExcludedChunkIndices.Any(index => !excludedBefore.Contains(index))) { - await RunSerializedPlaybackOperationAsync(async ct => - { - if (!IsRequestActive(requestId) || clip != CurrentClip) - return; - - // Probe-first: rebuild with the current exclusion set only. - // The builder re-probes every file, so a chunk that became unreadable since the last build shows up in AutoExcludedChunkIndices -- that's the real culprit, and the (possibly healthy) chunk under the failure position must NOT be excluded. - var excludedSnapshot = SnapshotExcludedChunkIndices(); - var newMediaSource = await Task.Run( - () => _mediaSourceBuilder.Build(clip, excludedSnapshot), - ct); - - var probeFoundCulprits = newMediaSource.AutoExcludedChunkIndices - .Any(index => !excludedSnapshot.Contains(index)); - - if (probeFoundCulprits) - { - Log.Warning( - "Probe found unreadable chunk(s); excluding them instead of the failure-position chunk. ClipName={ClipName}; ClipPath={ClipPath}; AutoExcludedChunkIndices={AutoExcludedChunkIndices}", - clip.Name, - clip.FullPath, - newMediaSource.AutoExcludedChunkIndices); - } - else - { - // Probe-clean corruption (moov intact, media data bad): fall back to excluding the chunk containing the failure position and rebuild again. - bool excludedNewChunk; - int excludedChunkCount; - lock (_excludedChunkIndicesLock) - { - excludedNewChunk = positionDerivedIndex >= 0 && _excludedChunkIndices.Add(positionDerivedIndex); - excludedChunkCount = _excludedChunkIndices.Count; - } - - if (!excludedNewChunk || excludedChunkCount >= clip.Chunks.Count) - { - GiveUpOnClip(clip, positionDerivedIndex); - return; - } - - Log.Warning( - "Probe found nothing new; excluding the chunk containing the failure position. ClipName={ClipName}; ClipPath={ClipPath}; BadChunkIndex={BadChunkIndex}; ChunkTimestamp={ChunkTimestamp}", - clip.Name, - clip.FullPath, - positionDerivedIndex, - clip.Chunks[positionDerivedIndex].Timestamp); - - var rebuildSnapshot = SnapshotExcludedChunkIndices(); - newMediaSource = await Task.Run( - () => _mediaSourceBuilder.Build(clip, rebuildSnapshot), - ct); - } - - if (newMediaSource.ChunkStarts.Count == 0) - { - GiveUpOnClip(clip, positionDerivedIndex); - return; - } - - Duration = newMediaSource.Duration; - - await OpenClipInternalAsync(clip, newMediaSource, playAfterOpen: false, requestId, ct); - - if (!IsRequestActive(requestId) || clip != CurrentClip || !IsMediaOpen) - return; - - var clampedResumePosition = Clamp(resumePosition, TimeSpan.Zero, Duration); - - // Resume playback BEFORE seeking: a seek issued while paused right after open can be swallowed by the player, whereas seeks during active playback are reliable. - if (wasPlaying) - { - await PlayOpenPlayersAsync(); - IsPlaying = true; - } - - await SeekOpenPlayersAsync(clampedResumePosition); - Position = clampedResumePosition; - - // One-shot guard against the reopen/seek race: give the player a moment and, if its reported position is still far below the resume target, reissue the seek. - await _postRecoverySeekVerifyDelay(ct); - - if (IsRequestActive(requestId) - && clampedResumePosition - Position > PostRecoverySeekTolerance) - { - Log.Warning( - "Post-recovery seek did not stick; reissuing. ClipName={ClipName}; ClipPath={ClipPath}; ResumePosition={ResumePosition}; ReportedPosition={ReportedPosition}", - clip.Name, - clip.FullPath, - clampedResumePosition, - Position); - await SeekOpenPlayersAsync(clampedResumePosition); - Position = clampedResumePosition; - } - }, replacePlaybackCts: true); + Log.Warning("Probe found unreadable chunk(s); excluding them. ClipName={ClipName}; AutoExcludedChunkIndices={AutoExcludedChunkIndices}", clip.Name, rebuilt.AutoExcludedChunkIndices); } - catch (OperationCanceledException) - { - } - catch (Exception ex) - { - Log.Error( - ex, - "Error recovering from premature end of playback. ClipName={ClipName}; ClipPath={ClipPath}; RequestId={RequestId}", - clip.Name, - clip.FullPath, - requestId); - ErrorMessage = $"Playback error: {ex.Message}"; - } - finally + else { - // The failure may have arrived while the original open was still joining secondary cameras (IsLoading true). - // Recovery bumped the request id above, so that open's finally no longer owns the flag; settle it here or the loading state sticks forever. - if (IsRequestActive(requestId)) + // Probe-clean corruption (moov intact, media data bad): exclude the chunk containing the failure position and rebuild. + if (positionDerivedIndex < 0 || !session.ExcludedChunks.Add(positionDerivedIndex) || session.ExcludedChunks.Count >= clip.Chunks.Count) { - IsLoading = false; + GiveUpOnClip(session, positionDerivedIndex); + return; } + + Log.Warning("Excluding the chunk containing the failure position. ClipName={ClipName}; BadChunkIndex={BadChunkIndex}", clip.Name, positionDerivedIndex); + var exclusions = session.ExcludedChunks.ToHashSet(); + rebuilt = await Task.Run(() => _mediaSourceBuilder.Build(clip, exclusions), token); } + + if (rebuilt.ChunkStarts.Count == 0) + { + GiveUpOnClip(session, positionDerivedIndex); + return; + } + + token.ThrowIfCancellationRequested(); + await OpenSourceAsync(session, rebuilt, resumePosition, wasPlaying, token); } - private void GiveUpOnClip(CamClip clip, int badChunkIndex) + private void GiveUpOnClip(Session session, int badChunkIndex) { Log.Error( "Giving up on clip playback after repeated unreadable chunks. ClipName={ClipName}; ClipPath={ClipPath}; BadChunkIndex={BadChunkIndex}; Attempts={Attempts}", - clip.Name, - clip.FullPath, + session.Clip.Name, + session.Clip.FullPath, badChunkIndex, - _recoveryAttempts); - ErrorMessage = EncryptedClipDetector.LooksEncrypted(clip) + session.RecoveryAttempts); + + ErrorMessage = EncryptedClipDetector.LooksEncrypted(session.Clip) ? EncryptedClipMessage : "Playback stopped: too many unreadable video files."; + session.IsOpen = false; + IsPlaying = false; IsMediaOpen = false; } /// - /// Maps an index into the currently opened (possibly already-shrunk) timeline's back to the corresponding index in the original clip's , accounting for chunks already excluded. + /// Maps an index into the currently opened (possibly already-shrunk) timeline back to the corresponding index in the original clip's chunks, accounting for chunks already excluded. /// - private HashSet SnapshotExcludedChunkIndices() + private static int MapTimelineIndexToOriginalChunkIndex(CamClip clip, IReadOnlySet excluded, int timelineIndex) { - lock (_excludedChunkIndicesLock) - { - return new HashSet(_excludedChunkIndices); - } - } - - private int MapTimelineIndexToOriginalChunkIndex(CamClip clip, int timelineIndex) - { - // Snapshot under the lock: this runs on a Flyleaf callback thread during recovery, outside the operation lock, while a concurrent clip change can clear the exclusion set. - var excluded = SnapshotExcludedChunkIndices(); var remaining = timelineIndex; for (var originalIndex = 0; originalIndex < clip.Chunks.Count; originalIndex++) @@ -1273,76 +974,16 @@ private int MapTimelineIndexToOriginalChunkIndex(CamClip clip, int timelineIndex return -1; } - private async void OnPlayerFailed(object sender, CameraPlaybackFailedEventArgs e) - { - var camera = GetCameraName(sender); - if (!ReferenceEquals(sender, _primaryPlayer)) - { - Log.Warning( - e.ErrorException, - "Secondary camera playback failed. Camera={Camera}; ClipName={ClipName}; ClipPath={ClipPath}", - camera, - CurrentClip?.Name, - CurrentClip?.FullPath); - return; - } - - // A chunk whose moov is intact but whose media data is truncated/corrupt makes Flyleaf raise Failed ("Playback stopped unexpectedly") rather than Ended when the concat demuxer dies mid-clip. - // Route that into the same corrupt-chunk recovery as a premature Ended; only genuinely unrecoverable failures fall through to the error UI below. - if (!_isDisposed && !_isOpeningMedia && IsMediaOpen && _openedMediaSource is not null - && Duration - Position > PrematureEndTolerance) - { - // Same as OnPlayerEnded: not gated on IsLoading, so a front failure during the secondary-camera join window still reaches recovery instead of the error UI below. - var mediaRequestId = Volatile.Read(ref _currentMediaRequestId); - if (mediaRequestId != 0 && mediaRequestId == Volatile.Read(ref _activeRequestId)) - { - Log.Warning( - e.ErrorException, - "Front camera playback failed mid-clip; attempting corrupt-chunk recovery. ClipName={ClipName}; ClipPath={ClipPath}; Position={Position}; Duration={Duration}", - CurrentClip?.Name, - CurrentClip?.FullPath, - Position, - Duration); - - var wasPlaying = IsPlaying; - IsPlaying = false; - await RecoverFromPrematureEndAsync(wasPlaying); - return; - } - } - - Log.Error( - e.ErrorException, - "Media playback failed. Camera={Camera}; ClipName={ClipName}; ClipPath={ClipPath}", - camera, - CurrentClip?.Name, - CurrentClip?.FullPath); - ErrorMessage = $"Playback failed: {e.ErrorException?.Message}"; - IsPlaying = false; - IsLoading = false; - IsMediaOpen = false; - } - private void OnPositionChanged(object sender, CameraPositionChangedEventArgs e) { - if (!ReferenceEquals(sender, _primaryPlayer) || _isOpeningMedia) + if (!ReferenceEquals(sender, _primaryPlayer) || !IsSessionOpen) return; - Position = e.Position; + Position = Clamp(e.Position, TimeSpan.Zero, Duration); } - private string GetCameraName(object sender) - { - foreach (var (camera, player) in _players) - { - if (ReferenceEquals(sender, player)) - { - return camera; - } - } - - return "unknown"; - } + private string CameraNameOf(object sender) => + _players.FirstOrDefault(pair => ReferenceEquals(pair.Value, sender)).Key ?? "unknown"; private static TimeSpan Clamp(TimeSpan value, TimeSpan min, TimeSpan max) { @@ -1354,4 +995,43 @@ private static TimeSpan Clamp(TimeSpan value, TimeSpan min, TimeSpan max) return value; } + + private sealed class QueuedSeek(Session session, TimeSpan target, bool accurate) + { + public Session Session { get; } = session; + + public TimeSpan Target { get; set; } = target; + + public bool Accurate { get; set; } = accurate; + + public Task Completion { get; set; } + } + + /// + /// One opened (or opening) clip. + /// Replacing or stopping the session cancels its token, which is how in-flight and queued work learns it no longer applies. + /// + private sealed class Session(CamClip clip) + { + private readonly CancellationTokenSource _cts = new(); + + public CamClip Clip { get; } = clip; + + public ClipMediaSource Source { get; set; } + + /// True once every camera is open and positioned; false while opening, after a failure, or once replaced. + public bool IsOpen { get; set; } + + public HashSet ExcludedChunks { get; } = []; + + public int RecoveryAttempts { get; set; } + + public CancellationToken Token => _cts.Token; + + public void Cancel() + { + IsOpen = false; + _cts.Cancel(); + } + } } diff --git a/SentryDeck/Services/FlyleafRuntime.cs b/SentryDeck/Services/FlyleafRuntime.cs index 1081da2..c0c6755 100644 --- a/SentryDeck/Services/FlyleafRuntime.cs +++ b/SentryDeck/Services/FlyleafRuntime.cs @@ -41,6 +41,7 @@ public bool TryStart() LogLevel = FlyleafLib.LogLevel.Warn, LogOutput = ":debug", UIRefresh = true, + UIRefreshInterval = 100, }); _isStarted = true;