Skip to content

Commit

Permalink
Add mandatory tracker support (#971)
Browse files Browse the repository at this point in the history
Co-authored-by: Walid Kayhal <3347810+waliid@users.noreply.github.com>
  • Loading branch information
defagos and waliid authored Aug 14, 2024
1 parent 4bc95e0 commit d209439
Show file tree
Hide file tree
Showing 15 changed files with 187 additions and 59 deletions.
9 changes: 7 additions & 2 deletions Sources/Analytics/CommandersAct/CommandersActTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,7 @@ public final class CommandersActTracker: PlayerItemTracker {

public func disable(with properties: PlayerProperties) {
notify(.stop, properties: properties)
stopwatch.reset()
heartbeat.reset()
reset()
}
}

Expand Down Expand Up @@ -89,6 +88,12 @@ private extension CommandersActTracker {
lastEvent = event
}
}

func reset() {
stopwatch.reset()
heartbeat.reset()
lastEvent = .none
}
}

private extension CommandersActTracker {
Expand Down
4 changes: 3 additions & 1 deletion Sources/Player/Player.docc/Articles/tracking/tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Once you have a tracker you can attach it to any item. The only requirement is t

> Tip: More information about <doc:metadata> is available from the dedicated article.
This transformation requires the use of a dedicated adapter, simply created from your custom tracker type using the ``PlayerItemTracker/adapter(configuration:mapper:)`` method. The adapter is also where you can supply any configuration required by your tracker:
This transformation requires the use of a dedicated adapter, simply created from your custom tracker type using the ``PlayerItemTracker/adapter(configuration:behavior:mapper:)`` method. The adapter is also where you can supply any configuration required by your tracker:

```swift
let item = PlayerItem.simple(url: url, metadata: CustomMetadata(), trackerAdapters: [
Expand All @@ -52,3 +52,5 @@ let item = PlayerItem.simple(url: url, metadata: CustomMetadata(), trackerAdapte
```

Note that alternative adapter creation methods are available if your tracker has `Void` configuration and / or metadata.

> Tip: You can enable or disable trackers with the ``Player/isTrackingEnabled`` player property. Trackers ignore this setting if their behavior is set to to ``TrackingBehavior/mandatory`` when creating the associated adapter with ``PlayerItemTracker/adapter(configuration:behavior:mapper:)``.
1 change: 1 addition & 0 deletions Sources/Player/Player.docc/Extensions/player.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
### Tracking

- ``isTrackingEnabled``
- ``currentSessionIdentifiers(trackedBy:)``

### User Interface

Expand Down
4 changes: 4 additions & 0 deletions Sources/Player/Player.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ public final class Player: ObservableObject, Equatable {
}

/// A Boolean setting whether trackers must be enabled or not.
///
/// This property only affects trackers having optional ``TrackingBehavior``, set when creating a corresponding
/// adapter using ``PlayerItemTracker/adapter(configuration:behavior:mapper:)`` or similar methods.
///
public var isTrackingEnabled = true {
didSet {
tracker?.isEnabled = isTrackingEnabled
Expand Down
20 changes: 12 additions & 8 deletions Sources/Player/PlayerItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,26 +137,30 @@ public final class PlayerItem: Equatable {
}

extension PlayerItem {
func enableTrackers(for player: AVPlayer) {
trackerAdapters.forEach { adapter in
private func trackerAdapters(matchingBehavior behavior: TrackingBehavior) -> [PlayerItemTracking] {
trackerAdapters.filter { $0.behavior == behavior }
}

func enableTrackers(matchingBehavior behavior: TrackingBehavior, for player: AVPlayer) {
trackerAdapters(matchingBehavior: behavior).forEach { adapter in
adapter.enable(for: player)
}
}

func updateTrackersProperties(to properties: PlayerProperties) {
trackerAdapters.forEach { adapter in
func updateTrackersProperties(matchingBehavior behavior: TrackingBehavior, to properties: PlayerProperties) {
trackerAdapters(matchingBehavior: behavior).forEach { adapter in
adapter.updateProperties(to: properties)
}
}

func updateTrackersMetricEvents(to events: [MetricEvent]) {
trackerAdapters.forEach { adapter in
func updateTrackersMetricEvents(matchingBehavior behavior: TrackingBehavior, to events: [MetricEvent]) {
trackerAdapters(matchingBehavior: behavior).forEach { adapter in
adapter.updateMetricEvents(to: events)
}
}

func disableTrackers(with properties: PlayerProperties) {
trackerAdapters.forEach { adapter in
func disableTrackers(matchingBehavior behavior: TrackingBehavior, with properties: PlayerProperties) {
trackerAdapters(matchingBehavior: behavior).forEach { adapter in
adapter.disable(with: properties)
}
}
Expand Down
31 changes: 21 additions & 10 deletions Sources/Player/Tracking/PlayerItemTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,38 +81,49 @@ public extension PlayerItemTracker {
///
/// - Parameters:
/// - configuration: The tracker configuration.
/// - behavior: The tracking behavior.
/// - mapper: A closure that maps an item metadata to tracker metadata.
/// - Returns: The tracker adapter.
static func adapter<M>(configuration: Configuration, mapper: @escaping (M) -> Metadata) -> TrackerAdapter<M> {
.init(trackerType: Self.self, configuration: configuration, mapper: mapper)
static func adapter<M>(
configuration: Configuration,
behavior: TrackingBehavior = .optional,
mapper: @escaping (M) -> Metadata
) -> TrackerAdapter<M> {
.init(trackerType: Self.self, configuration: configuration, behavior: behavior, mapper: mapper)
}
}

public extension PlayerItemTracker where Configuration == Void {
/// Creates an adapter for the receiver.
///
/// - Parameter mapper: A closure that maps an item metadata to tracker metadata.
/// - Parameters:
/// - behavior: The tracking behavior.
/// - mapper: A closure that maps an item metadata to tracker metadata.
/// - Returns: The tracker adapter.
static func adapter<M>(mapper: @escaping (M) -> Metadata) -> TrackerAdapter<M> {
.init(trackerType: Self.self, configuration: (), mapper: mapper)
static func adapter<M>(behavior: TrackingBehavior = .optional, mapper: @escaping (M) -> Metadata) -> TrackerAdapter<M> {
.init(trackerType: Self.self, configuration: (), behavior: behavior, mapper: mapper)
}
}

public extension PlayerItemTracker where Metadata == Void {
/// Creates an adapter for the receiver.
///
/// - Parameter configuration: The tracker configuration.
/// - Parameters:
/// - configuration: The tracker configuration.
/// - behavior: The tracking behavior.
/// - Returns: The tracker adapter.
static func adapter<M>(configuration: Configuration) -> TrackerAdapter<M> {
.init(trackerType: Self.self, configuration: configuration) { _ in }
static func adapter<M>(configuration: Configuration, behavior: TrackingBehavior = .optional) -> TrackerAdapter<M> {
.init(trackerType: Self.self, configuration: configuration, behavior: behavior) { _ in }
}
}

public extension PlayerItemTracker where Configuration == Void, Metadata == Void {
/// Creates an adapter for the receiver.
///
/// - Parameter behavior: The tracking behavior.
///
/// - Returns: The tracker adapter.
static func adapter<M>() -> TrackerAdapter<M> {
.init(trackerType: Self.self, configuration: ()) { _ in }
static func adapter<M>(behavior: TrackingBehavior = .optional) -> TrackerAdapter<M> {
.init(trackerType: Self.self, configuration: (), behavior: behavior) { _ in }
}
}
1 change: 1 addition & 0 deletions Sources/Player/Tracking/PlayerItemTracking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import AVFoundation

protocol PlayerItemTracking {
var behavior: TrackingBehavior { get }
var registration: TrackingRegistration? { get }

func enable(for player: AVPlayer)
Expand Down
59 changes: 40 additions & 19 deletions Sources/Player/Tracking/Tracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ final class Tracker {
didSet {
guard isEnabled != oldValue else { return }
if isEnabled {
enable()
enableTrackers(matchingBehavior: .optional)
item.updateTrackersProperties(matchingBehavior: .optional, to: properties)
item.updateTrackersMetricEvents(matchingBehavior: .optional, to: metricEventCollector.metricEvents)
}
else {
disable()
disableTrackers(matchingBehavior: .optional)
}
}
}
Expand All @@ -45,40 +47,59 @@ final class Tracker {
self.isEnabled = isEnabled
self.metricEventCollector = MetricEventCollector(items: items)

enableTrackers(matchingBehavior: .mandatory)
if isEnabled {
enable()
enableTrackers(matchingBehavior: .optional)
}
configurePublishers()
}

private func enable() {
item.enableTrackers(for: player)
metricEventCollector.$metricEvents
.filter { !$0.isEmpty }
.sink { [item] events in
item.updateTrackersMetricEvents(to: events)
}
.store(in: &cancellables)
private func enableTrackers(matchingBehavior behavior: TrackingBehavior) {
item.enableTrackers(matchingBehavior: behavior, for: player)
}

private func disableTrackers(matchingBehavior behavior: TrackingBehavior) {
item.disableTrackers(matchingBehavior: behavior, with: properties)
}

private func updateTrackersProperties(to properties: PlayerProperties) {
item.updateTrackersProperties(matchingBehavior: .mandatory, to: properties)
if isEnabled {
item.updateTrackersProperties(matchingBehavior: .optional, to: properties)
}
}

private func updateTrackersMetricEvents(to events: [MetricEvent]) {
item.updateTrackersMetricEvents(matchingBehavior: .mandatory, to: events)
if isEnabled {
item.updateTrackersMetricEvents(matchingBehavior: .optional, to: events)
}
}

private func configurePublishers() {
$playerItem
.map { [player] playerItem in
playerItem.propertiesPublisher(with: player)
}
.switchToLatest()
.handleEvents(receiveOutput: { [item] properties in
.handleEvents(receiveOutput: { [weak self] properties in
// swiftlint:disable:previous trailing_closure
item.updateTrackersProperties(to: properties)
self?.updateTrackersProperties(to: properties)
})
.weakAssign(to: \.properties, on: self)
.store(in: &cancellables)
}

private func disable() {
cancellables = []
item.disableTrackers(with: properties)
metricEventCollector.$metricEvents
.filter { !$0.isEmpty }
.sink { [weak self] events in
self?.updateTrackersMetricEvents(to: events)
}
.store(in: &cancellables)
}

deinit {
if isEnabled {
disable()
disableTrackers(matchingBehavior: .optional)
}
disableTrackers(matchingBehavior: .mandatory)
}
}
16 changes: 9 additions & 7 deletions Sources/Player/Tracking/TrackerAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,25 @@ import AVFoundation
///
/// An adapter transforms metadata delivered by a player item into the metadata format required by the tracker.
public struct TrackerAdapter<M> {
let behavior: TrackingBehavior

private let tracker: any PlayerItemTracker
private let update: (M) -> Void

/// Creates an adapter for a type of tracker with the provided mapper.
///
/// - Parameters:
/// - trackerType: The type of the tracker to instantiate and manage.
/// - configuration: The tracker configuration.
/// - mapper: The metadata mapper.
public init<T>(trackerType: T.Type, configuration: T.Configuration, mapper: ((M) -> T.Metadata)?) where T: PlayerItemTracker {
init<T>(
trackerType: T.Type,
configuration: T.Configuration,
behavior: TrackingBehavior,
mapper: ((M) -> T.Metadata)?
) where T: PlayerItemTracker {
let tracker = trackerType.init(configuration: configuration)
update = { metadata in
if let mapper {
tracker.updateMetadata(to: mapper(metadata))
}
}
self.tracker = tracker
self.behavior = behavior
}

func updateMetadata(to metadata: M) {
Expand Down
20 changes: 20 additions & 0 deletions Sources/Player/Types/TrackingBehavior.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// Copyright (c) SRG SSR. All rights reserved.
//
// License information is available from the LICENSE file.
//

/// A tracking behavior.
///
/// Determines how a ``TrackerAdapter`` is affected by the ``Player/isTrackingEnabled`` player setting.
public enum TrackingBehavior {
/// Optional tracking.
///
/// The ``TrackerAdapter`` takes into account the ``Player/isTrackingEnabled`` player setting.
case optional

/// Mandatory tracking.
///
/// The ``TrackerAdapter`` ignores the ``Player/isTrackingEnabled`` player setting.
case mandatory
}
25 changes: 25 additions & 0 deletions Tests/AnalyticsTests/CommandersAct/CommandersActTrackerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,29 @@ final class CommandersActTrackerTests: CommandersActTestCase {
player.isTrackingEnabled = true
}
}

func testEnableTrackingAgainWhilePaused() {
let player = Player()
player.append(.simple(
url: Stream.onDemand.url,
trackerAdapters: [
CommandersActTracker.adapter { _ in [:] }
]
))

expectAtLeastHits(play()) {
player.play()
}
expectAtLeastHits(stop()) {
player.isTrackingEnabled = false
}

player.pause()
expect(player.playbackState).toEventually(equal(.paused))

expectAtLeastHits(play()) {
player.isTrackingEnabled = true
player.play()
}
}
}
10 changes: 1 addition & 9 deletions Tests/MonitoringTests/MetricsTrackerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,7 @@ final class MetricsTrackerTests: MonitoringTestCase {
) { _ in .test }
]
))
expectAtLeastHits(
start(),
heartbeat { payload in
expect(payload.data.playerPosition).to(beCloseTo(1000, within: 100))
},
heartbeat { payload in
expect(payload.data.playerPosition).to(beCloseTo(2000, within: 100))
}
) {
expectAtLeastHits(start(), heartbeat(), heartbeat()) {
player.play()
}
}
Expand Down
4 changes: 2 additions & 2 deletions Tests/PlayerTests/Tools/PlayerItemTrackerMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ final class PlayerItemTrackerMock: PlayerItemTracker {
}

extension PlayerItemTrackerMock {
static func adapter(statePublisher: StatePublisher) -> TrackerAdapter<Void> {
adapter(configuration: Configuration(statePublisher: statePublisher))
static func adapter(statePublisher: StatePublisher, behavior: TrackingBehavior = .optional) -> TrackerAdapter<Void> {
adapter(configuration: Configuration(statePublisher: statePublisher), behavior: behavior)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ final class PlayerItemTrackerMetricPublisherTests: TestCase {
[.anyAssetLoading, .anyResourceLoading]
],
from: player.metricEventsPublisher,
during: .milliseconds(1500)
during: .seconds(2)
) {
player.play()
}
Expand Down
Loading

0 comments on commit d209439

Please sign in to comment.