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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 45 additions & 21 deletions Sources/MUXSDKStatsInternal/PlaybackEventTiming.swift
Original file line number Diff line number Diff line change
@@ -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<CMTimeRange>) {
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
Expand All @@ -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)
}
}
16 changes: 8 additions & 8 deletions Sources/MUXSDKStatsInternal/Publishers+AVPlayerItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
}
Expand Down Expand Up @@ -126,10 +124,12 @@ extension AVPlayerItem {
nonisolated func renditionChangeEventsUsingAVMetrics() -> some Publisher<MUXSDKRenditionChangeEvent, Error> {
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()
Expand Down
139 changes: 139 additions & 0 deletions Tests/MUXSDKStatsInternalTests/PlaybackEventTimingTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}