From c0778c13b3aaca78ef844965454aa84a5251f165 Mon Sep 17 00:00:00 2001 From: Gilles Moris Date: Fri, 6 Nov 2026 11:08:57 +0100 Subject: [PATCH 01/14] Add a Lens connector that adapts THEOplayer to the Lens player adapter Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Code/Lens/Source/THEOplayerLensAdapter.swift | 449 +++++++++++++++++++ Package.swift | 22 + 2 files changed, 471 insertions(+) create mode 100644 Code/Lens/Source/THEOplayerLensAdapter.swift diff --git a/Code/Lens/Source/THEOplayerLensAdapter.swift b/Code/Lens/Source/THEOplayerLensAdapter.swift new file mode 100644 index 00000000..60d2517d --- /dev/null +++ b/Code/Lens/Source/THEOplayerLensAdapter.swift @@ -0,0 +1,449 @@ +// +// THEOplayerLensAdapter.swift +// + +import Foundation +import LensSDK +import THEOplayerConnectorUtilities +import THEOplayerSDK + +/// Reports a THEOplayer to the OptiView Lens SDK, through the player's public API. +/// +/// - Remark: +/// - Create one adapter per player and hand the same instance to every `Lens.join` that observes it: Lens tells +/// players apart by adapter identity. +/// - Use it on the main queue, as the player. +/// - The adapter does not retain the player. +/// - Do not enable THEOplayer's own CMCD reporting for a player that Lens observes. +public final class THEOplayerLensAdapter: LensPlayerAdapter { + public let playerName = "THEOplayer" + public let playerVersion: String? = THEOplayer.version + + private weak var player: THEOplayer? + private var listeners: [(id: Int, listener: (LensPlayerFact) -> Void)] = [] + private var nextListenerId = 0 + private var lifetimeObserver: DispatchObserver? + private var observers: [DispatchObserver] = [] + private var trackObservers: [DispatchObserver] = [] + private var destroyed = false + /// The description whose source was last reported, and the typed source that report named. + private var announced: SourceDescription? + private var announcedTypedSource: TypedSource? + private var source: LensPlayerSource? + /// The source THEOplayer plays, which only `CURRENT_SOURCE_CHANGE` tells. + private var currentSource: TypedSource? + /// The description `SOURCE_CHANGE` last confirmed: until then the player's values are the previous source's. + private var loadedSource: SourceDescription? + private var lastProperties = LensPlayerProperties() + private var lastSnapshot = LensPlayerSnapshot(currentTime: 0, paused: true, ended: false) + + /// - Parameter player: The player to report. + public init(player: THEOplayer) { + self.player = player + loadedSource = player.source + // Kept for the player's lifetime: which source THEOplayer chose and loaded is only ever told by an event. + lifetimeObserver = DispatchObserver( + dispatcher: player, + eventListeners: [ + on(player, PlayerEventTypes.CURRENT_SOURCE_CHANGE) { $0.currentSource = $1.currentSource }, + on(player, PlayerEventTypes.SOURCE_CHANGE) { $0.loadedSource = $1.source }, + on(player, PlayerEventTypes.DESTROY) { $0.onDestroy() }, + ] + ) + } + + public func subscribe(_ listener: @escaping (LensPlayerFact) -> Void) -> () -> Void { + if listeners.isEmpty && !destroyed { + attach() + } + let id = nextListenerId + nextListenerId += 1 + listeners.append((id, listener)) + lastProperties = properties(known: loaded) + listener(.initial(source: destroyed ? nil : source, properties: lastProperties)) + return { [weak self] in + guard let self, let index = self.listeners.firstIndex(where: { $0.id == id }) else { return } + self.listeners.remove(at: index) + if self.listeners.isEmpty { + self.detach() + } + } + } + + public func snapshot() -> LensPlayerSnapshot { + guard let player, !destroyed, !player.isDestroyed else { return lastSnapshot } + let hasSource = loaded + lastSnapshot = LensPlayerSnapshot( + currentTime: player.currentTime, + paused: player.paused, + ended: player.ended, + playheadDateTime: hasSource ? player.currentProgramDateTime : nil, + duration: hasSource ? duration(player) : nil, + seekable: hasSource ? timeRanges(player.seekable) : nil, + buffered: hasSource ? [LensBufferedRanges(ranges: timeRanges(player.buffered))] : nil, + measuredThroughput: positive(player.metrics.currentBandwidthEstimate), + droppedFrames: hasSource ? player.metrics.droppedVideoFrames : nil, + viewWidth: positive(Double(player.bounds.width)), + viewHeight: positive(Double(player.bounds.height)) + ) + return lastSnapshot + } + + // MARK: Subscription + + private func attach() { + guard let player else { return } + announced = player.source + announcedTypedSource = typedSource(of: announced) + source = lensSource(of: announced) + let playerListeners: [RemovableEventListenerProtocol] = [ + fact(player, PlayerEventTypes.PLAY, .play), + fact(player, PlayerEventTypes.PLAYING, .playing), + fact(player, PlayerEventTypes.PAUSE, .pause), + fact(player, PlayerEventTypes.WAITING, .waiting), + fact(player, PlayerEventTypes.SEEKING, .seeking), + fact(player, PlayerEventTypes.SEEKED, .seeked), + fact(player, PlayerEventTypes.ENDED, .ended), + on(player, PlayerEventTypes.RATE_CHANGE) { $0.change() }, + on(player, PlayerEventTypes.VOLUME_CHANGE) { $0.change() }, + on(player, PlayerEventTypes.DURATION_CHANGE) { $0.change() }, + on(player, PlayerEventTypes.READY_STATE_CHANGE) { $0.change() }, + on(player, PlayerEventTypes.PROGRESS) { $0.change() }, + on(player, PlayerEventTypes.PRESENTATION_MODE_CHANGE) { $0.change() }, + on(player, PlayerEventTypes.SOURCE_CHANGE) { + $0.loadedSource = $1.source + $0.change() + }, + on(player, PlayerEventTypes.CURRENT_SOURCE_CHANGE) { $0.currentSourceChanged($1.currentSource) }, + on(player, PlayerEventTypes.ERROR) { $0.error($1) }, + ] + observers = [ + DispatchObserver(dispatcher: player, eventListeners: playerListeners), + trackListObserver( + player.videoTracks, + VideoTrackListEventTypes.ADD_TRACK, + VideoTrackListEventTypes.REMOVE_TRACK, + VideoTrackListEventTypes.CHANGE + ), + trackListObserver( + player.audioTracks, + AudioTrackListEventTypes.ADD_TRACK, + AudioTrackListEventTypes.REMOVE_TRACK, + AudioTrackListEventTypes.CHANGE + ), + trackListObserver( + player.textTracks, + TextTrackListEventTypes.ADD_TRACK, + TextTrackListEventTypes.REMOVE_TRACK, + TextTrackListEventTypes.CHANGE + ), + ] + watchTracks() + } + + private func detach() { + observers = [] + trackObservers = [] + } + + private func on( + _ dispatcher: EventDispatcherProtocol, + _ type: EventType, + _ handle: @escaping (THEOplayerLensAdapter, E) -> Void + ) -> RemovableEventListenerProtocol { + dispatcher.addRemovableEventListener(type: type) { [weak self] event in + guard let self else { return } + handle(self, event) + } + } + + private func on( + _ dispatcher: EventDispatcherProtocol, + _ type: EventType, + _ handle: @escaping (THEOplayerLensAdapter) -> Void + ) -> RemovableEventListenerProtocol { + on(dispatcher, type) { adapter, _ in handle(adapter) } + } + + private func fact(_ player: THEOplayer, _ type: EventType, _ fact: LensPlayerFact) -> RemovableEventListenerProtocol { + on(player, type) { + $0.syncSource() + $0.emit(fact) + } + } + + private func trackListObserver( + _ list: EventDispatcherProtocol, + _ add: EventType, + _ remove: EventType, + _ change: EventType + ) -> DispatchObserver { + DispatchObserver( + dispatcher: list, + eventListeners: [ + on(list, add) { $0.tracksChanged() }, + on(list, remove) { $0.tracksChanged() }, + on(list, change) { $0.tracksChanged() }, + ] + ) + } + + /// The active quality of a media track only changes on the track itself. + private func watchTracks() { + guard let player else { return } + let mediaTracks = (0.. TypedSource? { + guard let description else { return nil } + if let currentSource, description.sources.contains(where: { $0 === currentSource }) { + return currentSource + } + return description.sources.first + } + + private func lensSource(of description: SourceDescription?) -> LensPlayerSource? { + guard let player, let typedSource = typedSource(of: description) else { return nil } + // Google DAI replaces the source URL with the stream it resolved: name it by its asset IDs instead. + let contentId = googleDAIContentId(typedSource) + let url = contentId == nil ? nonEmpty(typedSource.src) : nil + guard url != nil || contentId != nil else { return nil } + return LensPlayerSource( + url: url, + contentId: contentId, + mimeType: nonEmpty(typedSource.type), + autoplay: player.autoplay, + preload: lensPreload(player.preload) + ) + } + + /// The properties; without `known`, those of the source are left out. + private func properties(known: Bool) -> LensPlayerProperties { + guard let player, !destroyed, !player.isDestroyed else { return LensPlayerProperties() } + let live = known ? duration(player).map { $0.isInfinite } : nil + let type = typedSource(of: announced)?.type.lowercased() ?? "" + return LensPlayerProperties( + live: live, + lowLatency: live == true && lowLatencyTypes.contains(type) ? true : nil, + tracks: known ? tracks(player) : nil, + targetBuffer: positive(player.abr.targetBuffer), + bufferStable: known ? player.readyState == .HAVE_ENOUGH_DATA : nil, + hasMediaData: known ? !player.buffered.isEmpty : nil, + playbackRate: player.playbackRate, + muted: player.muted, + volume: Double(player.volume), + presentationMode: lensPresentationMode(player.presentationMode) + ) + } + + private func tracks(_ player: THEOplayer) -> [LensTrack]? { + let hls = isHls(currentSource ?? typedSource(of: announced)) + var result: [LensTrack] = [] + for index in 0.. = [ + "application/vnd.theo.hesp+json", "application/vnd.theo.live+channel", "application/millicast", +] + +private func mediaTrack(_ track: MediaTrack, kind: LensTrackKind, hls: Bool) -> LensTrack { + // HLS declares a bitrate on variants only, which THEOplayer also gives the audio qualities. + let ladder = kind == .audio && hls ? nil : track.qualities + var renditions: [LensRendition] = [] + var ids = Set() + for index in 0..<(ladder?.count ?? 0) { + guard let quality = ladder?[index], quality.bandwidth > 0 else { continue } + // THEOplayer identifies a quality by its bandwidth. + let id = String(quality.bandwidth) + guard ids.insert(id).inserted else { continue } + let video = quality as? VideoQuality + renditions.append( + LensRendition( + id: id, + bitrate: Double(quality.bandwidth), + averageBitrate: quality.averageBandwidth.flatMap { positive(Double($0)) }, + // An HLS variant's BANDWIDTH counts every media type it plays with. + combinedBitrate: kind == .video && hls ? true : nil, + width: video.flatMap { $0.width > 0 ? $0.width : nil }, + height: video.flatMap { $0.height > 0 ? $0.height : nil } + ) + ) + } + let rendered = track.activeQuality.map { String($0.bandwidth) } + return LensTrack( + id: String(track.uid), + kind: kind, + selected: track.enabled, + language: nonEmpty(track.language), + label: nonEmpty(track.label), + renditions: renditions.isEmpty ? nil : renditions, + renderedRenditionId: rendered.flatMap { ids.contains($0) ? $0 : nil } + ) +} + +private func googleDAIContentId(_ source: TypedSource) -> String? { + switch source.ssai { + case let live as GoogleDAILiveConfiguration: + return nonEmpty(live.assetKey) + case let vod as GoogleDAIVodConfiguration: + return vod.contentSourceID.isEmpty || vod.videoID.isEmpty ? nil : "\(vod.contentSourceID)/\(vod.videoID)" + default: + return nil + } +} + +private func isHls(_ source: TypedSource?) -> Bool { + guard let source else { return false } + let type = source.type.lowercased() + if !type.isEmpty { + return type.contains("mpegurl") + } + return URL(string: source.src)?.pathExtension.lowercased() == "m3u8" +} + +private func lensPreload(_ preload: Preload) -> LensPreload? { + switch preload { + case Preload.none: return .off + case .auto: return .auto + #if os(iOS) + case .metadata: return .metadata + #endif + @unknown default: return nil + } +} + +private func lensPresentationMode(_ mode: PresentationMode) -> LensPresentationMode? { + switch mode { + case .inline: return .inline + case .fullscreen: return .fullscreen + case .pictureInPicture: return .pictureInPicture + @unknown default: return nil + } +} + +/// THEOplayer reports 0 until it knows the duration. +private func duration(_ player: THEOplayer) -> Double? { + player.duration.flatMap { $0 > 0 ? $0 : nil } +} + +private func timeRanges(_ ranges: [TimeRange]) -> [LensTimeRange] { + ranges.map { LensTimeRange(start: $0.start, end: $0.end) } +} + +private func positive(_ value: Double) -> Double? { + value.isFinite && value > 0 ? value : nil +} + +private func nonEmpty(_ value: String) -> String? { + value.isEmpty ? nil : value +} diff --git a/Package.swift b/Package.swift index 9500e289..3bedfe0e 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,8 @@ let package = Package( .library(name: "THEOplayerConnectorUplynk", targets: ["THEOplayerConnectorUplynk"]), .library(name: "THEOplayerConnectorComscore", targets: ["THEOplayerConnectorComscore"]), + + .library(name: "THEOplayerConnectorLens", targets: ["THEOplayerConnectorLens"]), ], dependencies: [ .package(name: "ConvivaSDK", url: "https://github.com/Conviva/conviva-ios-sdk-spm", "4.3.1"..<"4.4.0"), @@ -29,6 +31,7 @@ let package = Package( .package(name: "Swifter", url: "https://github.com/httpswift/swifter.git", .exactItem("1.5.0")), .package(name: "SwiftSubtitles", url: "https://github.com/dagronf/SwiftSubtitles.git", .exactItem("0.9.1")), .package(name: "ComScore", url: "https://github.com/comScore/Comscore-Swift-Package-Manager", "6.10.0"..<"6.11.0"), + .package(url: "https://github.com/Dolby-OptiView/optiview-lens-sdk-swift", "0.6.0"..<"0.7.0"), ], targets: [ .target( @@ -106,6 +109,25 @@ let package = Package( "ComScore" ], path: "Code/Comscore/Source" + ), + + .target( + name: "THEOplayerConnectorLens", + dependencies: [ + "THEOplayerSDK", + .product(name: "LensSDK", package: "optiview-lens-sdk-swift"), + .target(name: "THEOplayerConnectorUtilities") + ], + path: "Code/Lens/Source" + ), + .testTarget( + name: "THEOplayerConnectorLensTests", + dependencies: [ + "THEOplayerConnectorLens", + "THEOplayerSDK", + .product(name: "LensSDK", package: "optiview-lens-sdk-swift"), + ], + path: "Code/Lens/Tests" ) ] ) From c95bccd04dc9f75b2ad2f4104c425f3930334ce5 Mon Sep 17 00:00:00 2001 From: Gilles Moris Date: Fri, 6 Nov 2026 11:08:57 +0100 Subject: [PATCH 02/14] Test the Lens connector against a real player and run it in CI Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/build-and-test.yml | 2 + .../THEOplayerConnectorLensTests.xcscheme | 53 +++ .../Tests/THEOplayerLensAdapterTests.swift | 343 ++++++++++++++++++ Code/Lens/Tests/TestMedia.swift | 42 +++ 4 files changed, 440 insertions(+) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/THEOplayerConnectorLensTests.xcscheme create mode 100644 Code/Lens/Tests/THEOplayerLensAdapterTests.swift create mode 100644 Code/Lens/Tests/TestMedia.swift diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 3f445ae0..0e54d509 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -24,10 +24,12 @@ jobs: THEOplayerConnectorNielsen THEOplayerConnectorSideloadedSubtitle THEOplayerConnectorUplynk + THEOplayerConnectorLens # Test schemes to run TEST_SCHEMES: >- THEOplayerConnectorSideloadedSubtitleTests THEOplayerConnectorUplynkTests + THEOplayerConnectorLensTests steps: - uses: maxim-lobanov/setup-xcode@v1 diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/THEOplayerConnectorLensTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/THEOplayerConnectorLensTests.xcscheme new file mode 100644 index 00000000..c0fbfc35 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/THEOplayerConnectorLensTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Code/Lens/Tests/THEOplayerLensAdapterTests.swift b/Code/Lens/Tests/THEOplayerLensAdapterTests.swift new file mode 100644 index 00000000..ebfdcec1 --- /dev/null +++ b/Code/Lens/Tests/THEOplayerLensAdapterTests.swift @@ -0,0 +1,343 @@ +import LensSDK +import XCTest +@testable import THEOplayerConnectorLens +import THEOplayerSDK + +@MainActor +final class THEOplayerLensAdapterTests: XCTestCase { + // Local sources need no network, and THEOplayer's license allows them. + private let hlsUrl = localUrl("stream.m3u8") + private lazy var hlsSource = SourceDescription(source: TypedSource(src: hlsUrl, type: "application/x-mpegurl")) + private let mp4Source = SourceDescription(source: TypedSource(src: TestMedia.mp4.absoluteString, type: "video/mp4")) + + func testIdentifiesThePlayer() { + let adapter = THEOplayerLensAdapter(player: THEOplayer()) + XCTAssertEqual(adapter.playerName, "THEOplayer") + XCTAssertEqual(adapter.playerVersion, THEOplayer.version) + } + + func testInitialFactDescribesAPlayerWithoutSource() { + let player = THEOplayer() + let recorder = Recorder(THEOplayerLensAdapter(player: player)) + + XCTAssertEqual( + recorder.facts, + [ + .initial( + source: nil, + properties: LensPlayerProperties(playbackRate: 1, muted: false, volume: 1, presentationMode: .inline) + ) + ] + ) + } + + func testInitialFactCarriesASourceSetBeforeSubscribing() { + let player = THEOplayer() + player.autoplay = true + let adapter = THEOplayerLensAdapter(player: player) + load(hlsSource, into: player) + + let recorder = Recorder(adapter) + + guard case .initial(let source, _) = recorder.facts.first else { + return XCTFail("expected initial, got \(recorder.facts)") + } + XCTAssertEqual(source, LensPlayerSource(url: hlsUrl, mimeType: "application/x-mpegurl", autoplay: true, preload: .auto)) + } + + func testSettingASourceReportsItWithoutTheOldSourcesProperties() { + let player = THEOplayer() + let recorder = Recorder(THEOplayerLensAdapter(player: player)) + + load(hlsSource, into: player) + + XCTAssertEqual(recorder.sources.count, 1) + let reported = recorder.sources.first + XCTAssertEqual(reported?.source, LensPlayerSource(url: hlsUrl, mimeType: "application/x-mpegurl", autoplay: false, preload: .off)) + XCTAssertEqual(reported?.continuation, false) + XCTAssertNil(reported?.properties.tracks) + XCTAssertNil(reported?.properties.live) + XCTAssertNil(reported?.properties.bufferStable) + XCTAssertNil(reported?.properties.hasMediaData) + // Loaded, but THEOplayer does not know the duration yet. + XCTAssertNotNil(recorder.changes.last?.bufferStable) + XCTAssertNil(recorder.changes.last?.live) + } + + func testReplacingAndRemovingTheSourceReportsEachChange() { + let player = THEOplayer() + let recorder = Recorder(THEOplayerLensAdapter(player: player)) + let mp4Url = Self.localUrl("other.mp4") + + load(hlsSource, into: player) + load(SourceDescription(source: TypedSource(src: mp4Url, type: "video/mp4")), into: player) + load(nil, into: player) + + XCTAssertEqual(recorder.sources.map(\.source?.url), [hlsUrl, mp4Url, nil]) + } + + func testTheSameDescriptionSetAgainKeepsItsSource() { + let player = THEOplayer() + let recorder = Recorder(THEOplayerLensAdapter(player: player)) + + load(hlsSource, into: player) + load(hlsSource, into: player) + + XCTAssertEqual(recorder.sources.count, 1) + } + + func testTheSourceTheDescriptionPlaysIsReported() { + let player = THEOplayer() + let recorder = Recorder(THEOplayerLensAdapter(player: player)) + + load( + SourceDescription(sources: [ + TypedSource(src: Self.localUrl("stream.mpd"), type: "application/dash+xml"), + TypedSource(src: hlsUrl, type: "application/x-mpegurl"), + ]), + into: player + ) + + XCTAssertEqual(recorder.sources.last?.source?.url, hlsUrl) + } + + func testGoogleDAISourcesAreNamedByTheirAssetIds() { + let player = THEOplayer() + let recorder = Recorder(THEOplayerLensAdapter(player: player)) + let dai = GoogleDAITypedSource( + ssai: GoogleDAIVodConfiguration( + videoID: "tears-of-steel", + contentSourceID: "2548831", + apiKey: "", + authToken: nil, + streamActivityMonitorID: nil, + adTagParameters: nil + ) + ) + dai.src = hlsUrl + + load(SourceDescription(source: dai), into: player) + + XCTAssertEqual(recorder.sources.map(\.source?.contentId), ["2548831/tears-of-steel"]) + XCTAssertEqual(recorder.sources.map(\.source?.url), [nil]) + } + + func testPlaybackIsReportedInOrder() { + let player = THEOplayer() + let recorder = Recorder(THEOplayerLensAdapter(player: player)) + load(mp4Source, into: player) + + player.play() + recorder.wait(in: self) { $0 == .ended } + + let order = [ + recorder.facts.firstIndex { if case .source = $0 { return true } else { return false } }, + recorder.facts.firstIndex(of: .play), + recorder.facts.firstIndex(of: .playing), + recorder.facts.firstIndex(of: .ended), + ] + XCTAssertEqual(order.compactMap { $0 }, order.compactMap { $0 }.sorted()) + XCTAssertEqual(order.compactMap { $0 }.count, 4, "\(recorder.facts)") + } + + func testAPreloadedSourceReportsItsOwnProperties() { + let player = THEOplayer() + player.preload = .auto + let adapter = THEOplayerLensAdapter(player: player) + let recorder = Recorder(adapter) + load(mp4Source, into: player) + + recorder.wait(in: self) { + guard case .change(let properties) = $0 else { return false } + return properties.live == false && properties.bufferStable == true && properties.hasMediaData == true + } + + XCTAssertNil(recorder.changes.last?.lowLatency) + XCTAssertEqual(adapter.snapshot().duration ?? 0, TestMedia.duration, accuracy: 0.1) + } + + func testAFailingSourceIsReportedAsAFatalError() { + let player = THEOplayer() + let recorder = Recorder(THEOplayerLensAdapter(player: player)) + + load(hlsSource, into: player) + player.play() + recorder.wait(in: self) { if case .error = $0 { return true } else { return false } } + + guard case .error(let fatal, let code, let message) = recorder.facts.last else { + return XCTFail("expected an error, got \(recorder.facts)") + } + XCTAssertTrue(fatal) + XCTAssertNotNil(Int32(code).flatMap(THEOErrorCode.init(rawValue:))) + XCTAssertNotNil(message) + XCTAssertFalse(message?.contains("\n") ?? false) + } + + func testPlayerSettingsArePushedWhenTheyChange() { + let player = THEOplayer() + let recorder = Recorder(THEOplayerLensAdapter(player: player)) + loadMedia(into: player, recorder) + + player.muted = true + recorder.wait(in: self) { if case .change(let properties) = $0 { return properties.muted == true } else { return false } } + player.volume = 0.5 + recorder.wait(in: self) { if case .change(let properties) = $0 { return properties.volume == 0.5 } else { return false } } + player.playbackRate = 2 + recorder.wait(in: self) { if case .change(let properties) = $0 { return properties.playbackRate == 2 } else { return false } } + } + + func testUnchangedPropertiesAreNotPushedAgain() { + let player = THEOplayer() + let recorder = Recorder(THEOplayerLensAdapter(player: player)) + loadMedia(into: player, recorder) + player.muted = true + recorder.wait(in: self) { if case .change(let properties) = $0 { return properties.muted == true } else { return false } } + + player.muted = true + + for (previous, next) in zip(recorder.changes, recorder.changes.dropFirst()) { + XCTAssertNotEqual(previous, next) + } + } + + func testEverySubscriberStartsWithInitial() { + let adapter = THEOplayerLensAdapter(player: THEOplayer()) + let first = Recorder(adapter) + let second = Recorder(adapter) + + XCTAssertEqual(first.facts.count, 1) + XCTAssertEqual(second.facts.count, 1) + guard case .initial = second.facts.first else { return XCTFail("expected initial") } + } + + func testNoFactsAfterUnsubscribing() { + let player = THEOplayer() + let recorder = Recorder(THEOplayerLensAdapter(player: player)) + recorder.unsubscribe() + + load(hlsSource, into: player) + player.play() + + XCTAssertEqual(recorder.facts.count, 1) + } + + func testSnapshotWithoutSource() { + let player = THEOplayer(with: CGRect(x: 0, y: 0, width: 320, height: 180)) + let snapshot = THEOplayerLensAdapter(player: player).snapshot() + + XCTAssertEqual(snapshot.currentTime, 0) + XCTAssertTrue(snapshot.paused) + XCTAssertFalse(snapshot.ended) + XCTAssertNil(snapshot.duration) + XCTAssertNil(snapshot.buffered) + XCTAssertNil(snapshot.seekable) + XCTAssertNil(snapshot.droppedFrames) + XCTAssertEqual(snapshot.viewWidth, 320) + XCTAssertEqual(snapshot.viewHeight, 180) + } + + func testSnapshotWithSource() { + let player = THEOplayer() + let adapter = THEOplayerLensAdapter(player: player) + load(hlsSource, into: player) + + let snapshot = adapter.snapshot() + + XCTAssertEqual(snapshot.buffered, [LensBufferedRanges(ranges: [])]) + XCTAssertEqual(snapshot.seekable, []) + XCTAssertNotNil(snapshot.droppedFrames) + } + + func testReleasingThePlayerRemovesItsSource() { + var player: THEOplayer? = THEOplayer() + let adapter = THEOplayerLensAdapter(player: player!) + let recorder = Recorder(adapter) + load(hlsSource, into: player!) + + player = nil + + XCTAssertEqual(recorder.sources.map(\.source?.url), [hlsUrl, nil]) + XCTAssertEqual(adapter.snapshot().paused, true) + } + + func testLensStartsASessionAtTheFirstPlayRequest() throws { + try Lens.configure(LensConfigureOptions(backend: .disabled)) + let player = THEOplayer() + let handle = try Lens.join(LensJoinOptions(namespace: "connector-test", player: THEOplayerLensAdapter(player: player))) + defer { handle.dispose() } + load(mp4Source, into: player) + XCTAssertNil(handle.session) + + player.play() + + wait(for: [XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in handle.session != nil }, object: nil)], timeout: 5) + } + + /// Sets the source and waits for THEOplayer to take it: it holds sources until it has selected its license. + private func load(_ source: SourceDescription?, into player: THEOplayer) { + let loaded = expectation(description: "sourcechange") + loaded.assertForOverFulfill = false + let listener = player.addEventListener(type: PlayerEventTypes.SOURCE_CHANGE) { _ in loaded.fulfill() } + player.source = source + wait(for: [loaded], timeout: 5) + player.removeEventListener(type: PlayerEventTypes.SOURCE_CHANGE, listener: listener) + } + + /// Loads playable media and waits until THEOplayer knows it: it only reports its settings for a loaded item. + private func loadMedia(into player: THEOplayer, _ recorder: Recorder) { + player.preload = .auto + load(mp4Source, into: player) + recorder.wait(in: self) { if case .change(let properties) = $0 { return properties.live == false } else { return false } } + } + + private static func localUrl(_ name: String) -> String { + FileManager.default.temporaryDirectory.appendingPathComponent(name).absoluteString + } +} + +/// Records the facts an adapter delivers to one subscriber, holding the adapter as Lens does. +@MainActor +private final class Recorder { + private(set) var facts: [LensPlayerFact] = [] + private let adapter: THEOplayerLensAdapter + private var release: (() -> Void)? + private var awaited: (predicate: (LensPlayerFact) -> Bool, expectation: XCTestExpectation)? + + init(_ adapter: THEOplayerLensAdapter) { + self.adapter = adapter + release = adapter.subscribe { [weak self] fact in + self?.facts.append(fact) + if let awaited = self?.awaited, awaited.predicate(fact) { + awaited.expectation.fulfill() + self?.awaited = nil + } + } + } + + func unsubscribe() { + release?() + release = nil + } + + /// Waits for a fact that matches, unless one already arrived: the player delivers some facts asynchronously. + func wait(in test: XCTestCase, timeout: TimeInterval = 5, for predicate: @escaping (LensPlayerFact) -> Bool) { + guard !facts.contains(where: predicate) else { return } + let expectation = test.expectation(description: "fact") + awaited = (predicate, expectation) + test.wait(for: [expectation], timeout: timeout) + } + + var sources: [(source: LensPlayerSource?, properties: LensPlayerProperties, continuation: Bool)] { + facts.compactMap { + guard case .source(let source, let properties, let continuation) = $0 else { return nil } + return (source, properties, continuation) + } + } + + var changes: [LensPlayerProperties] { + facts.compactMap { + guard case .change(let properties) = $0 else { return nil } + return properties + } + } +} diff --git a/Code/Lens/Tests/TestMedia.swift b/Code/Lens/Tests/TestMedia.swift new file mode 100644 index 00000000..6e97151f --- /dev/null +++ b/Code/Lens/Tests/TestMedia.swift @@ -0,0 +1,42 @@ +import AVFoundation +import CoreVideo + +/// Media that plays offline: a short black H.264 video, written once per test run. +enum TestMedia { + static let duration: Double = 1 + + static let mp4: URL = { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("lens-test-\(UUID().uuidString).mp4") + let writer = try! AVAssetWriter(outputURL: url, fileType: .mp4) + let input = AVAssetWriterInput( + mediaType: .video, + outputSettings: [AVVideoCodecKey: AVVideoCodecType.h264, AVVideoWidthKey: 64, AVVideoHeightKey: 64] + ) + let adaptor = AVAssetWriterInputPixelBufferAdaptor( + assetWriterInput: input, + sourcePixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: 64, + kCVPixelBufferHeightKey as String: 64, + ] + ) + writer.add(input) + writer.startWriting() + writer.startSession(atSourceTime: .zero) + let framesPerSecond: Int32 = 10 + for frame in 0.. Date: Fri, 6 Nov 2026 11:08:58 +0100 Subject: [PATCH 03/14] Add a Lens connector example app Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../LensExample.xcodeproj/project.pbxproj | 361 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../LensExample/AppDelegate.swift | 18 + Code/Lens-Examples/LensExample/Config.swift | 16 + .../LensExample/ViewController.swift | 97 +++++ Code/Lens-Examples/README.md | 10 + 6 files changed, 509 insertions(+) create mode 100644 Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj create mode 100644 Code/Lens-Examples/LensExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Code/Lens-Examples/LensExample/AppDelegate.swift create mode 100644 Code/Lens-Examples/LensExample/Config.swift create mode 100644 Code/Lens-Examples/LensExample/ViewController.swift create mode 100644 Code/Lens-Examples/README.md diff --git a/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj b/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj new file mode 100644 index 00000000..6c0f5c33 --- /dev/null +++ b/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj @@ -0,0 +1,361 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 05A45CFB730C3C8D509763CD /* THEOplayerConnectorLens in Frameworks */ = {isa = PBXBuildFile; productRef = BD50A467F2B86C01A21AEA62 /* THEOplayerConnectorLens */; }; + 6727ECB29D2D4568413C9146 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6401D44393B218039D887E70 /* ViewController.swift */; }; + 90E843D6E688C47241AF4E1D /* LensSDK in Frameworks */ = {isa = PBXBuildFile; productRef = EB42CA8795D0CA38474E88C6 /* LensSDK */; }; + C09042638B031720488EE0B7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0595736CB1C4E25C161BCAF8 /* AppDelegate.swift */; }; + EEC733A15D5F02DEBF8363A6 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8753DD4AC5F3E283A269E22 /* Config.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 0595736CB1C4E25C161BCAF8 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 4A1B464FAA8A8AC17E8BE700 /* LensExample.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = LensExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 6401D44393B218039D887E70 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + 75D78A81E1A9C50211B30664 /* Lens-iOS-connector */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "Lens-iOS-connector"; path = ../..; sourceTree = SOURCE_ROOT; }; + C8753DD4AC5F3E283A269E22 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 7491CAF7AC316FD6A47E5097 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 05A45CFB730C3C8D509763CD /* THEOplayerConnectorLens in Frameworks */, + 90E843D6E688C47241AF4E1D /* LensSDK in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 34E387F1BF26B100769109D4 /* Products */ = { + isa = PBXGroup; + children = ( + 4A1B464FAA8A8AC17E8BE700 /* LensExample.app */, + ); + name = Products; + sourceTree = ""; + }; + 500EF6A589942868F315F14C /* Packages */ = { + isa = PBXGroup; + children = ( + 75D78A81E1A9C50211B30664 /* Lens-iOS-connector */, + ); + name = Packages; + sourceTree = ""; + }; + CC3833AB5B496A9CEF7F79FC /* LensExample */ = { + isa = PBXGroup; + children = ( + 0595736CB1C4E25C161BCAF8 /* AppDelegate.swift */, + C8753DD4AC5F3E283A269E22 /* Config.swift */, + 6401D44393B218039D887E70 /* ViewController.swift */, + ); + path = LensExample; + sourceTree = ""; + }; + DF6D653915A1F2F78C5270F2 = { + isa = PBXGroup; + children = ( + CC3833AB5B496A9CEF7F79FC /* LensExample */, + 500EF6A589942868F315F14C /* Packages */, + 34E387F1BF26B100769109D4 /* Products */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 62038F9D901A250F507F1B2E /* LensExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 09FB58C20CD885D8BC74D227 /* Build configuration list for PBXNativeTarget "LensExample" */; + buildPhases = ( + 5CC122D662E78AF1144D72BA /* Sources */, + 7491CAF7AC316FD6A47E5097 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = LensExample; + packageProductDependencies = ( + BD50A467F2B86C01A21AEA62 /* THEOplayerConnectorLens */, + EB42CA8795D0CA38474E88C6 /* LensSDK */, + ); + productName = LensExample; + productReference = 4A1B464FAA8A8AC17E8BE700 /* LensExample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 19997A7BD2AB255E760C3EB9 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + }; + }; + buildConfigurationList = EE05A1B853B83E42F82BE7EE /* Build configuration list for PBXProject "LensExample" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = DF6D653915A1F2F78C5270F2; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + C9E8062C083781BFD2E012E1 /* XCRemoteSwiftPackageReference "optiview-lens-sdk-swift" */, + AA6C5E45EC5CD078756BF947 /* XCLocalSwiftPackageReference "../.." */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 34E387F1BF26B100769109D4 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 62038F9D901A250F507F1B2E /* LensExample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 5CC122D662E78AF1144D72BA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C09042638B031720488EE0B7 /* AppDelegate.swift in Sources */, + EEC733A15D5F02DEBF8363A6 /* Config.swift in Sources */, + 6727ECB29D2D4568413C9146 /* ViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 0B6845CDE374E088EF1EF49D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.theoplayer.connector.LensExample; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 28DA32B65290217B330E05CB /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 4EC178116CA703D51F969778 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + AF38D51A041BA11FAEA9DF5B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.theoplayer.connector.LensExample; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 09FB58C20CD885D8BC74D227 /* Build configuration list for PBXNativeTarget "LensExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0B6845CDE374E088EF1EF49D /* Debug */, + AF38D51A041BA11FAEA9DF5B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + EE05A1B853B83E42F82BE7EE /* Build configuration list for PBXProject "LensExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 28DA32B65290217B330E05CB /* Debug */, + 4EC178116CA703D51F969778 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + AA6C5E45EC5CD078756BF947 /* XCLocalSwiftPackageReference "../.." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCRemoteSwiftPackageReference section */ + C9E8062C083781BFD2E012E1 /* XCRemoteSwiftPackageReference "optiview-lens-sdk-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Dolby-OptiView/optiview-lens-sdk-swift"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 0.6.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + BD50A467F2B86C01A21AEA62 /* THEOplayerConnectorLens */ = { + isa = XCSwiftPackageProductDependency; + productName = THEOplayerConnectorLens; + }; + EB42CA8795D0CA38474E88C6 /* LensSDK */ = { + isa = XCSwiftPackageProductDependency; + package = C9E8062C083781BFD2E012E1 /* XCRemoteSwiftPackageReference "optiview-lens-sdk-swift" */; + productName = LensSDK; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 19997A7BD2AB255E760C3EB9 /* Project object */; +} diff --git a/Code/Lens-Examples/LensExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Code/Lens-Examples/LensExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Code/Lens-Examples/LensExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Code/Lens-Examples/LensExample/AppDelegate.swift b/Code/Lens-Examples/LensExample/AppDelegate.swift new file mode 100644 index 00000000..574a648b --- /dev/null +++ b/Code/Lens-Examples/LensExample/AppDelegate.swift @@ -0,0 +1,18 @@ +// +// AppDelegate.swift +// LensExample +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = ViewController() + window?.makeKeyAndVisible() + return true + } +} diff --git a/Code/Lens-Examples/LensExample/Config.swift b/Code/Lens-Examples/LensExample/Config.swift new file mode 100644 index 00000000..49815b8c --- /dev/null +++ b/Code/Lens-Examples/LensExample/Config.swift @@ -0,0 +1,16 @@ +// +// Config.swift +// LensExample +// + +/// Your THEOplayer license, from the OptiView portal. +let theoplayerLicense: String? = nil + +/// An endpoint that receives the Lens reports as JSON, such as a request inspector you can watch. +let lensDestinationUrl: String? = nil + +let exampleSources: [(title: String, src: String)] = [ + ("Big Buck Bunny (VOD)", "https://cdn.theoplayer.com/video/big_buck_bunny/big_buck_bunny.m3u8"), + ("Elephants Dream (VOD)", "https://cdn.theoplayer.com/video/elephants-dream/playlist-no-subtitles.m3u8"), + ("Broken URL", "https://cdn.theoplayer.com/video/lens-example/missing.m3u8"), +] diff --git a/Code/Lens-Examples/LensExample/ViewController.swift b/Code/Lens-Examples/LensExample/ViewController.swift new file mode 100644 index 00000000..2fc3e9c1 --- /dev/null +++ b/Code/Lens-Examples/LensExample/ViewController.swift @@ -0,0 +1,97 @@ +// +// ViewController.swift +// LensExample +// + +import LensSDK +import THEOplayerConnectorLens +import THEOplayerSDK +import UIKit + +class ViewController: UIViewController { + private let player: THEOplayer = { + let configuration = THEOplayerConfigurationBuilder() + configuration.license = theoplayerLicense + return THEOplayer(configuration: configuration.build()) + }() + private var lens: LensHandle? + private let playerContainer = UIView() + private let sessionLabel = UILabel() + private var sessionTimer: Timer? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + layout() + player.addAsSubview(of: playerContainer) + + // One adapter per player: every Lens handle that observes this player shares it. + let adapter = THEOplayerLensAdapter(player: player) + do { + try Lens.configure(LensConfigureOptions(backend: .disabled)) + lens = try Lens.join( + LensJoinOptions( + namespace: "lens-example", + destinations: lensDestinationUrl.map { [LensDestination(url: $0, format: .json)] } ?? [], + player: adapter + ) + ) + } catch { + sessionLabel.text = "Lens: \(error)" + } + sessionTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + self?.showSession() + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + player.frame = playerContainer.bounds + } + + deinit { + sessionTimer?.invalidate() + lens?.dispose() + } + + private func showSession() { + guard let lens else { return } + sessionLabel.text = lens.session.map { "Lens session \($0.sessionId)" } ?? "No Lens session: press play" + } + + private func load(_ src: String) { + player.source = SourceDescription(source: TypedSource(src: src, type: "application/x-mpegurl")) + } + + private func layout() { + let buttons = exampleSources.map { source in + UIButton( + configuration: .bordered(), + primaryAction: UIAction(title: source.title) { [weak self] _ in self?.load(source.src) } + ) + } + let controls = [ + UIButton(configuration: .filled(), primaryAction: UIAction(title: "Play") { [weak self] _ in self?.player.play() }), + UIButton(configuration: .filled(), primaryAction: UIAction(title: "Pause") { [weak self] _ in self?.player.pause() }), + UIButton(configuration: .filled(), primaryAction: UIAction(title: "Stop") { [weak self] _ in self?.player.stop() }), + ] + let controlRow = UIStackView(arrangedSubviews: controls) + controlRow.distribution = .fillEqually + controlRow.spacing = 8 + sessionLabel.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + sessionLabel.numberOfLines = 0 + + playerContainer.backgroundColor = .black + let stack = UIStackView(arrangedSubviews: [playerContainer, controlRow] + buttons + [sessionLabel]) + stack.axis = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), + stack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16), + stack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16), + playerContainer.heightAnchor.constraint(equalTo: playerContainer.widthAnchor, multiplier: 9.0 / 16.0), + ]) + } +} diff --git a/Code/Lens-Examples/README.md b/Code/Lens-Examples/README.md new file mode 100644 index 00000000..c86f435b --- /dev/null +++ b/Code/Lens-Examples/README.md @@ -0,0 +1,10 @@ +# THEOplayer Connector for OptiView Lens: example + +`LensExample.xcodeproj` is an iOS app that plays a few streams with THEOplayer and reports them to the OptiView Lens SDK through the [Lens connector](../Lens). It uses the connector from this repository checkout. + +Before running it, fill in `LensExample/Config.swift`: + +- `theoplayerLicense`: your THEOplayer license. +- `lensDestinationUrl`: an endpoint that receives the Lens reports as JSON, such as a request inspector you can watch. The example disables the OptiView backend, so without a destination Lens reports nothing. + +The app shows the current Lens session, which starts at the first play request on a source. From 1db52d114b3b7e3ce7169780090e585cc4173a92 Mon Sep 17 00:00:00 2001 From: Gilles Moris Date: Fri, 6 Nov 2026 11:08:58 +0100 Subject: [PATCH 04/14] Document the Lens connector and add its changelog entry Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 5 +++++ Code/Lens/README.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ Readme.md | 1 + 3 files changed, 50 insertions(+) create mode 100644 Code/Lens/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index bab123c2..d1c86207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Lens + - Added the Lens connector, which reports THEOplayer playback to the OptiView Lens SDK through its player adapter. It is available through Swift Package Manager only. + ## [11.0.7] - 2026-11-05 ### Fixed diff --git a/Code/Lens/README.md b/Code/Lens/README.md new file mode 100644 index 00000000..ab4d5c99 --- /dev/null +++ b/Code/Lens/README.md @@ -0,0 +1,44 @@ +# THEOplayer Connector for OptiView Lens + +THEOplayer-Connector-Lens reports a THEOplayer to the [OptiView Lens SDK](https://github.com/Dolby-OptiView/optiview-lens-sdk-swift). It implements the Lens SDK's `LensPlayerAdapter` on top of the THEOplayer public API, so that Lens can run the viewing sessions of the player and report its playback on its own: playback states, errors, bitrate switches, mute and rate changes, presentation mode changes, and the player keys Lens derives from the playhead, the buffer, the tracks, the throughput and the dropped frames. + +## Installation + +The Lens SDK is distributed with Swift Package Manager only, and so is this connector. + +In Xcode, go to **File > Add Package Dependencies...** and add: + +``` +https://github.com/THEOplayer/iOS-Connector +``` + +Then select the `THEOplayerConnectorLens` product. Also add `https://github.com/Dolby-OptiView/optiview-lens-sdk-swift` and select its `LensSDK` product, to call the Lens SDK from your app. + +## Usage + +Import the modules: + +```swift +import LensSDK +import THEOplayerConnectorLens +``` + +Create a `THEOplayerLensAdapter` for your `THEOplayer` instance and join Lens with it: + +```swift +let adapter = THEOplayerLensAdapter(player: player) +let lens = try Lens.join(LensJoinOptions(namespace: "my-app", player: adapter)) +``` + +Lens then starts a session at the first play request on each source, and ends it when the source is replaced or removed, or the player is released. Call `lens.dispose()` when you no longer want to report the player. + +Notes: + +- Create one adapter per player, and hand the same adapter to every SDK that joins Lens for that player: Lens tells players apart by adapter identity, so they share one session. +- Use the adapter on the main queue, as the player. The adapter does not retain the player. +- Do not enable THEOplayer's own CMCD reporting on a player that Lens observes. +- A Google DAI source is reported by its asset key, or by its content source ID and video ID, instead of by the stream URL it resolves to. + +## Example + +See the [example app](../Lens-Examples). diff --git a/Readme.md b/Readme.md index e8b170d0..57887ca8 100644 --- a/Readme.md +++ b/Readme.md @@ -21,6 +21,7 @@ For more details about importing OptiView Player iOS SDK check the [documentatio | Nielsen | `THEOplayer-Connector-Nielsen` | 4.3.0 | [documentation](Code/Nielsen/README.md) | | SideloadedSubtitle | `THEOplayer-Connector-SideloadedSubtitle` | 5.2.0 | [documentation](Code/Sideloaded-TextTracks/README.md) | | Yospace | `THEOplayer-Connector-Yospace` | 7.8.0 | [documentation](Code/Yospace/README.md) | +| Lens | `THEOplayerConnectorLens` (SPM only) | 11.1.0 | [documentation](Code/Lens/README.md) | Notes: * OptiView Player iOS SDK Connectors are compatible with a OptiView Player SDK with the same major version number. It's not recommended to use different major versions for the iOS SDK and the Connectors. From b9c068503b4fd18a92e4f9c244749e1051b96876 Mon Sep 17 00:00:00 2001 From: Gilles Moris Date: Fri, 6 Nov 2026 14:09:58 +0100 Subject: [PATCH 05/14] Join Lens as theoplayer through a LensConnector, as on web and Android LensConnector joins Lens in the registered theoplayer namespace with the adapter, takes the other join options plus an optional content ID provider, exposes the handle and the adapter, and disposes with the player. Low latency now follows a latency configuration or a HESP or Millicast source only: a THEOlive channel can be regular HLS. A test covers a play request on a new source while playing, which the Apple SDK reports as PLAY. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Code/Lens/Source/LensConnector.swift | 85 ++++++++++++++ Code/Lens/Source/THEOplayerLensAdapter.swift | 39 ++++-- .../Tests/THEOplayerLensAdapterTests.swift | 111 +++++++++++++++++- 3 files changed, 219 insertions(+), 16 deletions(-) create mode 100644 Code/Lens/Source/LensConnector.swift diff --git a/Code/Lens/Source/LensConnector.swift b/Code/Lens/Source/LensConnector.swift new file mode 100644 index 00000000..9e34bd64 --- /dev/null +++ b/Code/Lens/Source/LensConnector.swift @@ -0,0 +1,85 @@ +// +// LensConnector.swift +// + +import LensSDK +import THEOplayerConnectorUtilities +import THEOplayerSDK + +/// The configuration of a `LensConnector`: the options of `Lens.join`, without the namespace and the player, which the +/// connector provides. +public struct LensConfiguration { + public var destinations: [LensDestination] + public var consent: LensConsent + public var strict: Bool + public var heartbeatMs: Int64 + public var batch: LensBatch? + /// Returns the content ID to report for a source. Without it, or when it returns nil, a Google DAI source is + /// reported by its asset IDs, and Lens derives the content ID of any other source from its URL. + public var contentId: ContentIdProvider? + + public init( + destinations: [LensDestination] = [], + consent: LensConsent = .granted, + strict: Bool = false, + heartbeatMs: Int64 = 30_000, + batch: LensBatch? = nil, + contentId: ContentIdProvider? = nil + ) { + self.destinations = destinations + self.consent = consent + self.strict = strict + self.heartbeatMs = heartbeatMs + self.batch = batch + self.contentId = contentId + } +} + +/// The connector between a THEOplayer and Dolby OptiView Lens. +/// +/// The connector joins Lens with a player adapter for the given player, in the `theoplayer` namespace, so Lens reports +/// playback on its own: sessions, playback states, errors and the player's CMCD keys. +public final class LensConnector { + /// The Lens handle the connector joined with, in the `theoplayer` namespace. Use it to set consent or flush. To + /// report events of your own, join Lens in your own namespace with `adapter`. + public let handle: LensHandle + + /// The Lens player adapter for the player. Another SDK reporting to Lens joins with this same adapter to share the + /// player's session. + public let adapter: THEOplayerLensAdapter + + private var destroyObserver: DispatchObserver? + + /// Creates a connector for the given player and joins Lens. + /// + /// - Parameters: + /// - player: The player to report. The connector does not retain it. + /// - configuration: The options to join Lens with. + /// - Throws: The error of `Lens.join` for invalid options. + public init(player: THEOplayer, configuration: LensConfiguration = LensConfiguration()) throws { + adapter = THEOplayerLensAdapter(player: player, contentId: configuration.contentId) + handle = try Lens.join( + LensJoinOptions( + namespace: "theoplayer", + destinations: configuration.destinations, + consent: configuration.consent, + strict: configuration.strict, + heartbeatMs: configuration.heartbeatMs, + player: adapter, + batch: configuration.batch + ) + ) + destroyObserver = DispatchObserver( + dispatcher: player, + eventListeners: [ + player.addRemovableEventListener(type: PlayerEventTypes.DESTROY) { [weak self] _ in self?.destroy() } + ] + ) + } + + /// Ends the session and stops reporting the player. The connector is destroyed with its player. + public func destroy() { + destroyObserver = nil + handle.dispose() + } +} diff --git a/Code/Lens/Source/THEOplayerLensAdapter.swift b/Code/Lens/Source/THEOplayerLensAdapter.swift index 60d2517d..1a078fbd 100644 --- a/Code/Lens/Source/THEOplayerLensAdapter.swift +++ b/Code/Lens/Source/THEOplayerLensAdapter.swift @@ -7,8 +7,13 @@ import LensSDK import THEOplayerConnectorUtilities import THEOplayerSDK +/// Returns the content ID (1-128 printable ASCII) to report for a source, or nil to report the default one. +public typealias ContentIdProvider = (SourceDescription) -> String? + /// Reports a THEOplayer to the OptiView Lens SDK, through the player's public API. /// +/// A `LensConnector` creates one and joins Lens with it; use the adapter directly only to join Lens yourself. +/// /// - Remark: /// - Create one adapter per player and hand the same instance to every `Lens.join` that observes it: Lens tells /// players apart by adapter identity. @@ -20,6 +25,7 @@ public final class THEOplayerLensAdapter: LensPlayerAdapter { public let playerVersion: String? = THEOplayer.version private weak var player: THEOplayer? + private let contentId: ContentIdProvider? private var listeners: [(id: Int, listener: (LensPlayerFact) -> Void)] = [] private var nextListenerId = 0 private var lifetimeObserver: DispatchObserver? @@ -37,9 +43,13 @@ public final class THEOplayerLensAdapter: LensPlayerAdapter { private var lastProperties = LensPlayerProperties() private var lastSnapshot = LensPlayerSnapshot(currentTime: 0, paused: true, ended: false) - /// - Parameter player: The player to report. - public init(player: THEOplayer) { + /// - Parameters: + /// - player: The player to report. + /// - contentId: Returns the content ID to report for a source. Without it, or when it returns nil, a Google DAI + /// source is reported by its asset IDs, and Lens derives the content ID of any other source from its URL. + public init(player: THEOplayer, contentId: ContentIdProvider? = nil) { self.player = player + self.contentId = contentId loadedSource = player.source // Kept for the player's lifetime: which source THEOplayer chose and loaded is only ever told by an event. lifetimeObserver = DispatchObserver( @@ -70,6 +80,9 @@ public final class THEOplayerLensAdapter: LensPlayerAdapter { } } + /// Whether Lens observes the adapter. + var subscribed: Bool { !listeners.isEmpty } + public func snapshot() -> LensPlayerSnapshot { guard let player, !destroyed, !player.isDestroyed else { return lastSnapshot } let hasSource = loaded @@ -293,10 +306,11 @@ public final class THEOplayerLensAdapter: LensPlayerAdapter { } private func lensSource(of description: SourceDescription?) -> LensPlayerSource? { - guard let player, let typedSource = typedSource(of: description) else { return nil } - // Google DAI replaces the source URL with the stream it resolved: name it by its asset IDs instead. - let contentId = googleDAIContentId(typedSource) - let url = contentId == nil ? nonEmpty(typedSource.src) : nil + guard let player, let description, let typedSource = typedSource(of: description) else { return nil } + // Google DAI replaces the source URL with the stream it resolved, so that URL is left out. + let googleDAI = typedSource.ssai is GoogleDAIConfiguration + let contentId = self.contentId?(description).flatMap(nonEmpty) ?? googleDAIContentId(typedSource) + let url = googleDAI ? nil : nonEmpty(typedSource.src) guard url != nil || contentId != nil else { return nil } return LensPlayerSource( url: url, @@ -311,10 +325,9 @@ public final class THEOplayerLensAdapter: LensPlayerAdapter { private func properties(known: Bool) -> LensPlayerProperties { guard let player, !destroyed, !player.isDestroyed else { return LensPlayerProperties() } let live = known ? duration(player).map { $0.isInfinite } : nil - let type = typedSource(of: announced)?.type.lowercased() ?? "" return LensPlayerProperties( live: live, - lowLatency: live == true && lowLatencyTypes.contains(type) ? true : nil, + lowLatency: live == true && isLowLatency(typedSource(of: announced)) ? true : nil, tracks: known ? tracks(player) : nil, targetBuffer: positive(player.abr.targetBuffer), bufferStable: known ? player.readyState == .HAVE_ENOUGH_DATA : nil, @@ -352,9 +365,13 @@ public final class THEOplayerLensAdapter: LensPlayerAdapter { } } -private let lowLatencyTypes: Set = [ - "application/vnd.theo.hesp+json", "application/vnd.theo.live+channel", "application/millicast", -] +/// A THEOlive channel alone says nothing: it can be regular HLS, LL-HLS or HESP. +private let lowLatencyTypes: Set = ["application/vnd.theo.hesp+json", "application/millicast"] + +func isLowLatency(_ source: TypedSource?) -> Bool { + guard let source else { return false } + return source.latencyConfiguration != nil || lowLatencyTypes.contains(source.type.lowercased()) +} private func mediaTrack(_ track: MediaTrack, kind: LensTrackKind, hls: Bool) -> LensTrack { // HLS declares a bitrate on variants only, which THEOplayer also gives the audio qualities. diff --git a/Code/Lens/Tests/THEOplayerLensAdapterTests.swift b/Code/Lens/Tests/THEOplayerLensAdapterTests.swift index ebfdcec1..2e62e99c 100644 --- a/Code/Lens/Tests/THEOplayerLensAdapterTests.swift +++ b/Code/Lens/Tests/THEOplayerLensAdapterTests.swift @@ -140,6 +140,22 @@ final class THEOplayerLensAdapterTests: XCTestCase { XCTAssertEqual(order.compactMap { $0 }.count, 4, "\(recorder.facts)") } + func testPlayingANewSourceWhilePlayingIsAPlayRequest() { + let player = THEOplayer() + let recorder = Recorder(THEOplayerLensAdapter(player: player)) + load(mp4Source, into: player) + player.play() + recorder.wait(in: self) { $0 == .playing } + + load(SourceDescription(source: TypedSource(src: TestMedia.mp4.absoluteString, type: "video/mp4")), into: player) + let sourceIndex = recorder.facts.count - 1 + player.play() + recorder.wait(in: self) { _ in recorder.facts[sourceIndex...].contains(.playing) } + + let next = Array(recorder.facts[sourceIndex...]) + XCTAssertLessThan(next.firstIndex(of: .play) ?? .max, next.firstIndex(of: .playing) ?? .max, "\(next)") + } + func testAPreloadedSourceReportsItsOwnProperties() { let player = THEOplayer() player.preload = .auto @@ -260,19 +276,104 @@ final class THEOplayerLensAdapterTests: XCTestCase { XCTAssertEqual(adapter.snapshot().paused, true) } - func testLensStartsASessionAtTheFirstPlayRequest() throws { - try Lens.configure(LensConfigureOptions(backend: .disabled)) + func testAContentIdProviderNamesTheSource() { + let player = THEOplayer() + let recorder = Recorder(THEOplayerLensAdapter(player: player) { $0.sources.first?.src == self.hlsUrl ? "movie-1" : nil }) + + load(hlsSource, into: player) + load(SourceDescription(source: TypedSource(src: Self.localUrl("other.mp4"), type: "video/mp4")), into: player) + + XCTAssertEqual(recorder.sources.map(\.source?.contentId), ["movie-1", nil]) + XCTAssertEqual(recorder.sources.first?.source?.url, hlsUrl) + } + + func testAContentIdProviderOverridesTheGoogleDAIAssetIds() { + let player = THEOplayer() + let recorder = Recorder(THEOplayerLensAdapter(player: player) { _ in "movie-1" }) + let dai = GoogleDAITypedSource( + ssai: GoogleDAILiveConfiguration( + assetKey: "live-asset", + apiKey: "", + authToken: nil, + streamActivityMonitorID: nil, + adTagParameters: nil + ) + ) + dai.src = hlsUrl + + load(SourceDescription(source: dai), into: player) + + XCTAssertEqual(recorder.sources.map(\.source?.contentId), ["movie-1"]) + XCTAssertEqual(recorder.sources.map(\.source?.url), [nil]) + } + + func testLowLatencyNeedsALatencyConfigurationOrALowLatencyType() { + func source(_ type: String, latency: SourceLatencyConfiguration? = nil) -> TypedSource { + TypedSource(src: hlsUrl, type: type, latencyConfiguration: latency) + } + XCTAssertTrue(isLowLatency(source("application/x-mpegurl", latency: SourceLatencyConfiguration(targetOffset: 3)))) + XCTAssertTrue(isLowLatency(source("application/vnd.theo.hesp+json"))) + XCTAssertTrue(isLowLatency(source("application/millicast"))) + XCTAssertFalse(isLowLatency(source("application/vnd.theo.live+channel"))) + XCTAssertFalse(isLowLatency(source("application/x-mpegurl"))) + } + + func testTheConnectorStartsASessionAtTheFirstPlayRequest() throws { let player = THEOplayer() - let handle = try Lens.join(LensJoinOptions(namespace: "connector-test", player: THEOplayerLensAdapter(player: player))) - defer { handle.dispose() } + let connector = try connect(player) + defer { connector.destroy() } load(mp4Source, into: player) - XCTAssertNil(handle.session) + XCTAssertNil(connector.handle.session) + + player.play() + + waitForSession(of: connector.handle) + } + + func testTheConnectorStopsWithItsPlayer() throws { + var player: THEOplayer? = THEOplayer() + let connector = try connect(player!) + load(mp4Source, into: player!) + player?.play() + waitForSession(of: connector.handle) + + player = nil + XCTAssertNil(connector.handle.session) + waitForRelease(of: connector.adapter) + } + + func testDestroyingTheConnectorEndsTheSession() throws { + let player = THEOplayer() + let connector = try connect(player) + load(mp4Source, into: player) player.play() + waitForSession(of: connector.handle) + + connector.destroy() + + XCTAssertNil(connector.handle.session) + waitForRelease(of: connector.adapter) + } + func testTheConnectorRejectsInvalidOptions() { + XCTAssertThrowsError(try LensConnector(player: THEOplayer(), configuration: LensConfiguration(heartbeatMs: 10))) + } + + private func connect(_ player: THEOplayer) throws -> LensConnector { + try Lens.configure(LensConfigureOptions(backend: .disabled)) + return try LensConnector(player: player) + } + + private func waitForSession(of handle: LensHandle) { wait(for: [XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in handle.session != nil }, object: nil)], timeout: 5) } + /// Lens releases its subscription once its last handle on the player is disposed. + private func waitForRelease(of adapter: THEOplayerLensAdapter) { + wait(for: [XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in !adapter.subscribed }, object: nil)], timeout: 5) + } + /// Sets the source and waits for THEOplayer to take it: it holds sources until it has selected its license. private func load(_ source: SourceDescription?, into player: THEOplayer) { let loaded = expectation(description: "sourcechange") From dc872e834ba2be5b244e0003ebaf485034426f01 Mon Sep 17 00:00:00 2001 From: Gilles Moris Date: Fri, 6 Nov 2026 14:09:58 +0100 Subject: [PATCH 06/14] Document LensConnector and use it in the example app Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- .../LensExample/ViewController.swift | 19 +++-- Code/Lens-Examples/README.md | 2 +- Code/Lens/README.md | 70 +++++++++++++++---- 4 files changed, 67 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1c86207..acb71b4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Lens - - Added the Lens connector, which reports THEOplayer playback to the OptiView Lens SDK through its player adapter. It is available through Swift Package Manager only. + - Added the Lens connector, which reports THEOplayer playback to OptiView Lens. It is available through Swift Package Manager only. ## [11.0.7] - 2026-11-05 diff --git a/Code/Lens-Examples/LensExample/ViewController.swift b/Code/Lens-Examples/LensExample/ViewController.swift index 2fc3e9c1..fb4c334d 100644 --- a/Code/Lens-Examples/LensExample/ViewController.swift +++ b/Code/Lens-Examples/LensExample/ViewController.swift @@ -14,7 +14,7 @@ class ViewController: UIViewController { configuration.license = theoplayerLicense return THEOplayer(configuration: configuration.build()) }() - private var lens: LensHandle? + private var lensConnector: LensConnector? private let playerContainer = UIView() private let sessionLabel = UILabel() private var sessionTimer: Timer? @@ -25,15 +25,12 @@ class ViewController: UIViewController { layout() player.addAsSubview(of: playerContainer) - // One adapter per player: every Lens handle that observes this player shares it. - let adapter = THEOplayerLensAdapter(player: player) do { try Lens.configure(LensConfigureOptions(backend: .disabled)) - lens = try Lens.join( - LensJoinOptions( - namespace: "lens-example", - destinations: lensDestinationUrl.map { [LensDestination(url: $0, format: .json)] } ?? [], - player: adapter + lensConnector = try LensConnector( + player: player, + configuration: LensConfiguration( + destinations: lensDestinationUrl.map { [LensDestination(url: $0, format: .json)] } ?? [] ) ) } catch { @@ -51,12 +48,12 @@ class ViewController: UIViewController { deinit { sessionTimer?.invalidate() - lens?.dispose() + lensConnector?.destroy() } private func showSession() { - guard let lens else { return } - sessionLabel.text = lens.session.map { "Lens session \($0.sessionId)" } ?? "No Lens session: press play" + guard let lensConnector else { return } + sessionLabel.text = lensConnector.handle.session.map { "Lens session \($0.sessionId)" } ?? "No Lens session: press play" } private func load(_ src: String) { diff --git a/Code/Lens-Examples/README.md b/Code/Lens-Examples/README.md index c86f435b..b7a7b0ec 100644 --- a/Code/Lens-Examples/README.md +++ b/Code/Lens-Examples/README.md @@ -1,6 +1,6 @@ # THEOplayer Connector for OptiView Lens: example -`LensExample.xcodeproj` is an iOS app that plays a few streams with THEOplayer and reports them to the OptiView Lens SDK through the [Lens connector](../Lens). It uses the connector from this repository checkout. +`LensExample.xcodeproj` is an iOS app that plays a few streams with THEOplayer and reports them to the OptiView Lens SDK through the [Lens connector](../Lens), with `LensConnector`. It uses the connector from this repository checkout. Before running it, fill in `LensExample/Config.swift`: diff --git a/Code/Lens/README.md b/Code/Lens/README.md index ab4d5c99..46ab06d8 100644 --- a/Code/Lens/README.md +++ b/Code/Lens/README.md @@ -1,6 +1,6 @@ # THEOplayer Connector for OptiView Lens -THEOplayer-Connector-Lens reports a THEOplayer to the [OptiView Lens SDK](https://github.com/Dolby-OptiView/optiview-lens-sdk-swift). It implements the Lens SDK's `LensPlayerAdapter` on top of the THEOplayer public API, so that Lens can run the viewing sessions of the player and report its playback on its own: playback states, errors, bitrate switches, mute and rate changes, presentation mode changes, and the player keys Lens derives from the playhead, the buffer, the tracks, the throughput and the dropped frames. +THEOplayer-Connector-Lens reports a THEOplayer to the [OptiView Lens SDK](https://github.com/Dolby-OptiView/optiview-lens-sdk-swift). It implements the Lens SDK's `LensPlayerAdapter` on top of the THEOplayer public API, so that Lens can run the viewing sessions of the player and report its playback on its own: playback states, errors, mute and rate changes, presentation mode changes, and the player keys Lens derives from the playhead, the buffer, the tracks, the throughput and the dropped frames. ## Installation @@ -12,32 +12,76 @@ In Xcode, go to **File > Add Package Dependencies...** and add: https://github.com/THEOplayer/iOS-Connector ``` -Then select the `THEOplayerConnectorLens` product. Also add `https://github.com/Dolby-OptiView/optiview-lens-sdk-swift` and select its `LensSDK` product, to call the Lens SDK from your app. +Then select the `THEOplayerConnectorLens` product. To call the Lens SDK from your app, for example to configure it or to report events of your own, also add `https://github.com/Dolby-OptiView/optiview-lens-sdk-swift` and select its `LensSDK` product. ## Usage -Import the modules: +Create one connector per player, on the main queue: ```swift -import LensSDK import THEOplayerConnectorLens + +let lensConnector = try LensConnector(player: player) ``` -Create a `THEOplayerLensAdapter` for your `THEOplayer` instance and join Lens with it: +Lens starts a session at the first play request on each source, and ends it when the source is replaced or removed. The connector does not retain the player. + +Lens reports to the OptiView ingest by default. The configuration takes the options of `Lens.join`, except the namespace and the player, for example to add destinations of your own: + +```swift +import LensSDK + +let lensConnector = try LensConnector( + player: player, + configuration: LensConfiguration(destinations: [LensDestination(url: "https://collector.example.com/lens", format: .json)]) +) +``` + +To report only to your own destinations, call `Lens.configure(LensConfigureOptions(backend: .disabled))` before creating the connector. + +The playback reports are sent in Lens's `lens` namespace, and the connector's heartbeats in the `theoplayer` namespace. A destination that filters on namespaces must list them to receive them. + +### Content ID + +Lens derives the content ID (`cid`) from the source URL. A Google DAI source is reported by its asset key, or by its content source ID and video ID, instead of by the stream URL it resolves to. To report your own content ID, return it from `contentId`: + +```swift +let lensConnector = try LensConnector( + player: player, + configuration: LensConfiguration(contentId: { source in source.metadata?.title }) +) +``` + +### Your own events + +To report events of your own, join Lens in your own registered namespace with `lensConnector.adapter`. Lens reports one session per player adapter, so your events and the player's playback share that session. Another SDK that reports this player to Lens joins with the adapter the same way. + +```swift +import LensSDK + +let lens = try Lens.join(LensJoinOptions(namespace: "my_namespace", player: lensConnector.adapter)) +lens.emitCustom("chapterStart", payload: LensPayload(custom: ["chapter": 3])) +``` + +`lensConnector.handle` is the Lens handle the connector joined with. Use it to set consent or to flush. + +### Destroying + +The connector ends the session and stops reporting when the player is released, or when you destroy it: ```swift -let adapter = THEOplayerLensAdapter(player: player) -let lens = try Lens.join(LensJoinOptions(namespace: "my-app", player: adapter)) +lensConnector.destroy() ``` -Lens then starts a session at the first play request on each source, and ends it when the source is replaced or removed, or the player is released. Call `lens.dispose()` when you no longer want to report the player. +Do not enable THEOplayer's own CMCD reporting on a player that Lens observes. -Notes: +## Limitations -- Create one adapter per player, and hand the same adapter to every SDK that joins Lens for that player: Lens tells players apart by adapter identity, so they share one session. -- Use the adapter on the main queue, as the player. The adapter does not retain the player. -- Do not enable THEOplayer's own CMCD reporting on a player that Lens observes. -- A Google DAI source is reported by its asset key, or by its content source ID and video ID, instead of by the stream URL it resolves to. +- THEOplayer exposes the quality being played, not the one being loaded. Lens therefore reports the top and lowest bitrates (`tb`/`lb`, or `tab`/`lab` for HLS), and the playhead bitrate (`pb`) outside HLS, but not the encoded bitrate (`br`) or bitrate changes (`bc`). +- A THEOplayer quality only exposes its bandwidth, and its size for video. A rendition is identified by its bandwidth, and Lens receives no codecs or frame rate. +- The visibility of the player's own view is not reported: Lens follows the app moving to the background and back. +- Only fatal errors are reported. +- Ads are not reported as such: Lens sees what the content player does during an ad break. ## Example From d1f47b2b07f67fa72b591c0b7d623315aa0f7354 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:27:48 +0000 Subject: [PATCH 07/14] Report low latency regardless of liveness and take Lens's heartbeat default Co-Authored-By: Gilles --- Code/Lens/Source/LensConnector.swift | 24 ++++++++++---------- Code/Lens/Source/THEOplayerLensAdapter.swift | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Code/Lens/Source/LensConnector.swift b/Code/Lens/Source/LensConnector.swift index 9e34bd64..7fe2cb6e 100644 --- a/Code/Lens/Source/LensConnector.swift +++ b/Code/Lens/Source/LensConnector.swift @@ -12,7 +12,8 @@ public struct LensConfiguration { public var destinations: [LensDestination] public var consent: LensConsent public var strict: Bool - public var heartbeatMs: Int64 + /// The cadence of the session's liveness report; nil for Lens's default. + public var heartbeatMs: Int64? public var batch: LensBatch? /// Returns the content ID to report for a source. Without it, or when it returns nil, a Google DAI source is /// reported by its asset IDs, and Lens derives the content ID of any other source from its URL. @@ -22,7 +23,7 @@ public struct LensConfiguration { destinations: [LensDestination] = [], consent: LensConsent = .granted, strict: Bool = false, - heartbeatMs: Int64 = 30_000, + heartbeatMs: Int64? = nil, batch: LensBatch? = nil, contentId: ContentIdProvider? = nil ) { @@ -58,17 +59,16 @@ public final class LensConnector { /// - Throws: The error of `Lens.join` for invalid options. public init(player: THEOplayer, configuration: LensConfiguration = LensConfiguration()) throws { adapter = THEOplayerLensAdapter(player: player, contentId: configuration.contentId) - handle = try Lens.join( - LensJoinOptions( - namespace: "theoplayer", - destinations: configuration.destinations, - consent: configuration.consent, - strict: configuration.strict, - heartbeatMs: configuration.heartbeatMs, - player: adapter, - batch: configuration.batch - ) + var options = LensJoinOptions( + namespace: "theoplayer", + destinations: configuration.destinations, + consent: configuration.consent, + strict: configuration.strict, + player: adapter, + batch: configuration.batch ) + if let heartbeatMs = configuration.heartbeatMs { options.heartbeatMs = heartbeatMs } + handle = try Lens.join(options) destroyObserver = DispatchObserver( dispatcher: player, eventListeners: [ diff --git a/Code/Lens/Source/THEOplayerLensAdapter.swift b/Code/Lens/Source/THEOplayerLensAdapter.swift index 1a078fbd..25d09699 100644 --- a/Code/Lens/Source/THEOplayerLensAdapter.swift +++ b/Code/Lens/Source/THEOplayerLensAdapter.swift @@ -327,7 +327,7 @@ public final class THEOplayerLensAdapter: LensPlayerAdapter { let live = known ? duration(player).map { $0.isInfinite } : nil return LensPlayerProperties( live: live, - lowLatency: live == true && isLowLatency(typedSource(of: announced)) ? true : nil, + lowLatency: isLowLatency(typedSource(of: announced)) ? true : nil, tracks: known ? tracks(player) : nil, targetBuffer: positive(player.abr.targetBuffer), bufferStable: known ? player.readyState == .HAVE_ENOUGH_DATA : nil, From dcc511c15afe69f6b400f451f1daa48561269c91 Mon Sep 17 00:00:00 2001 From: Gilles Moris Date: Fri, 6 Nov 2026 15:03:17 +0100 Subject: [PATCH 08/14] Show the reports Lens sends in the example app, for manual validation Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../LensExample.xcodeproj/project.pbxproj | 4 + Code/Lens-Examples/LensExample/Config.swift | 10 +- .../LensExample/LensReceiver.swift | 53 ++++++ .../LensExample/ViewController.swift | 177 +++++++++++++++--- Code/Lens-Examples/README.md | 17 +- 5 files changed, 227 insertions(+), 34 deletions(-) create mode 100644 Code/Lens-Examples/LensExample/LensReceiver.swift diff --git a/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj b/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj index 6c0f5c33..b7747822 100644 --- a/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj +++ b/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 05A45CFB730C3C8D509763CD /* THEOplayerConnectorLens in Frameworks */ = {isa = PBXBuildFile; productRef = BD50A467F2B86C01A21AEA62 /* THEOplayerConnectorLens */; }; + 63CEC8FC59C233CCE147F3E5 /* LensReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7E6CDD3D598D4ABECAA0102 /* LensReceiver.swift */; }; 6727ECB29D2D4568413C9146 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6401D44393B218039D887E70 /* ViewController.swift */; }; 90E843D6E688C47241AF4E1D /* LensSDK in Frameworks */ = {isa = PBXBuildFile; productRef = EB42CA8795D0CA38474E88C6 /* LensSDK */; }; C09042638B031720488EE0B7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0595736CB1C4E25C161BCAF8 /* AppDelegate.swift */; }; @@ -19,6 +20,7 @@ 4A1B464FAA8A8AC17E8BE700 /* LensExample.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = LensExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 6401D44393B218039D887E70 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 75D78A81E1A9C50211B30664 /* Lens-iOS-connector */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "Lens-iOS-connector"; path = ../..; sourceTree = SOURCE_ROOT; }; + C7E6CDD3D598D4ABECAA0102 /* LensReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LensReceiver.swift; sourceTree = ""; }; C8753DD4AC5F3E283A269E22 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -56,6 +58,7 @@ children = ( 0595736CB1C4E25C161BCAF8 /* AppDelegate.swift */, C8753DD4AC5F3E283A269E22 /* Config.swift */, + C7E6CDD3D598D4ABECAA0102 /* LensReceiver.swift */, 6401D44393B218039D887E70 /* ViewController.swift */, ); path = LensExample; @@ -134,6 +137,7 @@ files = ( C09042638B031720488EE0B7 /* AppDelegate.swift in Sources */, EEC733A15D5F02DEBF8363A6 /* Config.swift in Sources */, + 63CEC8FC59C233CCE147F3E5 /* LensReceiver.swift in Sources */, 6727ECB29D2D4568413C9146 /* ViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Code/Lens-Examples/LensExample/Config.swift b/Code/Lens-Examples/LensExample/Config.swift index 49815b8c..6f8df7dd 100644 --- a/Code/Lens-Examples/LensExample/Config.swift +++ b/Code/Lens-Examples/LensExample/Config.swift @@ -3,14 +3,14 @@ // LensExample // -/// Your THEOplayer license, from the OptiView portal. +/// Your THEOplayer license, from the OptiView portal. The example streams play without one. let theoplayerLicense: String? = nil -/// An endpoint that receives the Lens reports as JSON, such as a request inspector you can watch. +/// An endpoint that also receives the Lens reports as JSON, such as a request inspector you can watch. let lensDestinationUrl: String? = nil let exampleSources: [(title: String, src: String)] = [ - ("Big Buck Bunny (VOD)", "https://cdn.theoplayer.com/video/big_buck_bunny/big_buck_bunny.m3u8"), - ("Elephants Dream (VOD)", "https://cdn.theoplayer.com/video/elephants-dream/playlist-no-subtitles.m3u8"), - ("Broken URL", "https://cdn.theoplayer.com/video/lens-example/missing.m3u8"), + ("Big Buck Bunny (HLS VOD)", "https://cdn.theoplayer.com/video/big_buck_bunny/big_buck_bunny.m3u8"), + ("Elephants Dream (HLS VOD)", "https://cdn.theoplayer.com/video/elephants-dream/playlist-no-subtitles.m3u8"), + ("Broken URL (fatal error)", "https://cdn.theoplayer.com/video/lens-example/missing.m3u8"), ] diff --git a/Code/Lens-Examples/LensExample/LensReceiver.swift b/Code/Lens-Examples/LensExample/LensReceiver.swift new file mode 100644 index 00000000..88c337bc --- /dev/null +++ b/Code/Lens-Examples/LensExample/LensReceiver.swift @@ -0,0 +1,53 @@ +// +// LensReceiver.swift +// LensExample +// + +import Foundation + +/// A Lens destination inside the app, to show what Lens sends: it answers the requests to `url` itself, which Lens +/// posts with the shared URL session. +final class LensReceiver: URLProtocol { + static let url = "https://receiver.lens-example.invalid/reports" + + /// Called on the main queue with every report received, as a JSON object. + static var onReport: (([String: Any]) -> Void)? + + static func start() { + URLProtocol.registerClass(LensReceiver.self) + } + + override class func canInit(with request: URLRequest) -> Bool { + request.url?.absoluteString == url + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + let body = request.httpBody ?? request.httpBodyStream.map(Self.read) ?? Data() + let reports = (try? JSONSerialization.jsonObject(with: body) as? [String: Any])?["reports"] as? [[String: Any]] ?? [] + DispatchQueue.main.async { + reports.forEach { Self.onReport?($0) } + } + let response = HTTPURLResponse(url: request.url!, statusCode: 204, httpVersion: "HTTP/1.1", headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + private static func read(_ stream: InputStream) -> Data { + var data = Data() + var buffer = [UInt8](repeating: 0, count: 16_384) + stream.open() + defer { stream.close() } + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count > 0 else { break } + data.append(buffer, count: count) + } + return data + } +} diff --git a/Code/Lens-Examples/LensExample/ViewController.swift b/Code/Lens-Examples/LensExample/ViewController.swift index fb4c334d..c7d7b764 100644 --- a/Code/Lens-Examples/LensExample/ViewController.swift +++ b/Code/Lens-Examples/LensExample/ViewController.swift @@ -8,7 +8,7 @@ import THEOplayerConnectorLens import THEOplayerSDK import UIKit -class ViewController: UIViewController { +class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { private let player: THEOplayer = { let configuration = THEOplayerConfigurationBuilder() configuration.license = theoplayerLicense @@ -17,6 +17,8 @@ class ViewController: UIViewController { private var lensConnector: LensConnector? private let playerContainer = UIView() private let sessionLabel = UILabel() + private let reportsTable = UITableView(frame: .zero, style: .plain) + private var reports: [[String: Any]] = [] private var sessionTimer: Timer? override func viewDidLoad() { @@ -25,13 +27,16 @@ class ViewController: UIViewController { layout() player.addAsSubview(of: playerContainer) + LensReceiver.onReport = { [weak self] in self?.received($0) } + LensReceiver.start() + let destinations = ([LensReceiver.url] + [lensDestinationUrl].compactMap { $0 }).map { + LensDestination(url: $0, format: .json, heartbeat: true) + } do { try Lens.configure(LensConfigureOptions(backend: .disabled)) lensConnector = try LensConnector( player: player, - configuration: LensConfiguration( - destinations: lensDestinationUrl.map { [LensDestination(url: $0, format: .json)] } ?? [] - ) + configuration: LensConfiguration(destinations: destinations, heartbeatMs: 10_000) ) } catch { sessionLabel.text = "Lens: \(error)" @@ -51,44 +56,168 @@ class ViewController: UIViewController { lensConnector?.destroy() } + // MARK: Player + + private func load(_ src: String) { + let type = src.lowercased().contains(".m3u8") ? "application/x-mpegurl" : "video/mp4" + player.source = SourceDescription(source: TypedSource(src: src, type: type)) + } + + private func askForUrl() { + let alert = UIAlertController(title: "Custom URL", message: "Your THEOplayer license must allow its domain.", preferredStyle: .alert) + alert.addTextField { $0.placeholder = "https://…/stream.m3u8"; $0.keyboardType = .URL } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Load", style: .default) { [weak self, weak alert] _ in + if let src = alert?.textFields?.first?.text, !src.isEmpty { self?.load(src) } + }) + present(alert, animated: true) + } + private func showSession() { guard let lensConnector else { return } - sessionLabel.text = lensConnector.handle.session.map { "Lens session \($0.sessionId)" } ?? "No Lens session: press play" + sessionLabel.text = lensConnector.handle.session.map { "Lens session \($0.sessionId)" } ?? "No Lens session: load a source and play" } - private func load(_ src: String) { - player.source = SourceDescription(source: TypedSource(src: src, type: "application/x-mpegurl")) + // MARK: Reports + + private func received(_ report: [String: Any]) { + reports.insert(report, at: 0) + reportsTable.insertRows(at: [IndexPath(row: 0, section: 0)], with: .automatic) + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + reports.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "report") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "report") + let report = reports[indexPath.row] + cell.textLabel?.text = ReportFormat.title(report) + cell.textLabel?.font = .monospacedSystemFont(ofSize: 13, weight: .semibold) + cell.detailTextLabel?.text = ReportFormat.details(report) + cell.detailTextLabel?.font = .monospacedSystemFont(ofSize: 11, weight: .regular) + cell.detailTextLabel?.textColor = .secondaryLabel + cell.detailTextLabel?.numberOfLines = 0 + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let alert = UIAlertController(title: ReportFormat.title(reports[indexPath.row]), message: ReportFormat.json(reports[indexPath.row]), preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } + + // MARK: Layout + + private func button(_ title: String, _ style: UIButton.Configuration = .bordered(), action: @escaping (UIButton) -> Void) -> UIButton { + UIButton(configuration: style, primaryAction: UIAction(title: title) { action($0.sender as! UIButton) }) + } + + private func row(_ views: [UIView]) -> UIStackView { + let row = UIStackView(arrangedSubviews: views) + row.distribution = .fillEqually + row.spacing = 8 + return row } private func layout() { - let buttons = exampleSources.map { source in - UIButton( - configuration: .bordered(), - primaryAction: UIAction(title: source.title) { [weak self] _ in self?.load(source.src) } - ) - } + let sources = UIButton(configuration: .filled()) + sources.setTitle("Load a source", for: .normal) + sources.showsMenuAsPrimaryAction = true + sources.menu = UIMenu(children: exampleSources.map { source in + UIAction(title: source.title) { [weak self] _ in self?.load(source.src) } + } + [UIAction(title: "Custom URL…") { [weak self] _ in self?.askForUrl() }]) + let controls = [ - UIButton(configuration: .filled(), primaryAction: UIAction(title: "Play") { [weak self] _ in self?.player.play() }), - UIButton(configuration: .filled(), primaryAction: UIAction(title: "Pause") { [weak self] _ in self?.player.pause() }), - UIButton(configuration: .filled(), primaryAction: UIAction(title: "Stop") { [weak self] _ in self?.player.stop() }), + row([sources, button("Stop") { [weak self] _ in self?.player.stop() }]), + row([ + button("Play", .filled()) { [weak self] _ in self?.player.play() }, + button("Pause", .filled()) { [weak self] _ in self?.player.pause() }, + button("+30 s") { [weak self] _ in + guard let player = self?.player else { return } + player.currentTime += 30 + }, + ]), + row([ + button("Mute") { [weak self] sender in + guard let player = self?.player else { return } + player.muted.toggle() + sender.configuration?.title = player.muted ? "Unmute" : "Mute" + }, + button("Rate 2×") { [weak self] sender in + guard let player = self?.player else { return } + player.playbackRate = player.playbackRate == 1 ? 2 : 1 + sender.configuration?.title = player.playbackRate == 1 ? "Rate 2×" : "Rate 1×" + }, + button("Fullscreen") { [weak self] _ in self?.player.presentationMode = .fullscreen }, + ]), ] - let controlRow = UIStackView(arrangedSubviews: controls) - controlRow.distribution = .fillEqually - controlRow.spacing = 8 + sessionLabel.font = .monospacedSystemFont(ofSize: 12, weight: .regular) sessionLabel.numberOfLines = 0 + let reportsHeader = UILabel() + reportsHeader.text = "Reports Lens sent, newest first (tap for all keys)" + reportsHeader.font = .preferredFont(forTextStyle: .footnote) + reportsHeader.textColor = .secondaryLabel + let clear = button("Clear") { [weak self] _ in + self?.reports = [] + self?.reportsTable.reloadData() + } + clear.setContentHuggingPriority(.required, for: .horizontal) + let header = UIStackView(arrangedSubviews: [reportsHeader, clear]) + reportsTable.dataSource = self + reportsTable.delegate = self playerContainer.backgroundColor = .black - let stack = UIStackView(arrangedSubviews: [playerContainer, controlRow] + buttons + [sessionLabel]) + let stack = UIStackView(arrangedSubviews: [playerContainer] + controls + [sessionLabel, header, reportsTable]) stack.axis = .vertical - stack.spacing = 12 + stack.spacing = 8 + stack.setCustomSpacing(12, after: playerContainer) stack.translatesAutoresizingMaskIntoConstraints = false view.addSubview(stack) NSLayoutConstraint.activate([ - stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), - stack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16), - stack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16), + stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + stack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 12), + stack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12), playerContainer.heightAnchor.constraint(equalTo: playerContainer.widthAnchor, multiplier: 9.0 / 16.0), ]) } } + +/// Short, readable lines for a Lens report. +private enum ReportFormat { + static let events = [ + "t": "heartbeat", "ps": "state change", "e": "error", "bc": "bitrate change", "m": "mute", "um": "unmute", + "pr": "rate change", "b": "background change", "pe": "player expanded", "pc": "player collapsed", + "c": "content ID change", "h": "hostname change", "ce": "custom event", + ] + static let states = [ + "d": "preloading", "s": "starting", "p": "playing", "k": "seeking", "r": "rebuffering", "a": "paused", + "w": "waiting for input", "e": "ended", "f": "fatal error", "q": "quit", + ] + /// The keys worth a glance; the rest is one tap away. + static let highlighted = ["cid", "st", "sf", "pt", "bl", "tbl", "br", "pb", "tb", "lb", "ab", "tab", "lab", "mtp", "dfa", "pr", "ec", "su", "bg"] + + static func title(_ report: [String: Any]) -> String { + let event = report["e"] as? String ?? "?" + let state = (report["sta"] as? String).map { " · \(states[$0] ?? $0)" } ?? "" + return "[\(report["ns"] as? String ?? "?")] \(events[event] ?? event)\(state)" + } + + static func details(_ report: [String: Any]) -> String { + highlighted.compactMap { key in report[key].map { "\(key)=\(value($0))" } }.joined(separator: " ") + } + + static func json(_ report: [String: Any]) -> String { + report.keys.sorted().map { "\($0) = \(value(report[$0]!))" }.joined(separator: "\n") + } + + private static func value(_ value: Any) -> String { + if let items = value as? [[String: Any]] { + return "(" + items.map { item in (item["ot"].map { "\($0):" } ?? "") + "\(item["value"] ?? "")" }.joined(separator: " ") + ")" + } + return "\(value)" + } +} diff --git a/Code/Lens-Examples/README.md b/Code/Lens-Examples/README.md index b7a7b0ec..6a24dd7d 100644 --- a/Code/Lens-Examples/README.md +++ b/Code/Lens-Examples/README.md @@ -1,10 +1,17 @@ # THEOplayer Connector for OptiView Lens: example -`LensExample.xcodeproj` is an iOS app that plays a few streams with THEOplayer and reports them to the OptiView Lens SDK through the [Lens connector](../Lens), with `LensConnector`. It uses the connector from this repository checkout. +`LensExample.xcodeproj` is an iOS app that plays streams with THEOplayer and reports them to OptiView Lens with a `LensConnector` from the [Lens connector](../Lens). It uses the connector from this repository checkout. -Before running it, fill in `LensExample/Config.swift`: +Open the project, pick an iPhone simulator or device, and run. The app: -- `theoplayerLicense`: your THEOplayer license. -- `lensDestinationUrl`: an endpoint that receives the Lens reports as JSON, such as a request inspector you can watch. The example disables the OptiView backend, so without a destination Lens reports nothing. +- loads one of a few example streams, or a URL of your own (`Load a source`), +- plays, pauses, stops, seeks, mutes, changes the rate and goes fullscreen, which Lens reports as state changes, `m`/`um`, `pr` and `pe`/`pc`, +- shows the current Lens session, which starts at the first play request on a source, +- lists every report Lens sends, newest first, with its namespace, event, playback state and main keys. Tap a report for all its keys. -The app shows the current Lens session, which starts at the first play request on a source. +The reports are captured inside the app: the example disables the OptiView backend and adds a destination that the app answers itself. Heartbeats are sent every 10 seconds. + +In `LensExample/Config.swift`, you can set: + +- `theoplayerLicense`: your THEOplayer license, needed for streams of your own. The example streams play without one. +- `lensDestinationUrl`: an endpoint that also receives the reports as JSON, such as a request inspector. From b00cd3b2eb40be98a4f305c8bfb769d63761e9bd Mon Sep 17 00:00:00 2001 From: Gilles Moris Date: Fri, 6 Nov 2026 15:57:01 +0100 Subject: [PATCH 09/14] Add a staging switch and a cross-platform scenario to the Lens example The example enables the Lens development backend and sends its reports to the staging ingest only while the switch is on. The scenario replays the same playback as the Lens demos of the other platforms, reported with the content ID lens-connector-scenario, and can run unattended from launch arguments. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../LensExample.xcodeproj/project.pbxproj | 12 ++ .../LensExample/LensReceiver.swift | 62 ++++++++- Code/Lens-Examples/LensExample/Scenario.swift | 128 ++++++++++++++++++ .../LensExample/ViewController.swift | 86 +++++++++++- Code/Lens-Examples/README.md | 32 ++++- 5 files changed, 308 insertions(+), 12 deletions(-) create mode 100644 Code/Lens-Examples/LensExample/Scenario.swift diff --git a/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj b/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj index b7747822..ba148fcf 100644 --- a/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj +++ b/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj @@ -8,10 +8,12 @@ /* Begin PBXBuildFile section */ 05A45CFB730C3C8D509763CD /* THEOplayerConnectorLens in Frameworks */ = {isa = PBXBuildFile; productRef = BD50A467F2B86C01A21AEA62 /* THEOplayerConnectorLens */; }; + 43C7581412A3DF206EC4C660 /* LensSDKDev in Frameworks */ = {isa = PBXBuildFile; productRef = FFB346B2F06C0F4AC773535B /* LensSDKDev */; }; 63CEC8FC59C233CCE147F3E5 /* LensReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7E6CDD3D598D4ABECAA0102 /* LensReceiver.swift */; }; 6727ECB29D2D4568413C9146 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6401D44393B218039D887E70 /* ViewController.swift */; }; 90E843D6E688C47241AF4E1D /* LensSDK in Frameworks */ = {isa = PBXBuildFile; productRef = EB42CA8795D0CA38474E88C6 /* LensSDK */; }; C09042638B031720488EE0B7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0595736CB1C4E25C161BCAF8 /* AppDelegate.swift */; }; + C42B1C4E8257439E0DFB63F1 /* Scenario.swift in Sources */ = {isa = PBXBuildFile; fileRef = D612104EA5996CDD02E63B42 /* Scenario.swift */; }; EEC733A15D5F02DEBF8363A6 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8753DD4AC5F3E283A269E22 /* Config.swift */; }; /* End PBXBuildFile section */ @@ -22,6 +24,7 @@ 75D78A81E1A9C50211B30664 /* Lens-iOS-connector */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "Lens-iOS-connector"; path = ../..; sourceTree = SOURCE_ROOT; }; C7E6CDD3D598D4ABECAA0102 /* LensReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LensReceiver.swift; sourceTree = ""; }; C8753DD4AC5F3E283A269E22 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; + D612104EA5996CDD02E63B42 /* Scenario.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Scenario.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -31,6 +34,7 @@ files = ( 05A45CFB730C3C8D509763CD /* THEOplayerConnectorLens in Frameworks */, 90E843D6E688C47241AF4E1D /* LensSDK in Frameworks */, + 43C7581412A3DF206EC4C660 /* LensSDKDev in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -59,6 +63,7 @@ 0595736CB1C4E25C161BCAF8 /* AppDelegate.swift */, C8753DD4AC5F3E283A269E22 /* Config.swift */, C7E6CDD3D598D4ABECAA0102 /* LensReceiver.swift */, + D612104EA5996CDD02E63B42 /* Scenario.swift */, 6401D44393B218039D887E70 /* ViewController.swift */, ); path = LensExample; @@ -91,6 +96,7 @@ packageProductDependencies = ( BD50A467F2B86C01A21AEA62 /* THEOplayerConnectorLens */, EB42CA8795D0CA38474E88C6 /* LensSDK */, + FFB346B2F06C0F4AC773535B /* LensSDKDev */, ); productName = LensExample; productReference = 4A1B464FAA8A8AC17E8BE700 /* LensExample.app */; @@ -138,6 +144,7 @@ C09042638B031720488EE0B7 /* AppDelegate.swift in Sources */, EEC733A15D5F02DEBF8363A6 /* Config.swift in Sources */, 63CEC8FC59C233CCE147F3E5 /* LensReceiver.swift in Sources */, + C42B1C4E8257439E0DFB63F1 /* Scenario.swift in Sources */, 6727ECB29D2D4568413C9146 /* ViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -359,6 +366,11 @@ package = C9E8062C083781BFD2E012E1 /* XCRemoteSwiftPackageReference "optiview-lens-sdk-swift" */; productName = LensSDK; }; + FFB346B2F06C0F4AC773535B /* LensSDKDev */ = { + isa = XCSwiftPackageProductDependency; + package = C9E8062C083781BFD2E012E1 /* XCRemoteSwiftPackageReference "optiview-lens-sdk-swift" */; + productName = LensSDKDev; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 19997A7BD2AB255E760C3EB9 /* Project object */; diff --git a/Code/Lens-Examples/LensExample/LensReceiver.swift b/Code/Lens-Examples/LensExample/LensReceiver.swift index 88c337bc..380620e5 100644 --- a/Code/Lens-Examples/LensExample/LensReceiver.swift +++ b/Code/Lens-Examples/LensExample/LensReceiver.swift @@ -4,21 +4,38 @@ // import Foundation +import LensSDKDev -/// A Lens destination inside the app, to show what Lens sends: it answers the requests to `url` itself, which Lens -/// posts with the shared URL session. +/// Lens posts its reports with the shared URL session; this answers some of them inside the app: +/// - the reports to `url`, a JSON destination, are shown in the app; +/// - the reports to the staging ingest go out only while `forwardsToStaging` is on, and are answered here otherwise. final class LensReceiver: URLProtocol { static let url = "https://receiver.lens-example.invalid/reports" + static let stagingUrl = LensDev.developmentIngestUrl /// Called on the main queue with every report received, as a JSON object. static var onReport: (([String: Any]) -> Void)? + /// Called on the main queue after every post to the staging ingest: the HTTP status when it was sent, nil when it + /// was answered here. + static var onStagingPost: ((_ status: Int?) -> Void)? + + private static let lock = NSLock() + private static var _forwardsToStaging = false + static var forwardsToStaging: Bool { + get { lock.lock(); defer { lock.unlock() }; return _forwardsToStaging } + set { lock.lock(); defer { lock.unlock() }; _forwardsToStaging = newValue } + } + + private static let forwardedKey = "LensExampleForwarded" + private var forwarding: URLSessionDataTask? static func start() { URLProtocol.registerClass(LensReceiver.self) } override class func canInit(with request: URLRequest) -> Bool { - request.url?.absoluteString == url + guard property(forKey: forwardedKey, in: request) == nil else { return false } + return [url, stagingUrl].contains(request.url?.absoluteString) } override class func canonicalRequest(for request: URLRequest) -> URLRequest { @@ -27,16 +44,47 @@ final class LensReceiver: URLProtocol { override func startLoading() { let body = request.httpBody ?? request.httpBodyStream.map(Self.read) ?? Data() - let reports = (try? JSONSerialization.jsonObject(with: body) as? [String: Any])?["reports"] as? [[String: Any]] ?? [] - DispatchQueue.main.async { - reports.forEach { Self.onReport?($0) } + if request.url?.absoluteString == Self.url { + let reports = (try? JSONSerialization.jsonObject(with: body) as? [String: Any])?["reports"] as? [[String: Any]] ?? [] + DispatchQueue.main.async { reports.forEach { Self.onReport?($0) } } + answerLocally() + } else if Self.forwardsToStaging { + forward(body) + } else { + DispatchQueue.main.async { Self.onStagingPost?(nil) } + answerLocally() } + } + + override func stopLoading() { + forwarding?.cancel() + } + + private func answerLocally() { let response = HTTPURLResponse(url: request.url!, statusCode: 204, httpVersion: "HTTP/1.1", headerFields: nil)! client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocolDidFinishLoading(self) } - override func stopLoading() {} + private func forward(_ body: Data) { + let outgoing = (request as NSURLRequest).mutableCopy() as! NSMutableURLRequest + outgoing.httpBody = body + outgoing.httpBodyStream = nil + URLProtocol.setProperty(true, forKey: Self.forwardedKey, in: outgoing) + forwarding = URLSession.shared.dataTask(with: outgoing as URLRequest) { [weak self] data, response, error in + guard let self else { return } + let status = (response as? HTTPURLResponse)?.statusCode + DispatchQueue.main.async { Self.onStagingPost?(status ?? -1) } + if let response { + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + data.map { self.client?.urlProtocol(self, didLoad: $0) } + self.client?.urlProtocolDidFinishLoading(self) + } else { + self.client?.urlProtocol(self, didFailWithError: error ?? URLError(.unknown)) + } + } + forwarding?.resume() + } private static func read(_ stream: InputStream) -> Data { var data = Data() diff --git a/Code/Lens-Examples/LensExample/Scenario.swift b/Code/Lens-Examples/LensExample/Scenario.swift new file mode 100644 index 00000000..50e10cf2 --- /dev/null +++ b/Code/Lens-Examples/LensExample/Scenario.swift @@ -0,0 +1,128 @@ +// +// Scenario.swift +// LensExample +// + +import Foundation +import THEOplayerSDK + +/// The same playback on every platform's Lens demo, so their reports can be compared in the backend. Keep the steps +/// and timings in line with the other platforms (see README). +@MainActor +final class Scenario { + /// The content ID the scenario's source is reported with, to find its sessions in the backend. + static let contentId = "lens-connector-scenario" + static let src = "https://cdn.theoplayer.com/video/big_buck_bunny/big_buck_bunny.m3u8" + + enum Failure: Error, CustomStringConvertible { + case timeout(String) + var description: String { + switch self { + case .timeout(let event): return "no \(event) in time" + } + } + } + + /// The source of the current run; the connector reports it with `contentId`. + private(set) var source: SourceDescription? + /// Called on every step, and once more when the run is over. + var onStep: ((String) -> Void)? + var isRunning: Bool { task != nil } + + private let player: THEOplayer + private var task: Task? + + init(player: THEOplayer) { + self.player = player + } + + func run() { + guard task == nil else { return } + task = Task { [weak self] in + guard let self else { return } + let outcome: String + do { + try await self.steps() + outcome = "Scenario done" + } catch is CancellationError { + outcome = "Scenario stopped" + } catch { + outcome = "Scenario failed: \(error)" + } + self.restore() + self.task = nil + self.onStep?(outcome) + } + } + + func stop() { + task?.cancel() + } + + private func steps() async throws { + restore() + step("1/10 Load Big Buck Bunny") + // A new description, so each run is a new source and a new session. + let source = SourceDescription(source: TypedSource(src: Self.src, type: "application/x-mpegurl")) + self.source = source + try await next(PlayerEventTypes.SOURCE_CHANGE) { player.source = source } + step("2/10 Play for 10 s") + try await next(PlayerEventTypes.PLAYING) { player.play() } + try await wait(10) + step("3/10 Pause for 3 s") + try await next(PlayerEventTypes.PAUSE) { player.pause() } + try await wait(3) + step("4/10 Play for 5 s") + try await next(PlayerEventTypes.PLAYING) { player.play() } + try await wait(5) + step("5/10 Seek to 2:00, play for 5 s") + try await next(PlayerEventTypes.SEEKED) { player.currentTime = 120 } + try await wait(5) + step("6/10 Mute for 3 s") + player.muted = true + try await wait(3) + player.muted = false + step("7/10 Play at 2× for 5 s") + player.playbackRate = 2 + try await wait(5) + player.playbackRate = 1 + step("8/10 Fullscreen for 5 s") + try await next(PlayerEventTypes.PRESENTATION_MODE_CHANGE) { player.presentationMode = .fullscreen } + try await wait(5) + try await next(PlayerEventTypes.PRESENTATION_MODE_CHANGE) { player.presentationMode = .inline } + step("9/10 Seek to 5 s before the end, play to the end") + let duration = player.duration ?? 0 + try await next(PlayerEventTypes.SEEKED) { player.currentTime = max(duration - 5, 0) } + try await next(PlayerEventTypes.ENDED, timeout: 30) {} + try await wait(3) + step("10/10 Unload the source") + try await next(PlayerEventTypes.SOURCE_CHANGE) { player.stop() } + } + + private func restore() { + player.muted = false + player.playbackRate = 1 + player.presentationMode = .inline + } + + private func step(_ name: String) { + onStep?(name) + } + + private func wait(_ seconds: Double) async throws { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } + + /// Runs `action`, then waits for the player's next event of `type`. + private func next(_ type: EventType, timeout: Double = 20, after action: () -> Void) async throws { + var fired = false + let listener = player.addEventListener(type: type) { _ in fired = true } + defer { player.removeEventListener(type: type, listener: listener) } + action() + let deadline = Date().addingTimeInterval(timeout) + while !fired { + guard Date() < deadline else { throw Failure.timeout(type.name) } + try await Task.sleep(nanoseconds: 100_000_000) + } + } +} diff --git a/Code/Lens-Examples/LensExample/ViewController.swift b/Code/Lens-Examples/LensExample/ViewController.swift index c7d7b764..f634fe3a 100644 --- a/Code/Lens-Examples/LensExample/ViewController.swift +++ b/Code/Lens-Examples/LensExample/ViewController.swift @@ -4,21 +4,36 @@ // import LensSDK +import LensSDKDev import THEOplayerConnectorLens import THEOplayerSDK import UIKit +/// Launch arguments, e.g. `-sendToStaging YES -runScenario YES`; the staging switch is also remembered between runs. +private enum Defaults { + static let sendToStaging = "sendToStaging" + static let runScenario = "runScenario" +} + class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { private let player: THEOplayer = { let configuration = THEOplayerConfigurationBuilder() configuration.license = theoplayerLicense return THEOplayer(configuration: configuration.build()) }() + private lazy var scenario = Scenario(player: player) private var lensConnector: LensConnector? private let playerContainer = UIView() private let sessionLabel = UILabel() + private let scenarioLabel = UILabel() + private let stagingLabel = UILabel() + private let stagingSwitch = UISwitch() + private let scenarioButton = UIButton(configuration: .filled()) private let reportsTable = UITableView(frame: .zero, style: .plain) private var reports: [[String: Any]] = [] + private var stagingSent = 0 + private var stagingAnswered = 0 + private var stagingLastStatus: Int? private var sessionTimer: Timer? override func viewDidLoad() { @@ -27,16 +42,27 @@ class ViewController: UIViewController, UITableViewDataSource, UITableViewDelega layout() player.addAsSubview(of: playerContainer) + stagingSwitch.isOn = UserDefaults.standard.bool(forKey: Defaults.sendToStaging) + LensReceiver.forwardsToStaging = stagingSwitch.isOn LensReceiver.onReport = { [weak self] in self?.received($0) } + LensReceiver.onStagingPost = { [weak self] in self?.stagingPosted($0) } LensReceiver.start() + showStaging() + scenario.onStep = { [weak self] in self?.scenarioStepped($0) } + let destinations = ([LensReceiver.url] + [lensDestinationUrl].compactMap { $0 }).map { LensDestination(url: $0, format: .json, heartbeat: true) } do { - try Lens.configure(LensConfigureOptions(backend: .disabled)) + // Lens reports to the staging ingest, which the app forwards to only while the switch is on. + try LensDev.enableDevelopmentBackend() lensConnector = try LensConnector( player: player, - configuration: LensConfiguration(destinations: destinations, heartbeatMs: 10_000) + configuration: LensConfiguration( + destinations: destinations, + heartbeatMs: 10_000, + contentId: { [weak self] in $0 === self?.scenario.source ? Scenario.contentId : nil } + ) ) } catch { sessionLabel.text = "Lens: \(error)" @@ -46,6 +72,13 @@ class ViewController: UIViewController, UITableViewDataSource, UITableViewDelega } } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + if UserDefaults.standard.bool(forKey: Defaults.runScenario), !scenario.isRunning { + scenario.run() + } + } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() player.frame = playerContainer.bounds @@ -78,6 +111,38 @@ class ViewController: UIViewController, UITableViewDataSource, UITableViewDelega sessionLabel.text = lensConnector.handle.session.map { "Lens session \($0.sessionId)" } ?? "No Lens session: load a source and play" } + // MARK: Scenario and staging + + private func toggleScenario() { + scenario.isRunning ? scenario.stop() : scenario.run() + } + + private func scenarioStepped(_ step: String) { + scenarioLabel.text = step + scenarioButton.configuration?.title = scenario.isRunning ? "Stop scenario" : "Run scenario" + } + + private func stagingToggled() { + LensReceiver.forwardsToStaging = stagingSwitch.isOn + UserDefaults.standard.set(stagingSwitch.isOn, forKey: Defaults.sendToStaging) + showStaging() + } + + private func stagingPosted(_ status: Int?) { + if let status { + stagingSent += 1 + stagingLastStatus = status + } else { + stagingAnswered += 1 + } + showStaging() + } + + private func showStaging() { + let last = stagingLastStatus.map { $0 < 0 ? ", last failed" : ", last HTTP \($0)" } ?? "" + stagingLabel.text = "Send to staging (\(stagingSent) sent\(last), \(stagingAnswered) kept in app)" + } + // MARK: Reports private func received(_ report: [String: Any]) { @@ -154,6 +219,21 @@ class ViewController: UIViewController, UITableViewDataSource, UITableViewDelega ]), ] + scenarioButton.setTitle("Run scenario", for: .normal) + scenarioButton.addAction(UIAction { [weak self] _ in self?.toggleScenario() }, for: .primaryActionTriggered) + scenarioButton.setContentHuggingPriority(.required, for: .horizontal) + scenarioLabel.text = "Scenario: Big Buck Bunny, about 1 minute" + scenarioLabel.font = .preferredFont(forTextStyle: .footnote) + scenarioLabel.numberOfLines = 2 + let scenarioRow = UIStackView(arrangedSubviews: [scenarioButton, scenarioLabel]) + scenarioRow.spacing = 8 + stagingSwitch.addAction(UIAction { [weak self] _ in self?.stagingToggled() }, for: .valueChanged) + stagingLabel.font = .preferredFont(forTextStyle: .footnote) + stagingLabel.numberOfLines = 2 + let stagingRow = UIStackView(arrangedSubviews: [stagingSwitch, stagingLabel]) + stagingRow.spacing = 8 + stagingRow.alignment = .center + sessionLabel.font = .monospacedSystemFont(ofSize: 12, weight: .regular) sessionLabel.numberOfLines = 0 let reportsHeader = UILabel() @@ -170,7 +250,7 @@ class ViewController: UIViewController, UITableViewDataSource, UITableViewDelega reportsTable.delegate = self playerContainer.backgroundColor = .black - let stack = UIStackView(arrangedSubviews: [playerContainer] + controls + [sessionLabel, header, reportsTable]) + let stack = UIStackView(arrangedSubviews: [playerContainer] + controls + [scenarioRow, stagingRow, sessionLabel, header, reportsTable]) stack.axis = .vertical stack.spacing = 8 stack.setCustomSpacing(12, after: playerContainer) diff --git a/Code/Lens-Examples/README.md b/Code/Lens-Examples/README.md index 6a24dd7d..89ecdb1c 100644 --- a/Code/Lens-Examples/README.md +++ b/Code/Lens-Examples/README.md @@ -6,12 +6,40 @@ Open the project, pick an iPhone simulator or device, and run. The app: - loads one of a few example streams, or a URL of your own (`Load a source`), - plays, pauses, stops, seeks, mutes, changes the rate and goes fullscreen, which Lens reports as state changes, `m`/`um`, `pr` and `pe`/`pc`, +- runs a fixed scenario (`Run scenario`, below), - shows the current Lens session, which starts at the first play request on a source, - lists every report Lens sends, newest first, with its namespace, event, playback state and main keys. Tap a report for all its keys. -The reports are captured inside the app: the example disables the OptiView backend and adds a destination that the app answers itself. Heartbeats are sent every 10 seconds. +## Where the reports go -In `LensExample/Config.swift`, you can set: +The reports are captured inside the app, without a server: Lens sends them to a JSON destination that the app answers itself, and shows them in the list. Heartbeats are sent every 10 seconds. + +The example also enables the Lens development backend (the `LensSDKDev` product), which sends the reports to the staging ingest. The app sends them on only while **Send to staging** is on, and answers them itself otherwise. Next to the switch, the app counts the reports it sent, with the last HTTP status, and the ones it kept. The switch is remembered between launches. + +In `LensExample/Config.swift`, you can also set: - `theoplayerLicense`: your THEOplayer license, needed for streams of your own. The example streams play without one. - `lensDestinationUrl`: an endpoint that also receives the reports as JSON, such as a request inspector. + +## The scenario + +The scenario is the same on every platform's Lens demo, so that their reports can be compared in the backend. It takes about a minute, and its source is reported with the content ID `lens-connector-scenario`: + +1. Load Big Buck Bunny (`https://cdn.theoplayer.com/video/big_buck_bunny/big_buck_bunny.m3u8`), as a new source. +2. Play, and play for 10 s once playing. +3. Pause for 3 s. +4. Play for 5 s. +5. Seek to 2:00, and play for 5 s. +6. Mute for 3 s, then unmute. +7. Play at rate 2 for 5 s, then at rate 1. +8. Go fullscreen, stay 5 s once fullscreen, then go back inline and wait until inline. +9. Seek to 5 s before the end, play to the end, and wait 3 s. +10. Unload the source. + +Lens reports `sta=e` at the end, and no quit report after it: the session is over once the content has ended. + +To run the scenario unattended, and send it to staging, launch the app with arguments, for example on a booted simulator: + +```sh +xcrun simctl launch booted com.theoplayer.connector.LensExample -runScenario YES -sendToStaging YES +``` From 585226b4e1ac7cc62548f943274b9375ec8d905f Mon Sep 17 00:00:00 2001 From: Gilles Moris Date: Fri, 6 Nov 2026 16:06:16 +0100 Subject: [PATCH 10/14] Align the Lens example scenario with the aggregate cross-platform scenario Adds the 2 s settles after unmute, rate 1 and inline, and waits 2 s instead of 3 s after the end, as agreed for all platforms. Fullscreen stands for the picture-in-picture or fullscreen step. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Code/Lens-Examples/LensExample/Scenario.swift | 24 ++++++++++--------- Code/Lens-Examples/README.md | 17 ++++++------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/Code/Lens-Examples/LensExample/Scenario.swift b/Code/Lens-Examples/LensExample/Scenario.swift index 50e10cf2..6998761c 100644 --- a/Code/Lens-Examples/LensExample/Scenario.swift +++ b/Code/Lens-Examples/LensExample/Scenario.swift @@ -61,41 +61,43 @@ final class Scenario { private func steps() async throws { restore() - step("1/10 Load Big Buck Bunny") + step("1/9 Load Big Buck Bunny") // A new description, so each run is a new source and a new session. let source = SourceDescription(source: TypedSource(src: Self.src, type: "application/x-mpegurl")) self.source = source try await next(PlayerEventTypes.SOURCE_CHANGE) { player.source = source } - step("2/10 Play for 10 s") + step("2/9 Play for 10 s") try await next(PlayerEventTypes.PLAYING) { player.play() } try await wait(10) - step("3/10 Pause for 3 s") + step("3/9 Pause for 3 s") try await next(PlayerEventTypes.PAUSE) { player.pause() } try await wait(3) - step("4/10 Play for 5 s") + step("4/9 Play for 5 s") try await next(PlayerEventTypes.PLAYING) { player.play() } try await wait(5) - step("5/10 Seek to 2:00, play for 5 s") + step("5/9 Seek to 120 s, play for 5 s") try await next(PlayerEventTypes.SEEKED) { player.currentTime = 120 } try await wait(5) - step("6/10 Mute for 3 s") + step("6/9 Mute for 3 s, unmute for 2 s") player.muted = true try await wait(3) player.muted = false - step("7/10 Play at 2× for 5 s") + try await wait(2) + step("7/9 Rate 2× for 5 s, 1× for 2 s") player.playbackRate = 2 try await wait(5) player.playbackRate = 1 - step("8/10 Fullscreen for 5 s") + try await wait(2) + step("8/9 Fullscreen for 5 s, inline for 2 s") try await next(PlayerEventTypes.PRESENTATION_MODE_CHANGE) { player.presentationMode = .fullscreen } try await wait(5) try await next(PlayerEventTypes.PRESENTATION_MODE_CHANGE) { player.presentationMode = .inline } - step("9/10 Seek to 5 s before the end, play to the end") + try await wait(2) + step("9/9 Seek to 5 s before the end, play to the end, wait 2 s, unload") let duration = player.duration ?? 0 try await next(PlayerEventTypes.SEEKED) { player.currentTime = max(duration - 5, 0) } try await next(PlayerEventTypes.ENDED, timeout: 30) {} - try await wait(3) - step("10/10 Unload the source") + try await wait(2) try await next(PlayerEventTypes.SOURCE_CHANGE) { player.stop() } } diff --git a/Code/Lens-Examples/README.md b/Code/Lens-Examples/README.md index 89ecdb1c..0a62fe97 100644 --- a/Code/Lens-Examples/README.md +++ b/Code/Lens-Examples/README.md @@ -26,19 +26,20 @@ In `LensExample/Config.swift`, you can also set: The scenario is the same on every platform's Lens demo, so that their reports can be compared in the backend. It takes about a minute, and its source is reported with the content ID `lens-connector-scenario`: 1. Load Big Buck Bunny (`https://cdn.theoplayer.com/video/big_buck_bunny/big_buck_bunny.m3u8`), as a new source. -2. Play, and play for 10 s once playing. +2. Play for 10 s. 3. Pause for 3 s. 4. Play for 5 s. -5. Seek to 2:00, and play for 5 s. -6. Mute for 3 s, then unmute. -7. Play at rate 2 for 5 s, then at rate 1. -8. Go fullscreen, stay 5 s once fullscreen, then go back inline and wait until inline. -9. Seek to 5 s before the end, play to the end, and wait 3 s. -10. Unload the source. +5. Seek to 120 s, play for 5 s. +6. Mute for 3 s, then unmute for 2 s. +7. Play at rate 2 for 5 s, then at rate 1 for 2 s. +8. Go picture-in-picture or fullscreen for 5 s, then inline for 2 s. This demo goes fullscreen. +9. Seek to 5 s before the end, play to the end, wait 2 s, and unload the source. + +Steps that change the player (play, pause, seek, presentation mode) count their time from when the player has made the change. Lens reports `sta=e` at the end, and no quit report after it: the session is over once the content has ended. -To run the scenario unattended, and send it to staging, launch the app with arguments, for example on a booted simulator: +Press **Run scenario** to run it, with **Send to staging** on or off; press it again to stop. To run it unattended, launch the app with arguments, for example on a booted simulator: ```sh xcrun simctl launch booted com.theoplayer.connector.LensExample -runScenario YES -sendToStaging YES From 40e5784bff4044b0d76860c0c5a3849e7a8b2a30 Mon Sep 17 00:00:00 2001 From: Gilles Moris Date: Fri, 6 Nov 2026 16:16:01 +0100 Subject: [PATCH 11/14] Use picture-in-picture and the shared content ID in the Lens scenario The scenario now goes into the player's in-app picture-in-picture, as the web demo uses the in-page one, and reports the content ID lens-connector-demo-scenario like the other platforms. The README spells out the timing choices every platform makes. The demo gets a PiP button. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Code/Lens-Examples/LensExample/Scenario.swift | 6 +++--- Code/Lens-Examples/LensExample/ViewController.swift | 10 ++++++++++ Code/Lens-Examples/README.md | 12 ++++++++---- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/Code/Lens-Examples/LensExample/Scenario.swift b/Code/Lens-Examples/LensExample/Scenario.swift index 6998761c..521e6203 100644 --- a/Code/Lens-Examples/LensExample/Scenario.swift +++ b/Code/Lens-Examples/LensExample/Scenario.swift @@ -11,7 +11,7 @@ import THEOplayerSDK @MainActor final class Scenario { /// The content ID the scenario's source is reported with, to find its sessions in the backend. - static let contentId = "lens-connector-scenario" + static let contentId = "lens-connector-demo-scenario" static let src = "https://cdn.theoplayer.com/video/big_buck_bunny/big_buck_bunny.m3u8" enum Failure: Error, CustomStringConvertible { @@ -88,8 +88,8 @@ final class Scenario { try await wait(5) player.playbackRate = 1 try await wait(2) - step("8/9 Fullscreen for 5 s, inline for 2 s") - try await next(PlayerEventTypes.PRESENTATION_MODE_CHANGE) { player.presentationMode = .fullscreen } + step("8/9 Picture-in-picture for 5 s, inline for 2 s") + try await next(PlayerEventTypes.PRESENTATION_MODE_CHANGE) { player.presentationMode = .pictureInPicture } try await wait(5) try await next(PlayerEventTypes.PRESENTATION_MODE_CHANGE) { player.presentationMode = .inline } try await wait(2) diff --git a/Code/Lens-Examples/LensExample/ViewController.swift b/Code/Lens-Examples/LensExample/ViewController.swift index f634fe3a..d09c8044 100644 --- a/Code/Lens-Examples/LensExample/ViewController.swift +++ b/Code/Lens-Examples/LensExample/ViewController.swift @@ -19,6 +19,10 @@ class ViewController: UIViewController, UITableViewDataSource, UITableViewDelega private let player: THEOplayer = { let configuration = THEOplayerConfigurationBuilder() configuration.license = theoplayerLicense + // The player's in-app picture-in-picture, as the in-page one of the web demo, not the system's. + let pip = PiPConfigurationBuilder() + pip.nativePictureInPicture = false + configuration.pip = pip.build() return THEOplayer(configuration: configuration.build()) }() private lazy var scenario = Scenario(player: player) @@ -215,7 +219,13 @@ class ViewController: UIViewController, UITableViewDataSource, UITableViewDelega player.playbackRate = player.playbackRate == 1 ? 2 : 1 sender.configuration?.title = player.playbackRate == 1 ? "Rate 2×" : "Rate 1×" }, + ]), + row([ button("Fullscreen") { [weak self] _ in self?.player.presentationMode = .fullscreen }, + button("PiP") { [weak self] _ in + guard let player = self?.player else { return } + player.presentationMode = player.presentationMode == .pictureInPicture ? .inline : .pictureInPicture + }, ]), ] diff --git a/Code/Lens-Examples/README.md b/Code/Lens-Examples/README.md index 0a62fe97..298bb2ff 100644 --- a/Code/Lens-Examples/README.md +++ b/Code/Lens-Examples/README.md @@ -5,7 +5,7 @@ Open the project, pick an iPhone simulator or device, and run. The app: - loads one of a few example streams, or a URL of your own (`Load a source`), -- plays, pauses, stops, seeks, mutes, changes the rate and goes fullscreen, which Lens reports as state changes, `m`/`um`, `pr` and `pe`/`pc`, +- plays, pauses, stops, seeks, mutes, changes the rate and goes fullscreen or picture-in-picture, which Lens reports as state changes, `m`/`um`, `pr` and `pe`/`pc`, - runs a fixed scenario (`Run scenario`, below), - shows the current Lens session, which starts at the first play request on a source, - lists every report Lens sends, newest first, with its namespace, event, playback state and main keys. Tap a report for all its keys. @@ -23,7 +23,7 @@ In `LensExample/Config.swift`, you can also set: ## The scenario -The scenario is the same on every platform's Lens demo, so that their reports can be compared in the backend. It takes about a minute, and its source is reported with the content ID `lens-connector-scenario`: +The scenario is the same on every platform's Lens demo, so that their reports can be compared in the backend. It takes about a minute, and its source is reported with the content ID `lens-connector-demo-scenario`: 1. Load Big Buck Bunny (`https://cdn.theoplayer.com/video/big_buck_bunny/big_buck_bunny.m3u8`), as a new source. 2. Play for 10 s. @@ -32,10 +32,14 @@ The scenario is the same on every platform's Lens demo, so that their reports ca 5. Seek to 120 s, play for 5 s. 6. Mute for 3 s, then unmute for 2 s. 7. Play at rate 2 for 5 s, then at rate 1 for 2 s. -8. Go picture-in-picture or fullscreen for 5 s, then inline for 2 s. This demo goes fullscreen. +8. Go picture-in-picture for 5 s, then inline for 2 s. 9. Seek to 5 s before the end, play to the end, wait 2 s, and unload the source. -Steps that change the player (play, pause, seek, presentation mode) count their time from when the player has made the change. +Every platform makes the same choices behind these timings: + +- A play step counts from when playback starts (`playing`), not from the play request, so a slow startup does not shorten it. +- A seek step waits for the seek to complete (`seeked`) before counting. +- Picture-in-picture is the player's own: the in-page one on web, and here THEOplayer's in-app one (`nativePictureInPicture` off), not the system's. A presentation step counts from when the player has changed mode. Lens reports `sta=e` at the end, and no quit report after it: the session is over once the content has ended. From 894c545a5c101a3d3dc41902dbfaafc289cfd207 Mon Sep 17 00:00:00 2001 From: Gilles Moris Date: Fri, 6 Nov 2026 16:29:32 +0100 Subject: [PATCH 12/14] Let the Lens example send to a destination of your own, set in the app Destination... takes a URL and a format, CMCD or JSON, remembers them, and joins Lens again with that destination. The app counts what it sent there, with the last HTTP status, and allows plain http endpoints. This replaces the constant in Config.swift. Forwarded posts to staging went out with an empty body: setting the body stream to nil after the body cleared the body. They now carry the reports. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../LensExample.xcodeproj/project.pbxproj | 4 + Code/Lens-Examples/LensExample/Config.swift | 3 - Code/Lens-Examples/LensExample/Info.plist | 11 ++ .../LensExample/LensReceiver.swift | 32 +++-- .../LensExample/ViewController.swift | 117 ++++++++++++++++-- Code/Lens-Examples/README.md | 7 +- 6 files changed, 145 insertions(+), 29 deletions(-) create mode 100644 Code/Lens-Examples/LensExample/Info.plist diff --git a/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj b/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj index ba148fcf..40cd3951 100644 --- a/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj +++ b/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ /* Begin PBXFileReference section */ 0595736CB1C4E25C161BCAF8 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 0FD35DE090C16D1460BC0759 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 4A1B464FAA8A8AC17E8BE700 /* LensExample.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = LensExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 6401D44393B218039D887E70 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 75D78A81E1A9C50211B30664 /* Lens-iOS-connector */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "Lens-iOS-connector"; path = ../..; sourceTree = SOURCE_ROOT; }; @@ -62,6 +63,7 @@ children = ( 0595736CB1C4E25C161BCAF8 /* AppDelegate.swift */, C8753DD4AC5F3E283A269E22 /* Config.swift */, + 0FD35DE090C16D1460BC0759 /* Info.plist */, C7E6CDD3D598D4ABECAA0102 /* LensReceiver.swift */, D612104EA5996CDD02E63B42 /* Scenario.swift */, 6401D44393B218039D887E70 /* ViewController.swift */, @@ -159,6 +161,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LensExample/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; @@ -300,6 +303,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LensExample/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; diff --git a/Code/Lens-Examples/LensExample/Config.swift b/Code/Lens-Examples/LensExample/Config.swift index 6f8df7dd..a51965d5 100644 --- a/Code/Lens-Examples/LensExample/Config.swift +++ b/Code/Lens-Examples/LensExample/Config.swift @@ -6,9 +6,6 @@ /// Your THEOplayer license, from the OptiView portal. The example streams play without one. let theoplayerLicense: String? = nil -/// An endpoint that also receives the Lens reports as JSON, such as a request inspector you can watch. -let lensDestinationUrl: String? = nil - let exampleSources: [(title: String, src: String)] = [ ("Big Buck Bunny (HLS VOD)", "https://cdn.theoplayer.com/video/big_buck_bunny/big_buck_bunny.m3u8"), ("Elephants Dream (HLS VOD)", "https://cdn.theoplayer.com/video/elephants-dream/playlist-no-subtitles.m3u8"), diff --git a/Code/Lens-Examples/LensExample/Info.plist b/Code/Lens-Examples/LensExample/Info.plist new file mode 100644 index 00000000..6a6654d9 --- /dev/null +++ b/Code/Lens-Examples/LensExample/Info.plist @@ -0,0 +1,11 @@ + + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/Code/Lens-Examples/LensExample/LensReceiver.swift b/Code/Lens-Examples/LensExample/LensReceiver.swift index 380620e5..68ce3fb9 100644 --- a/Code/Lens-Examples/LensExample/LensReceiver.swift +++ b/Code/Lens-Examples/LensExample/LensReceiver.swift @@ -6,25 +6,33 @@ import Foundation import LensSDKDev -/// Lens posts its reports with the shared URL session; this answers some of them inside the app: +/// Lens posts its reports with the shared URL session; this handles some of them inside the app: /// - the reports to `url`, a JSON destination, are shown in the app; -/// - the reports to the staging ingest go out only while `forwardsToStaging` is on, and are answered here otherwise. +/// - the reports to the staging ingest go out only while `forwardsToStaging` is on, and are answered here otherwise; +/// - the reports to `extraUrl`, a destination of your own, go out, and their HTTP status is shown. final class LensReceiver: URLProtocol { static let url = "https://receiver.lens-example.invalid/reports" static let stagingUrl = LensDev.developmentIngestUrl /// Called on the main queue with every report received, as a JSON object. static var onReport: (([String: Any]) -> Void)? - /// Called on the main queue after every post to the staging ingest: the HTTP status when it was sent, nil when it - /// was answered here. + /// Called on the main queue after every post to the staging ingest: the HTTP status when it was sent, -1 when it + /// failed, nil when it was answered here. static var onStagingPost: ((_ status: Int?) -> Void)? + /// Called on the main queue after every post to `extraUrl`: the HTTP status, -1 when it failed. + static var onExtraPost: ((_ status: Int) -> Void)? private static let lock = NSLock() private static var _forwardsToStaging = false + private static var _extraUrl: String? static var forwardsToStaging: Bool { get { lock.lock(); defer { lock.unlock() }; return _forwardsToStaging } set { lock.lock(); defer { lock.unlock() }; _forwardsToStaging = newValue } } + static var extraUrl: String? { + get { lock.lock(); defer { lock.unlock() }; return _extraUrl } + set { lock.lock(); defer { lock.unlock() }; _extraUrl = newValue } + } private static let forwardedKey = "LensExampleForwarded" private var forwarding: URLSessionDataTask? @@ -35,7 +43,7 @@ final class LensReceiver: URLProtocol { override class func canInit(with request: URLRequest) -> Bool { guard property(forKey: forwardedKey, in: request) == nil else { return false } - return [url, stagingUrl].contains(request.url?.absoluteString) + return [url, stagingUrl, extraUrl].contains(request.url?.absoluteString) } override class func canonicalRequest(for request: URLRequest) -> URLRequest { @@ -44,12 +52,15 @@ final class LensReceiver: URLProtocol { override func startLoading() { let body = request.httpBody ?? request.httpBodyStream.map(Self.read) ?? Data() - if request.url?.absoluteString == Self.url { + let target = request.url?.absoluteString + if target == Self.url { let reports = (try? JSONSerialization.jsonObject(with: body) as? [String: Any])?["reports"] as? [[String: Any]] ?? [] DispatchQueue.main.async { reports.forEach { Self.onReport?($0) } } answerLocally() + } else if target == Self.extraUrl { + forward(body) { status in Self.onExtraPost?(status) } } else if Self.forwardsToStaging { - forward(body) + forward(body) { status in Self.onStagingPost?(status) } } else { DispatchQueue.main.async { Self.onStagingPost?(nil) } answerLocally() @@ -66,15 +77,16 @@ final class LensReceiver: URLProtocol { client?.urlProtocolDidFinishLoading(self) } - private func forward(_ body: Data) { + /// Sends the request on, then calls `posted` on the main queue with the HTTP status, -1 when it failed. + private func forward(_ body: Data, posted: @escaping (Int) -> Void) { let outgoing = (request as NSURLRequest).mutableCopy() as! NSMutableURLRequest + // Replaces the body stream, which URLSession hands protocols instead of the body. outgoing.httpBody = body - outgoing.httpBodyStream = nil URLProtocol.setProperty(true, forKey: Self.forwardedKey, in: outgoing) forwarding = URLSession.shared.dataTask(with: outgoing as URLRequest) { [weak self] data, response, error in guard let self else { return } let status = (response as? HTTPURLResponse)?.statusCode - DispatchQueue.main.async { Self.onStagingPost?(status ?? -1) } + DispatchQueue.main.async { posted(status ?? -1) } if let response { self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) data.map { self.client?.urlProtocol(self, didLoad: $0) } diff --git a/Code/Lens-Examples/LensExample/ViewController.swift b/Code/Lens-Examples/LensExample/ViewController.swift index d09c8044..225dd06c 100644 --- a/Code/Lens-Examples/LensExample/ViewController.swift +++ b/Code/Lens-Examples/LensExample/ViewController.swift @@ -9,10 +9,14 @@ import THEOplayerConnectorLens import THEOplayerSDK import UIKit -/// Launch arguments, e.g. `-sendToStaging YES -runScenario YES`; the staging switch is also remembered between runs. +/// Settings the app remembers, which launch arguments also set, e.g. `-sendToStaging YES -runScenario YES` +/// or `-destinationUrl https://… -destinationFormat cmcd`. private enum Defaults { static let sendToStaging = "sendToStaging" static let runScenario = "runScenario" + static let destinationUrl = "destinationUrl" + /// `cmcd` (the default) or `json`. + static let destinationFormat = "destinationFormat" } class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { @@ -32,6 +36,9 @@ class ViewController: UIViewController, UITableViewDataSource, UITableViewDelega private let scenarioLabel = UILabel() private let stagingLabel = UILabel() private let stagingSwitch = UISwitch() + private let destinationLabel = UILabel() + private var destinationSent = 0 + private var destinationLastStatus: Int? private let scenarioButton = UIButton(configuration: .filled()) private let reportsTable = UITableView(frame: .zero, style: .plain) private var reports: [[String: Any]] = [] @@ -50,27 +57,18 @@ class ViewController: UIViewController, UITableViewDataSource, UITableViewDelega LensReceiver.forwardsToStaging = stagingSwitch.isOn LensReceiver.onReport = { [weak self] in self?.received($0) } LensReceiver.onStagingPost = { [weak self] in self?.stagingPosted($0) } + LensReceiver.onExtraPost = { [weak self] in self?.destinationPosted($0) } LensReceiver.start() showStaging() scenario.onStep = { [weak self] in self?.scenarioStepped($0) } - let destinations = ([LensReceiver.url] + [lensDestinationUrl].compactMap { $0 }).map { - LensDestination(url: $0, format: .json, heartbeat: true) - } do { // Lens reports to the staging ingest, which the app forwards to only while the switch is on. try LensDev.enableDevelopmentBackend() - lensConnector = try LensConnector( - player: player, - configuration: LensConfiguration( - destinations: destinations, - heartbeatMs: 10_000, - contentId: { [weak self] in $0 === self?.scenario.source ? Scenario.contentId : nil } - ) - ) } catch { sessionLabel.text = "Lens: \(error)" } + connect() sessionTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in self?.showSession() } @@ -93,6 +91,91 @@ class ViewController: UIViewController, UITableViewDataSource, UITableViewDelega lensConnector?.destroy() } + /// Joins Lens with the in-app receiver and the destination of your own, if any. Destinations are set at join, so + /// changing yours joins again, which ends the current session. + private func connect() { + lensConnector?.destroy() + lensConnector = nil + let defaults = UserDefaults.standard + let extraUrl = defaults.string(forKey: Defaults.destinationUrl).flatMap { $0.isEmpty ? nil : $0 } + let extraFormat: LensFormat = defaults.string(forKey: Defaults.destinationFormat) == "json" ? .json : .cmcd + LensReceiver.extraUrl = extraUrl + var destinations = [LensDestination(url: LensReceiver.url, format: .json, heartbeat: true)] + if let extraUrl { + destinations.append(LensDestination(url: extraUrl, format: extraFormat, heartbeat: true)) + } + destinationSent = 0 + destinationLastStatus = nil + showDestination() + do { + lensConnector = try LensConnector( + player: player, + configuration: LensConfiguration( + destinations: destinations, + heartbeatMs: 10_000, + contentId: { [weak self] in $0 === self?.scenario.source ? Scenario.contentId : nil } + ) + ) + } catch { + sessionLabel.text = "Lens: \(error)" + } + } + + // MARK: Destination + + private func editDestination() { + let defaults = UserDefaults.standard + let alert = UIAlertController( + title: "Your destination", + message: "Lens also sends every report to this URL, in the format you pick. Changing it ends the current session.", + preferredStyle: .alert + ) + alert.addTextField { + $0.placeholder = "https://collector.example.com/cmcd" + $0.text = defaults.string(forKey: Defaults.destinationUrl) + $0.keyboardType = .URL + $0.autocapitalizationType = .none + $0.autocorrectionType = .no + } + let save = { [weak self, weak alert] (format: String) in + let url = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) ?? "" + guard let scheme = URL(string: url)?.scheme?.lowercased(), ["http", "https"].contains(scheme) else { + self?.destinationLabel.text = "Destination not saved: enter an http or https URL" + return + } + defaults.set(url, forKey: Defaults.destinationUrl) + defaults.set(format, forKey: Defaults.destinationFormat) + self?.connect() + } + alert.addAction(UIAlertAction(title: "Save as CMCD", style: .default) { _ in save("cmcd") }) + alert.addAction(UIAlertAction(title: "Save as JSON", style: .default) { _ in save("json") }) + if defaults.string(forKey: Defaults.destinationUrl) != nil { + alert.addAction(UIAlertAction(title: "Remove", style: .destructive) { [weak self] _ in + defaults.removeObject(forKey: Defaults.destinationUrl) + defaults.removeObject(forKey: Defaults.destinationFormat) + self?.connect() + }) + } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + present(alert, animated: true) + } + + private func destinationPosted(_ status: Int) { + destinationSent += 1 + destinationLastStatus = status + showDestination() + } + + private func showDestination() { + guard let url = LensReceiver.extraUrl else { + destinationLabel.text = "No destination of your own" + return + } + let format = UserDefaults.standard.string(forKey: Defaults.destinationFormat) == "json" ? "JSON" : "CMCD" + let last = destinationLastStatus.map { $0 < 0 ? ", last failed" : ", last HTTP \($0)" } ?? "" + destinationLabel.text = "\(format) to \(url) (\(destinationSent) sent\(last))" + } + // MARK: Player private func load(_ src: String) { @@ -243,6 +326,14 @@ class ViewController: UIViewController, UITableViewDataSource, UITableViewDelega let stagingRow = UIStackView(arrangedSubviews: [stagingSwitch, stagingLabel]) stagingRow.spacing = 8 stagingRow.alignment = .center + let destinationButton = button("Destination…") { [weak self] _ in self?.editDestination() } + destinationButton.setContentHuggingPriority(.required, for: .horizontal) + destinationLabel.font = .preferredFont(forTextStyle: .footnote) + destinationLabel.numberOfLines = 2 + destinationLabel.lineBreakMode = .byTruncatingMiddle + let destinationRow = UIStackView(arrangedSubviews: [destinationButton, destinationLabel]) + destinationRow.spacing = 8 + destinationRow.alignment = .center sessionLabel.font = .monospacedSystemFont(ofSize: 12, weight: .regular) sessionLabel.numberOfLines = 0 @@ -260,7 +351,7 @@ class ViewController: UIViewController, UITableViewDataSource, UITableViewDelega reportsTable.delegate = self playerContainer.backgroundColor = .black - let stack = UIStackView(arrangedSubviews: [playerContainer] + controls + [scenarioRow, stagingRow, sessionLabel, header, reportsTable]) + let stack = UIStackView(arrangedSubviews: [playerContainer] + controls + [scenarioRow, stagingRow, destinationRow, sessionLabel, header, reportsTable]) stack.axis = .vertical stack.spacing = 8 stack.setCustomSpacing(12, after: playerContainer) diff --git a/Code/Lens-Examples/README.md b/Code/Lens-Examples/README.md index 298bb2ff..cb443fd5 100644 --- a/Code/Lens-Examples/README.md +++ b/Code/Lens-Examples/README.md @@ -16,10 +16,9 @@ The reports are captured inside the app, without a server: Lens sends them to a The example also enables the Lens development backend (the `LensSDKDev` product), which sends the reports to the staging ingest. The app sends them on only while **Send to staging** is on, and answers them itself otherwise. Next to the switch, the app counts the reports it sent, with the last HTTP status, and the ones it kept. The switch is remembered between launches. -In `LensExample/Config.swift`, you can also set: +To send the reports to an endpoint of your own as well, such as a test CMCD endpoint, press **Destination…**, enter its URL, and save it as CMCD or JSON. Next to the button, the app counts the reports it sent there, with the last HTTP status. The destination is remembered between launches; changing or removing it joins Lens again, which ends the current session. The example allows plain `http` endpoints. -- `theoplayerLicense`: your THEOplayer license, needed for streams of your own. The example streams play without one. -- `lensDestinationUrl`: an endpoint that also receives the reports as JSON, such as a request inspector. +In `LensExample/Config.swift`, you can set `theoplayerLicense`, your THEOplayer license, needed for streams of your own. The example streams play without one. ## The scenario @@ -48,3 +47,5 @@ Press **Run scenario** to run it, with **Send to staging** on or off; press it a ```sh xcrun simctl launch booted com.theoplayer.connector.LensExample -runScenario YES -sendToStaging YES ``` + +`-destinationUrl ` and `-destinationFormat cmcd|json` set a destination of your own for that launch. From 1798ec3c0542f9a3e0e5767fdef12c86ec2bc2db Mon Sep 17 00:00:00 2001 From: Gilles Moris Date: Tue, 10 Nov 2026 10:52:28 +0100 Subject: [PATCH 13/14] Move to Lens SDK 0.6.2 and key the Lens session on the player Lens 0.6.2 keys a session on the adapter's target. The adapter names the THEOplayer itself, so an SDK without a reference to the adapter shares the session by joining Lens with the raw player. Lens holds the player while a handle on it exists, so the connector no longer ends on the player's DESTROY, which cannot happen while it is joined: apps destroy the connector, then release it. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Code/Lens/Source/LensConnector.swift | 22 +++++---------- Code/Lens/Source/THEOplayerLensAdapter.swift | 8 +++--- .../Tests/THEOplayerLensAdapterTests.swift | 27 +++++++++++++++---- Package.swift | 2 +- 4 files changed, 35 insertions(+), 24 deletions(-) diff --git a/Code/Lens/Source/LensConnector.swift b/Code/Lens/Source/LensConnector.swift index 7fe2cb6e..7bd6d59d 100644 --- a/Code/Lens/Source/LensConnector.swift +++ b/Code/Lens/Source/LensConnector.swift @@ -3,7 +3,6 @@ // import LensSDK -import THEOplayerConnectorUtilities import THEOplayerSDK /// The configuration of a `LensConnector`: the options of `Lens.join`, without the namespace and the player, which the @@ -40,21 +39,21 @@ public struct LensConfiguration { /// /// The connector joins Lens with a player adapter for the given player, in the `theoplayer` namespace, so Lens reports /// playback on its own: sessions, playback states, errors and the player's CMCD keys. +/// +/// - Important: Lens keeps the player alive while the connector is joined. Call `destroy()` when you are done with the +/// player, before you release it. public final class LensConnector { /// The Lens handle the connector joined with, in the `theoplayer` namespace. Use it to set consent or flush. To - /// report events of your own, join Lens in your own namespace with `adapter`. + /// report events of your own, join Lens in your own namespace with the player. public let handle: LensHandle - /// The Lens player adapter for the player. Another SDK reporting to Lens joins with this same adapter to share the - /// player's session. + /// The Lens player adapter for the player. public let adapter: THEOplayerLensAdapter - private var destroyObserver: DispatchObserver? - /// Creates a connector for the given player and joins Lens. /// /// - Parameters: - /// - player: The player to report. The connector does not retain it. + /// - player: The player to report. /// - configuration: The options to join Lens with. /// - Throws: The error of `Lens.join` for invalid options. public init(player: THEOplayer, configuration: LensConfiguration = LensConfiguration()) throws { @@ -69,17 +68,10 @@ public final class LensConnector { ) if let heartbeatMs = configuration.heartbeatMs { options.heartbeatMs = heartbeatMs } handle = try Lens.join(options) - destroyObserver = DispatchObserver( - dispatcher: player, - eventListeners: [ - player.addRemovableEventListener(type: PlayerEventTypes.DESTROY) { [weak self] _ in self?.destroy() } - ] - ) } - /// Ends the session and stops reporting the player. The connector is destroyed with its player. + /// Ends the session, stops reporting the player, and lets Lens release it. public func destroy() { - destroyObserver = nil handle.dispose() } } diff --git a/Code/Lens/Source/THEOplayerLensAdapter.swift b/Code/Lens/Source/THEOplayerLensAdapter.swift index 25d09699..cab23fa7 100644 --- a/Code/Lens/Source/THEOplayerLensAdapter.swift +++ b/Code/Lens/Source/THEOplayerLensAdapter.swift @@ -15,14 +15,16 @@ public typealias ContentIdProvider = (SourceDescription) -> String? /// A `LensConnector` creates one and joins Lens with it; use the adapter directly only to join Lens yourself. /// /// - Remark: -/// - Create one adapter per player and hand the same instance to every `Lens.join` that observes it: Lens tells -/// players apart by adapter identity. +/// - Lens keys the session on the player itself: other SDKs share it by joining Lens with the player. Lens observes +/// one adapter per player, so create one per player. /// - Use it on the main queue, as the player. -/// - The adapter does not retain the player. +/// - The adapter does not retain the player, but Lens does while handles are joined with it. /// - Do not enable THEOplayer's own CMCD reporting for a player that Lens observes. public final class THEOplayerLensAdapter: LensPlayerAdapter { public let playerName = "THEOplayer" public let playerVersion: String? = THEOplayer.version + /// The player, which Lens keys its session on; the adapter itself once the player is gone. + public var target: AnyObject { player ?? self } private weak var player: THEOplayer? private let contentId: ContentIdProvider? diff --git a/Code/Lens/Tests/THEOplayerLensAdapterTests.swift b/Code/Lens/Tests/THEOplayerLensAdapterTests.swift index 2e62e99c..4ef8e6e6 100644 --- a/Code/Lens/Tests/THEOplayerLensAdapterTests.swift +++ b/Code/Lens/Tests/THEOplayerLensAdapterTests.swift @@ -330,17 +330,34 @@ final class THEOplayerLensAdapterTests: XCTestCase { waitForSession(of: connector.handle) } - func testTheConnectorStopsWithItsPlayer() throws { + func testAnSDKJoiningWithThePlayerSharesTheSession() throws { + let player = THEOplayer() + let connector = try connect(player) + defer { connector.destroy() } + let other = try Lens.join(LensJoinOptions(namespace: "other_sdk", player: player)) + defer { other.dispose() } + load(mp4Source, into: player) + player.play() + waitForSession(of: connector.handle) + + XCTAssertNotNil(other.session) + XCTAssertEqual(other.session?.sessionId, connector.handle.session?.sessionId) + } + + /// Lens keeps the player alive while a handle on it exists, disposed or not. + func testDestroyingAndReleasingTheConnectorReleasesThePlayer() throws { var player: THEOplayer? = THEOplayer() - let connector = try connect(player!) + weak var released = player + var connector: LensConnector? = try connect(player!) load(mp4Source, into: player!) player?.play() - waitForSession(of: connector.handle) + waitForSession(of: connector!.handle) + connector?.destroy() + connector = nil player = nil - XCTAssertNil(connector.handle.session) - waitForRelease(of: connector.adapter) + wait(for: [XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in released == nil }, object: nil)], timeout: 5) } func testDestroyingTheConnectorEndsTheSession() throws { diff --git a/Package.swift b/Package.swift index 3bedfe0e..58f77501 100644 --- a/Package.swift +++ b/Package.swift @@ -31,7 +31,7 @@ let package = Package( .package(name: "Swifter", url: "https://github.com/httpswift/swifter.git", .exactItem("1.5.0")), .package(name: "SwiftSubtitles", url: "https://github.com/dagronf/SwiftSubtitles.git", .exactItem("0.9.1")), .package(name: "ComScore", url: "https://github.com/comScore/Comscore-Swift-Package-Manager", "6.10.0"..<"6.11.0"), - .package(url: "https://github.com/Dolby-OptiView/optiview-lens-sdk-swift", "0.6.0"..<"0.7.0"), + .package(url: "https://github.com/Dolby-OptiView/optiview-lens-sdk-swift", "0.6.2"..<"0.7.0"), ], targets: [ .target( From a45e95e1f058ed93b3c6a83efc1d8cb140f36ed9 Mon Sep 17 00:00:00 2001 From: Gilles Moris Date: Tue, 10 Nov 2026 10:52:28 +0100 Subject: [PATCH 14/14] Document the Lens session on the player and require Lens 0.6.2 in the example Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../LensExample.xcodeproj/project.pbxproj | 2 +- Code/Lens/README.md | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj b/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj index 40cd3951..305a6c90 100644 --- a/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj +++ b/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj @@ -355,7 +355,7 @@ repositoryURL = "https://github.com/Dolby-OptiView/optiview-lens-sdk-swift"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 0.6.0; + minimumVersion = 0.6.2; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Code/Lens/README.md b/Code/Lens/README.md index 46ab06d8..85b4c16a 100644 --- a/Code/Lens/README.md +++ b/Code/Lens/README.md @@ -24,7 +24,7 @@ import THEOplayerConnectorLens let lensConnector = try LensConnector(player: player) ``` -Lens starts a session at the first play request on each source, and ends it when the source is replaced or removed. The connector does not retain the player. +Lens starts a session at the first play request on each source, and ends it when the source is replaced or removed. Lens keeps the player alive while the connector is joined: destroy the connector before you release the player (see Destroying). Lens reports to the OptiView ingest by default. The configuration takes the options of `Lens.join`, except the namespace and the player, for example to add destinations of your own: @@ -54,12 +54,12 @@ let lensConnector = try LensConnector( ### Your own events -To report events of your own, join Lens in your own registered namespace with `lensConnector.adapter`. Lens reports one session per player adapter, so your events and the player's playback share that session. Another SDK that reports this player to Lens joins with the adapter the same way. +To report events of your own, join Lens in your own registered namespace with the player itself. Lens keys the session on the player, so your events and the player's playback share that session. Another SDK that reports this player to Lens joins with the player the same way, without needing the connector or its adapter. ```swift import LensSDK -let lens = try Lens.join(LensJoinOptions(namespace: "my_namespace", player: lensConnector.adapter)) +let lens = try Lens.join(LensJoinOptions(namespace: "my_namespace", player: player)) lens.emitCustom("chapterStart", payload: LensPayload(custom: ["chapter": 3])) ``` @@ -67,12 +67,15 @@ lens.emitCustom("chapterStart", payload: LensPayload(custom: ["chapter": 3])) ### Destroying -The connector ends the session and stops reporting when the player is released, or when you destroy it: +Lens holds on to the player while a handle on it exists, so a THEOplayer, which is destroyed when it is released, stays alive until you destroy the connector and release it. When you are done with the player, end the session and stop reporting it first: ```swift lensConnector.destroy() +lensConnector = nil ``` +Do the same with any handle of your own on the player: `lens.dispose()`, then release it. + Do not enable THEOplayer's own CMCD reporting on a player that Lens observes. ## Limitations