Skip to content

Commit

Permalink
Support standard actions at item playback end (#780)
Browse files Browse the repository at this point in the history
Co-authored-by: Samuel Défago <[email protected]>
  • Loading branch information
waliid and defagos authored Feb 29, 2024
1 parent 9390520 commit 49b3c94
Show file tree
Hide file tree
Showing 14 changed files with 183 additions and 45 deletions.
14 changes: 13 additions & 1 deletion Demo/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,20 @@
}
}
},
"Active" : {
"Action at item end" : {

},
"Active (AirPlay / Control Center)" : {

},
"Add" : {

},
"Add a stream to the playlist" : {

},
"Advance" : {

},
"Android" : {

Expand Down Expand Up @@ -126,9 +132,15 @@
},
"Mode" : {

},
"None" : {

},
"Opt-in features" : {

},
"Pause" : {

},
"Pauses" : {

Expand Down
16 changes: 15 additions & 1 deletion Demo/Sources/Showcase/OptInView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ struct OptInView: View {
@State private var isActive = false
@State private var supportsPictureInPicture = false
@State private var audiovisualBackgroundPlaybackPolicy: AVPlayerAudiovisualBackgroundPlaybackPolicy = .automatic
@State private var actionAtItemEnd: AVPlayer.ActionAtItemEnd = .advance

var body: some View {
VStack {
Expand All @@ -28,12 +29,13 @@ struct OptInView: View {
.background(.black)
List {
Toggle(isOn: $isActive) {
Text("Active")
Text("Active (AirPlay / Control Center)")
}
Toggle(isOn: $supportsPictureInPicture) {
Text("Picture in Picture")
}
audiovisualBackgroundPlaybackPolicyPicker()
actionAtItemEndPicker()

Section {
Toggle(isOn: $player.isTrackingEnabled) {
Expand All @@ -55,6 +57,9 @@ struct OptInView: View {
.onChange(of: audiovisualBackgroundPlaybackPolicy) { newValue in
player.audiovisualBackgroundPlaybackPolicy = newValue
}
.onChange(of: actionAtItemEnd) { newValue in
player.actionAtItemEnd = newValue
}
.onAppear(perform: play)
.tracked(name: "tracking")
}
Expand All @@ -68,6 +73,15 @@ struct OptInView: View {
}
}

@ViewBuilder
private func actionAtItemEndPicker() -> some View {
Picker("Action at item end", selection: $actionAtItemEnd) {
Text("Advance").tag(AVPlayer.ActionAtItemEnd.advance)
Text("Pause").tag(AVPlayer.ActionAtItemEnd.pause)
Text("None").tag(AVPlayer.ActionAtItemEnd.none)
}
}

private func play() {
player.append(media.playerItem())
player.play()
Expand Down
4 changes: 3 additions & 1 deletion Demo/Sources/Showcase/Stories/StoriesViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ final class StoriesViewModel: ObservableObject {
}

private static func player(for story: Story) -> Player {
Player(item: Media(from: story.template).playerItem(), configuration: .externalPlaybackDisabled)
let player = Player(item: Media(from: story.template).playerItem(), configuration: .externalPlaybackDisabled)
player.actionAtItemEnd = .pause
return player
}

private static func players(
Expand Down
5 changes: 5 additions & 0 deletions Sources/Analytics/ComScore/ComScoreLabels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ public struct ComScoreLabels {

// MARK: Streaming labels

/// The value of `ns_st_id` (media player session identifier).
public var ns_st_id: String? {
extract()
}

/// The value of `ns_st_ev` (streaming event).
public var ns_st_ev: String? {
extract()
Expand Down
39 changes: 27 additions & 12 deletions Sources/Analytics/ComScore/ComScoreTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,12 @@ public final class ComScoreTracker: PlayerItemTracker {

public func enable(for player: Player) {
self.player = player
streamingAnalytics.createPlaybackSession()
streamingAnalytics.setMediaPlayerName("Pillarbox")
streamingAnalytics.setMediaPlayerVersion(Player.version)
createPlaybackSession()
}

public func updateMetadata(with metadata: [String: String]) {
self.metadata = metadata
let builder = SCORStreamingContentMetadataBuilder()
if let globals = Analytics.shared.comScoreGlobals {
builder.setCustomLabels(metadata.merging(globals.labels) { _, new in new })
}
else {
builder.setCustomLabels(metadata)
}
let contentMetadata = SCORStreamingContentMetadata(builder: builder)
streamingAnalytics.setMetadata(contentMetadata)
addMetadata(metadata)
}

public func updateProperties(with properties: PlayerProperties) {
Expand All @@ -58,11 +48,36 @@ public final class ComScoreTracker: PlayerItemTracker {
case (false, false):
streamingAnalytics.notifyBufferStop()
streamingAnalytics.notifyEvent(for: properties.playbackState, at: properties.rate)
renewPlaybackSessionIfNeeded(for: properties.playbackState)
}
}

public func disable() {
streamingAnalytics = ComScoreStreamingAnalytics()
player = nil
}

private func renewPlaybackSessionIfNeeded(for playbackState: PlaybackState) {
guard playbackState == .ended else { return }
createPlaybackSession()
addMetadata(metadata)
}

private func createPlaybackSession() {
streamingAnalytics.createPlaybackSession()
streamingAnalytics.setMediaPlayerName("Pillarbox")
streamingAnalytics.setMediaPlayerVersion(Player.version)
}

private func addMetadata(_ metadata: [String: String]) {
let builder = SCORStreamingContentMetadataBuilder()
if let globals = Analytics.shared.comScoreGlobals {
builder.setCustomLabels(metadata.merging(globals.labels) { _, new in new })
}
else {
builder.setCustomLabels(metadata)
}
let contentMetadata = SCORStreamingContentMetadata(builder: builder)
streamingAnalytics.setMetadata(contentMetadata)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ final class CommandersActStreamingAnalytics {
guard event != lastEvent else { return }

switch (lastEvent, event) {
case (.pause, .seek), (.pause, .eof), (.seek, .pause), (.seek, .eof), (.eof, _), (.stop, _):
case (.pause, .seek), (.pause, .eof), (.seek, .pause), (.seek, .eof), (.stop, _):
break
case (.none, _) where event != .play, (.eof, _) where event != .play:
break
case let (.none, event) where event != .play:
return
default:
sendEvent(event)
}
Expand Down
12 changes: 12 additions & 0 deletions Sources/Player/Player.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,18 @@ public final class Player: ObservableObject, Equatable {
queuePlayer
}

/// The action that the player should perform when playback of an item ends.
///
/// The default value is `.advance`.
public var actionAtItemEnd: AVPlayer.ActionAtItemEnd {
get {
queuePlayer.actionAtItemEnd
}
set {
queuePlayer.actionAtItemEnd = newValue
}
}

/// A policy that determines how playback of audiovisual media continues when the app transitions
/// to the background.
public var audiovisualBackgroundPlaybackPolicy: AVPlayerAudiovisualBackgroundPlaybackPolicy {
Expand Down
59 changes: 40 additions & 19 deletions Sources/Player/Publishers/AVPlayerItemPublishers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,24 +38,6 @@ extension AVPlayerItem {
.eraseToAnyPublisher()
}

func statusPublisher() -> AnyPublisher<ItemStatus, Never> {
Publishers.Merge(
publisher(for: \.status)
.map { status in
switch status {
case .readyToPlay:
return .readyToPlay
default:
return .unknown
}
},
NotificationCenter.default.weakPublisher(for: .AVPlayerItemDidPlayToEndTime, object: self)
.map { _ in .ended }
)
.removeDuplicates()
.eraseToAnyPublisher()
}

private func timePropertiesPublisher() -> AnyPublisher<TimeProperties, Never> {
Publishers.CombineLatest3(
publisher(for: \.loadedTimeRanges),
Expand Down Expand Up @@ -106,6 +88,45 @@ extension AVPlayerItem {
}
}

extension AVPlayerItem {
func statusPublisher() -> AnyPublisher<ItemStatus, Never> {
Publishers.CombineLatest(
publisher(for: \.status),
isEndedPublisher()
)
.map { status, isEnded -> ItemStatus in
switch status {
case .readyToPlay:
return isEnded ? .ended : .readyToPlay
default:
return .unknown
}
}
.removeDuplicates()
.eraseToAnyPublisher()
}

private func isEndedPublisher() -> AnyPublisher<Bool, Never> {
Publishers.Merge(endTimeNotificationPublisher(), timebaseUpdateNotificationPublisher())
.prepend(false)
.removeDuplicates()
.eraseToAnyPublisher()
}

private func endTimeNotificationPublisher() -> AnyPublisher<Bool, Never> {
NotificationCenter.default.weakPublisher(for: AVPlayerItem.didPlayToEndTimeNotification, object: self)
.map { _ in true }
.eraseToAnyPublisher()
}

private func timebaseUpdateNotificationPublisher() -> AnyPublisher<Bool, Never> {
publisher(for: \.timebase)
.compactMap { $0 }
.map { _ in false }
.eraseToAnyPublisher()
}
}

extension AVPlayerItem {
func errorPublisher() -> AnyPublisher<Error, Never> {
Publishers.Merge(
Expand All @@ -126,7 +147,7 @@ extension AVPlayerItem {
}

private func playbackErrorPublisher() -> AnyPublisher<Error, Never> {
NotificationCenter.default.weakPublisher(for: .AVPlayerItemFailedToPlayToEndTime, object: self)
NotificationCenter.default.weakPublisher(for: AVPlayerItem.didPlayToEndTimeNotification, object: self)
.compactMap { notification in
guard let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error else {
return nil
Expand Down
2 changes: 1 addition & 1 deletion Sources/Player/Types/CoreProperties.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ extension CoreProperties {
}

var playbackState: PlaybackState {
.init(itemStatus: itemProperties.status, rate: rate)
.init(itemStatus: itemStatus, rate: rate)
}
}

Expand Down
40 changes: 39 additions & 1 deletion Tests/AnalyticsTests/ComScore/ComScoreTrackerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ final class ComScoreTrackerTests: ComScoreTestCase {
expectAtLeastHits(
.play { labels in
expect(labels.ns_st_mp).to(equal("Pillarbox"))
expect(labels.ns_st_mv).notTo(beEmpty())
expect(labels.ns_st_mv).to(equal(PackageInfo.version))
expect(labels.cs_ucfr).to(beEmpty())
}
) {
Expand Down Expand Up @@ -228,4 +228,42 @@ final class ComScoreTrackerTests: ComScoreTestCase {
player.play()
}
}

func testSessionRenewal() {
let player = Player(item: .simple(
url: Stream.shortOnDemand.url,
metadata: AssetMetadataMock(),
trackerAdapters: [
ComScoreTracker.adapter { _ in .test }
]
))
player.actionAtItemEnd = .pause

var ns_st_id: String?

expectAtLeastHits(
.play { labels in
ns_st_id = labels.ns_st_id
},
.end()
) {
player.play()
}

expectAtLeastHits(
.play { labels in
expect(labels.ns_st_id).notTo(beNil())
expect(labels.ns_st_id).notTo(equal(ns_st_id))

// Other metadata must be preserved as well.
expect(labels.ns_st_mp).to(equal("Pillarbox"))
expect(labels.ns_st_mv).to(equal(PackageInfo.version))
expect(labels["media_title"]).to(equal("name"))
expect(labels.cs_ucfr).to(beEmpty())
}
) {
player.seek(to: .zero)
player.play()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,9 @@ final class CommandersActStreamingAnalyticsStateTransitionTests: CommandersActTe
let analytics = CommandersActStreamingAnalytics()
analytics.notify(.play)
analytics.notify(.eof)
expectNoHits(during: .milliseconds(500)) {
expectAtLeastHits(.play()) {
analytics.notify(.play)
expect(analytics.lastEvent).to(equal(.eof))
expect(analytics.lastEvent).to(equal(.play))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ final class CommandersActTrackerMetadataTests: CommandersActTestCase {
expectAtLeastHits(
.play { labels in
expect(labels.media_player_display).to(equal("Pillarbox"))
expect(labels.media_player_version).notTo(beEmpty())
expect(labels.media_player_version).to(equal(PackageInfo.version))
expect(labels.media_volume).notTo(beNil())
expect(labels.media_title).to(equal("name"))
expect(labels.media_audio_track).to(equal("UND"))
Expand Down Expand Up @@ -52,7 +52,7 @@ final class CommandersActTrackerMetadataTests: CommandersActTestCase {
expectAtLeastHits(
.stop { labels in
expect(labels.media_player_display).to(equal("Pillarbox"))
expect(labels.media_player_version).notTo(beEmpty())
expect(labels.media_player_version).to(equal(PackageInfo.version))
expect(labels.media_volume).notTo(beNil())
expect(labels.media_title).to(equal("name"))
expect(labels.media_audio_track).to(equal("UND"))
Expand Down
2 changes: 1 addition & 1 deletion Tests/PlayerTests/Asset/AssetCreationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ final class AssetCreationTests: TestCase {
expect(asset.resource).to(equal(.custom(url: Stream.onDemand.url, delegate: delegate)))
expect(asset.nowPlayingInfo()).notTo(beEmpty())
expect(asset.playerItem().preferredForwardBufferDuration).to(equal(4))
expect(asset.playerItem().externalMetadata).toNot(beEmpty())
expect(asset.playerItem().externalMetadata).notTo(beEmpty())
}

func testCustomAssetWithoutConfiguration() {
Expand Down
Loading

0 comments on commit 49b3c94

Please sign in to comment.