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/CHANGELOG.md b/CHANGELOG.md
index bab123c2..acb71b4f 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 OptiView Lens. It is available through Swift Package Manager only.
+
## [11.0.7] - 2026-11-05
### Fixed
diff --git a/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj b/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj
new file mode 100644
index 00000000..305a6c90
--- /dev/null
+++ b/Code/Lens-Examples/LensExample.xcodeproj/project.pbxproj
@@ -0,0 +1,381 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* 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 */
+
+/* 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; };
+ 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 */
+ 7491CAF7AC316FD6A47E5097 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 05A45CFB730C3C8D509763CD /* THEOplayerConnectorLens in Frameworks */,
+ 90E843D6E688C47241AF4E1D /* LensSDK in Frameworks */,
+ 43C7581412A3DF206EC4C660 /* LensSDKDev 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 */,
+ 0FD35DE090C16D1460BC0759 /* Info.plist */,
+ C7E6CDD3D598D4ABECAA0102 /* LensReceiver.swift */,
+ D612104EA5996CDD02E63B42 /* Scenario.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 */,
+ FFB346B2F06C0F4AC773535B /* LensSDKDev */,
+ );
+ 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 */,
+ 63CEC8FC59C233CCE147F3E5 /* LensReceiver.swift in Sources */,
+ C42B1C4E8257439E0DFB63F1 /* Scenario.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_FILE = LensExample/Info.plist;
+ 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_FILE = LensExample/Info.plist;
+ 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.2;
+ };
+ };
+/* 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;
+ };
+ 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.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..a51965d5
--- /dev/null
+++ b/Code/Lens-Examples/LensExample/Config.swift
@@ -0,0 +1,13 @@
+//
+// Config.swift
+// LensExample
+//
+
+/// Your THEOplayer license, from the OptiView portal. The example streams play without one.
+let theoplayerLicense: 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"),
+ ("Broken URL (fatal error)", "https://cdn.theoplayer.com/video/lens-example/missing.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
new file mode 100644
index 00000000..68ce3fb9
--- /dev/null
+++ b/Code/Lens-Examples/LensExample/LensReceiver.swift
@@ -0,0 +1,113 @@
+//
+// LensReceiver.swift
+// LensExample
+//
+
+import Foundation
+import LensSDKDev
+
+/// 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 `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, -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?
+
+ static func start() {
+ URLProtocol.registerClass(LensReceiver.self)
+ }
+
+ override class func canInit(with request: URLRequest) -> Bool {
+ guard property(forKey: forwardedKey, in: request) == nil else { return false }
+ return [url, stagingUrl, extraUrl].contains(request.url?.absoluteString)
+ }
+
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ let body = request.httpBody ?? request.httpBodyStream.map(Self.read) ?? Data()
+ 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) { status in Self.onStagingPost?(status) }
+ } 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)
+ }
+
+ /// 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
+ 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 { posted(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()
+ 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/Scenario.swift b/Code/Lens-Examples/LensExample/Scenario.swift
new file mode 100644
index 00000000..521e6203
--- /dev/null
+++ b/Code/Lens-Examples/LensExample/Scenario.swift
@@ -0,0 +1,130 @@
+//
+// 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-demo-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/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/9 Play for 10 s")
+ try await next(PlayerEventTypes.PLAYING) { player.play() }
+ try await wait(10)
+ step("3/9 Pause for 3 s")
+ try await next(PlayerEventTypes.PAUSE) { player.pause() }
+ try await wait(3)
+ step("4/9 Play for 5 s")
+ try await next(PlayerEventTypes.PLAYING) { player.play() }
+ try await wait(5)
+ 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/9 Mute for 3 s, unmute for 2 s")
+ player.muted = true
+ try await wait(3)
+ player.muted = false
+ 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
+ try await wait(2)
+ 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)
+ 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(2)
+ 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
new file mode 100644
index 00000000..225dd06c
--- /dev/null
+++ b/Code/Lens-Examples/LensExample/ViewController.swift
@@ -0,0 +1,404 @@
+//
+// ViewController.swift
+// LensExample
+//
+
+import LensSDK
+import LensSDKDev
+import THEOplayerConnectorLens
+import THEOplayerSDK
+import UIKit
+
+/// 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 {
+ 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)
+ 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 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]] = []
+ private var stagingSent = 0
+ private var stagingAnswered = 0
+ private var stagingLastStatus: Int?
+ private var sessionTimer: Timer?
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = .systemBackground
+ 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.onExtraPost = { [weak self] in self?.destinationPosted($0) }
+ LensReceiver.start()
+ showStaging()
+ scenario.onStep = { [weak self] in self?.scenarioStepped($0) }
+
+ do {
+ // Lens reports to the staging ingest, which the app forwards to only while the switch is on.
+ try LensDev.enableDevelopmentBackend()
+ } catch {
+ sessionLabel.text = "Lens: \(error)"
+ }
+ connect()
+ sessionTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
+ self?.showSession()
+ }
+ }
+
+ 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
+ }
+
+ deinit {
+ sessionTimer?.invalidate()
+ 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) {
+ 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: 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]) {
+ 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 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 = [
+ 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×"
+ },
+ ]),
+ 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
+ },
+ ]),
+ ]
+
+ 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
+ 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
+ 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] + controls + [scenarioRow, stagingRow, destinationRow, sessionLabel, header, reportsTable])
+ stack.axis = .vertical
+ stack.spacing = 8
+ stack.setCustomSpacing(12, after: playerContainer)
+ stack.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(stack)
+ NSLayoutConstraint.activate([
+ 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
new file mode 100644
index 00000000..cb443fd5
--- /dev/null
+++ b/Code/Lens-Examples/README.md
@@ -0,0 +1,51 @@
+# THEOplayer Connector for OptiView Lens: example
+
+`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.
+
+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 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.
+
+## Where the reports go
+
+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.
+
+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.
+
+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
+
+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.
+3. Pause for 3 s.
+4. Play for 5 s.
+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 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.
+
+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.
+
+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
+```
+
+`-destinationUrl ` and `-destinationFormat cmcd|json` set a destination of your own for that launch.
diff --git a/Code/Lens/README.md b/Code/Lens/README.md
new file mode 100644
index 00000000..85b4c16a
--- /dev/null
+++ b/Code/Lens/README.md
@@ -0,0 +1,91 @@
+# 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, 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. 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
+
+Create one connector per player, on the main queue:
+
+```swift
+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. 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:
+
+```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 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: player))
+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
+
+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
+
+- 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
+
+See the [example app](../Lens-Examples).
diff --git a/Code/Lens/Source/LensConnector.swift b/Code/Lens/Source/LensConnector.swift
new file mode 100644
index 00000000..7bd6d59d
--- /dev/null
+++ b/Code/Lens/Source/LensConnector.swift
@@ -0,0 +1,77 @@
+//
+// LensConnector.swift
+//
+
+import LensSDK
+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
+ /// 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.
+ public var contentId: ContentIdProvider?
+
+ public init(
+ destinations: [LensDestination] = [],
+ consent: LensConsent = .granted,
+ strict: Bool = false,
+ heartbeatMs: Int64? = nil,
+ 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.
+///
+/// - 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 the player.
+ public let handle: LensHandle
+
+ /// The Lens player adapter for the player.
+ public let adapter: THEOplayerLensAdapter
+
+ /// Creates a connector for the given player and joins Lens.
+ ///
+ /// - Parameters:
+ /// - 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 {
+ adapter = THEOplayerLensAdapter(player: player, contentId: configuration.contentId)
+ 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)
+ }
+
+ /// Ends the session, stops reporting the player, and lets Lens release it.
+ public func destroy() {
+ handle.dispose()
+ }
+}
diff --git a/Code/Lens/Source/THEOplayerLensAdapter.swift b/Code/Lens/Source/THEOplayerLensAdapter.swift
new file mode 100644
index 00000000..cab23fa7
--- /dev/null
+++ b/Code/Lens/Source/THEOplayerLensAdapter.swift
@@ -0,0 +1,468 @@
+//
+// THEOplayerLensAdapter.swift
+//
+
+import Foundation
+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:
+/// - 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, 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?
+ 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)
+
+ /// - 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(
+ 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()
+ }
+ }
+ }
+
+ /// 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
+ 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 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,
+ 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
+ return LensPlayerProperties(
+ live: live,
+ 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,
+ 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/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.
+ 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/Code/Lens/Tests/THEOplayerLensAdapterTests.swift b/Code/Lens/Tests/THEOplayerLensAdapterTests.swift
new file mode 100644
index 00000000..4ef8e6e6
--- /dev/null
+++ b/Code/Lens/Tests/THEOplayerLensAdapterTests.swift
@@ -0,0 +1,461 @@
+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 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
+ 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 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 connector = try connect(player)
+ defer { connector.destroy() }
+ load(mp4Source, into: player)
+ XCTAssertNil(connector.handle.session)
+
+ player.play()
+
+ waitForSession(of: connector.handle)
+ }
+
+ 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()
+ weak var released = player
+ var connector: LensConnector? = try connect(player!)
+ load(mp4Source, into: player!)
+ player?.play()
+ waitForSession(of: connector!.handle)
+
+ connector?.destroy()
+ connector = nil
+ player = nil
+
+ wait(for: [XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in released == nil }, object: nil)], timeout: 5)
+ }
+
+ 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")
+ 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..