diff --git a/Sources/MUXSDKStatsInternal/PlaybackEventTiming.swift b/Sources/MUXSDKStatsInternal/PlaybackEventTiming.swift index cd143966..bd514027 100644 --- a/Sources/MUXSDKStatsInternal/PlaybackEventTiming.swift +++ b/Sources/MUXSDKStatsInternal/PlaybackEventTiming.swift @@ -1,19 +1,25 @@ import AVFoundation import CoreMedia.CMSync -struct PlaybackEventTiming: Hashable { +struct PlaybackEventTiming: Sendable, Hashable { let mediaTime: CMTime let programDate: Date? let liveEdgeProgramDate: Date? + + init(mediaTime: CMTime, programDate: Date?, liveEdgeProgramDate: Date?) { + self.mediaTime = mediaTime + self.programDate = programDate + self.liveEdgeProgramDate = liveEdgeProgramDate + } } extension PlaybackEventTiming { - init(mediaTime: CMTime, programDate: Date?, loadedTimeRanges: some BidirectionalCollection) { + init(mediaTime: CMTime, programDate: Date?, bufferEnd: CMTime?) { self.mediaTime = mediaTime self.programDate = programDate guard let programDate, - let bufferEnd = loadedTimeRanges.last?.end, + let bufferEnd, bufferEnd > mediaTime else { liveEdgeProgramDate = nil return @@ -22,27 +28,45 @@ extension PlaybackEventTiming { let offset = bufferEnd - mediaTime liveEdgeProgramDate = programDate + offset.seconds } +} - init(playerItem: AVPlayerItem) { - self.init( - mediaTime: playerItem.currentTime(), - programDate: playerItem.currentDate(), - loadedTimeRanges: playerItem.loadedTimeRanges.lazy.map(\.timeRangeValue)) +@available(iOS 13, tvOS 13, *) +extension AVPlayerItem { + nonisolated private func inferProgramDateSync(at mediaTime: CMTime) -> Date? { + // currentDate() can block the calling thread, for example while waiting for + // the variant playlist containing the EXT-X-PROGRAM-DATE-TIME tag + currentDate() + .flatMap { currentDate in + let mediaTimeElapsed = currentTime() - mediaTime + guard mediaTimeElapsed.isNumeric else { + return nil + } + return currentDate - mediaTimeElapsed.seconds + } } - @available(iOS 18, tvOS 18, visionOS 2, *) - init(variantSwitchEvent: AVMetricPlayerItemVariantSwitchEvent, on playerItem: AVPlayerItem) { - self.init( + nonisolated func inferProgramDate(at mediaTime: CMTime) async -> Date? { + await Task { + inferProgramDateSync(at: mediaTime) + }.value + } + + nonisolated func currentTiming() async -> PlaybackEventTiming { + let mediaTime = currentTime() + let bufferEnd = loadedTimeRanges.last?.timeRangeValue.end + return PlaybackEventTiming( + mediaTime: mediaTime, + programDate: await inferProgramDate(at: mediaTime), + bufferEnd: bufferEnd) + } +} + +@available(iOS 18, tvOS 18, visionOS 2, *) +extension PlaybackEventTiming { + init(variantSwitchEvent: AVMetricPlayerItemVariantSwitchEvent, on playerItem: AVPlayerItem) async { + await self.init( mediaTime: variantSwitchEvent.mediaTime, - programDate: playerItem.currentDate() - .flatMap { playerItemProgramDate in - let playerItemMediaTime = playerItem.currentTime() - let mediaTimeElapsed = playerItemMediaTime - variantSwitchEvent.mediaTime - guard mediaTimeElapsed.isValid, mediaTimeElapsed >= .zero else { - return nil - } - return playerItemProgramDate - mediaTimeElapsed.seconds - }, - loadedTimeRanges: variantSwitchEvent.loadedTimeRanges) + programDate: playerItem.inferProgramDate(at: variantSwitchEvent.mediaTime), + bufferEnd: variantSwitchEvent.loadedTimeRanges.last?.end) } } diff --git a/Sources/MUXSDKStatsInternal/Publishers+AVPlayerItem.swift b/Sources/MUXSDKStatsInternal/Publishers+AVPlayerItem.swift index 3fe2bd6c..03954f8f 100644 --- a/Sources/MUXSDKStatsInternal/Publishers+AVPlayerItem.swift +++ b/Sources/MUXSDKStatsInternal/Publishers+AVPlayerItem.swift @@ -57,14 +57,12 @@ extension AVPlayerItem { } .removeDuplicates { $0?.trackID == $1?.trackID } .flatMap { videoAssetTrack in - // capture timing immediately - let timing = PlaybackEventTiming(playerItem: self) - return Future { + async let timing = self.currentTiming() guard let videoAssetTrack else { - return (timing, MUXSDKVideoData()) + return await (timing, MUXSDKVideoData()) } - return (timing, await MUXSDKVideoData.makeWithRenditionInfo(track: videoAssetTrack, on: self)) + return await (timing, MUXSDKVideoData.makeWithRenditionInfo(track: videoAssetTrack, on: self)) } } } @@ -126,10 +124,12 @@ extension AVPlayerItem { nonisolated func renditionChangeEventsUsingAVMetrics() -> some Publisher { metrics(forType: AVMetricPlayerItemVariantSwitchEvent.self) .filter(\.didSucceed) - .publisher .map { metricEvent in - let timing = PlaybackEventTiming(variantSwitchEvent: metricEvent, on: self) - + let timing = await PlaybackEventTiming(variantSwitchEvent: metricEvent, on: self) + return (metricEvent, timing) + } + .publisher + .map { (metricEvent, timing) in let muxEvent = MUXSDKRenditionChangeEvent() let playerData = MUXSDKPlayerData() diff --git a/Tests/MUXSDKStatsInternalTests/PlaybackEventTimingTests.swift b/Tests/MUXSDKStatsInternalTests/PlaybackEventTimingTests.swift new file mode 100644 index 00000000..73e09346 --- /dev/null +++ b/Tests/MUXSDKStatsInternalTests/PlaybackEventTimingTests.swift @@ -0,0 +1,139 @@ +import AVFoundation +import CoreMedia.CMSync +@testable import MUXSDKStatsInternal +import Testing + +private class MockableTimingAVPlayerItem: AVPlayerItem { + nonisolated(unsafe) var getCurrentDate: (() -> Date?)! + + override func currentDate() -> Date? { + getCurrentDate() + } + + nonisolated(unsafe) var getCurrentTime: (() -> CMTime)! + + override func currentTime() -> CMTime { + getCurrentTime() + } + + nonisolated(unsafe) var getLoadedTimeRanges: (() -> [NSValue])! + + override var loadedTimeRanges: [NSValue] { + getLoadedTimeRanges() + } +} + +struct PlaybackEventTimingTests { + @Test(arguments: [ + (CMTime.zero, Date?.none, CMTime?.none, + PlaybackEventTiming( + mediaTime: .zero, + programDate: nil, + liveEdgeProgramDate: nil)), + + // nil programDate + (CMTime(seconds: 1, preferredTimescale: 1000), Date?.none, CMTime(seconds: 2, preferredTimescale: 1000), + PlaybackEventTiming( + mediaTime: CMTime(seconds: 1, preferredTimescale: 1000), + programDate: nil, + liveEdgeProgramDate: nil)), + + // nil bufferEnd + (CMTime(seconds: 1, preferredTimescale: 1000), Date(timeIntervalSince1970: 100), CMTime?.none, + PlaybackEventTiming( + mediaTime: CMTime(seconds: 1, preferredTimescale: 1000), + programDate: Date(timeIntervalSince1970: 100), + liveEdgeProgramDate: nil)), + + // typical + (CMTime(seconds: 1, preferredTimescale: 1000), Date(timeIntervalSince1970: 100), CMTime(seconds: 2, preferredTimescale: 1000), + PlaybackEventTiming( + mediaTime: CMTime(seconds: 1, preferredTimescale: 1000), + programDate: Date(timeIntervalSince1970: 100), + liveEdgeProgramDate: Date(timeIntervalSince1970: 101))), + + // earlier + (CMTime(seconds: 1, preferredTimescale: 1000), Date(timeIntervalSince1970: 100), CMTime(seconds: 0, preferredTimescale: 1000), + PlaybackEventTiming( + mediaTime: CMTime(seconds: 1, preferredTimescale: 1000), + programDate: Date(timeIntervalSince1970: 100), + liveEdgeProgramDate: nil)), + ]) + func bufferEndInit(mediaTime: CMTime, programDate: Date?, bufferEnd: CMTime?, expected: PlaybackEventTiming) async throws { + let actual = PlaybackEventTiming(mediaTime: mediaTime, programDate: programDate, bufferEnd: bufferEnd) + #expect(actual.mediaTime == expected.mediaTime) + #expect(actual.programDate == expected.programDate) + #expect(actual.liveEdgeProgramDate == expected.liveEdgeProgramDate) + #expect(actual == expected) + } + + @Test + func inferredProgramDateOnAVPlayerItem() async { + let playerItem = await MainActor.run { MockableTimingAVPlayerItem(url: URL(string: "https://example.com")!) } + playerItem.getCurrentDate = { Date(timeIntervalSince1970: 101) } + playerItem.getCurrentTime = { CMTime(seconds: 2, preferredTimescale: 1000) } + + let programDate = await playerItem.inferProgramDate(at: CMTime(seconds: 1, preferredTimescale: 1000)) + #expect(programDate == Date(timeIntervalSince1970: 100)) + } + + @Test + func playerItemCurrentTimingPaused() async { + let playerItem = await MainActor.run { MockableTimingAVPlayerItem(url: URL(string: "https://example.com")!) } + playerItem.getCurrentDate = { Date(timeIntervalSince1970: 101) } + playerItem.getCurrentTime = { CMTime(seconds: 1, preferredTimescale: 1000) } + playerItem.getLoadedTimeRanges = { [NSValue(timeRange: CMTimeRange(start: .zero, end: CMTime(seconds: 3, preferredTimescale: 1000)))] } + + let timing = await playerItem.currentTiming() + + let expected = PlaybackEventTiming( + mediaTime: CMTime(seconds: 1, preferredTimescale: 1000), + programDate: Date(timeIntervalSince1970: 101), + liveEdgeProgramDate: Date(timeIntervalSince1970: 103)) + + #expect(timing == expected) + } + + @Test + func playerItemCurrentTimingPausedNoLoadedTimeRanges() async { + let playerItem = await MainActor.run { MockableTimingAVPlayerItem(url: URL(string: "https://example.com")!) } + playerItem.getCurrentDate = { Date(timeIntervalSince1970: 101) } + playerItem.getCurrentTime = { CMTime(seconds: 1, preferredTimescale: 1000) } + playerItem.getLoadedTimeRanges = { [] } + + let timing = await playerItem.currentTiming() + + let expected = PlaybackEventTiming( + mediaTime: CMTime(seconds: 1, preferredTimescale: 1000), + programDate: Date(timeIntervalSince1970: 101), + liveEdgeProgramDate: nil) + + #expect(timing == expected) + } + + @Test + func playerItemCurrentTimingWhileTimeAdvancing() async { + let playerItem = await MainActor.run { MockableTimingAVPlayerItem(url: URL(string: "https://example.com")!) } + // initial call: + let mediaTime = CMTime(seconds: 1, preferredTimescale: 1000) + // in background, immediately after getting currentDate: + let mediaTimeAfter = CMTime(seconds: 2, preferredTimescale: 1000) + var currentTime = mediaTime + playerItem.getCurrentDate = { + defer { currentTime = mediaTimeAfter } + return Date(timeIntervalSince1970: 104) + } + playerItem.getCurrentTime = { currentTime } + // corresponds to date range 102..<105 + playerItem.getLoadedTimeRanges = { [NSValue(timeRange: CMTimeRange(start: .zero, end: CMTime(seconds: 3, preferredTimescale: 1000)))] } + + let timing = await playerItem.currentTiming() + + let expected = PlaybackEventTiming( + mediaTime: CMTime(seconds: 1, preferredTimescale: 1000), + programDate: Date(timeIntervalSince1970: 103), + liveEdgeProgramDate: Date(timeIntervalSince1970: 105)) + + #expect(timing == expected) + } +}