unified-video-framework 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/.github/workflows/ci.yml +253 -0
  2. package/ANDROID_TV_IMPLEMENTATION.md +313 -0
  3. package/COMPLETION_STATUS.md +165 -0
  4. package/CONTRIBUTING.md +376 -0
  5. package/FINAL_STATUS_REPORT.md +170 -0
  6. package/FRAMEWORK_REVIEW.md +247 -0
  7. package/IMPROVEMENTS_SUMMARY.md +168 -0
  8. package/LICENSE +21 -0
  9. package/NATIVE_APP_INTEGRATION_GUIDE.md +903 -0
  10. package/PAYWALL_RENTAL_FLOW.md +499 -0
  11. package/PLATFORM_SETUP_GUIDE.md +1636 -0
  12. package/README.md +315 -0
  13. package/RUN_LOCALLY.md +151 -0
  14. package/apps/demo/cast-sender-min.html +173 -0
  15. package/apps/demo/custom-player.html +883 -0
  16. package/apps/demo/demo.html +990 -0
  17. package/apps/demo/enhanced-player.html +3556 -0
  18. package/apps/demo/index.html +159 -0
  19. package/apps/rental-api/.env.example +24 -0
  20. package/apps/rental-api/README.md +23 -0
  21. package/apps/rental-api/migrations/001_init.sql +35 -0
  22. package/apps/rental-api/migrations/002_videos.sql +10 -0
  23. package/apps/rental-api/migrations/003_add_gateway_subref.sql +4 -0
  24. package/apps/rental-api/migrations/004_update_gateways.sql +4 -0
  25. package/apps/rental-api/migrations/005_seed_demo_video.sql +5 -0
  26. package/apps/rental-api/package-lock.json +2045 -0
  27. package/apps/rental-api/package.json +33 -0
  28. package/apps/rental-api/scripts/run-migration.js +42 -0
  29. package/apps/rental-api/scripts/update-video-currency.js +21 -0
  30. package/apps/rental-api/scripts/update-video-price.js +19 -0
  31. package/apps/rental-api/src/config.ts +14 -0
  32. package/apps/rental-api/src/db.ts +10 -0
  33. package/apps/rental-api/src/routes/cashfree.ts +167 -0
  34. package/apps/rental-api/src/routes/pesapal.ts +92 -0
  35. package/apps/rental-api/src/routes/rentals.ts +242 -0
  36. package/apps/rental-api/src/routes/webhooks.ts +73 -0
  37. package/apps/rental-api/src/server.ts +41 -0
  38. package/apps/rental-api/src/services/entitlements.ts +45 -0
  39. package/apps/rental-api/src/services/payments.ts +22 -0
  40. package/apps/rental-api/tsconfig.json +17 -0
  41. package/check-urls.ps1 +74 -0
  42. package/comparison-report.md +181 -0
  43. package/docs/PAYWALL.md +95 -0
  44. package/docs/PLAYER_UI_VISIBILITY.md +431 -0
  45. package/docs/README.md +7 -0
  46. package/docs/SYSTEM_ARCHITECTURE.md +612 -0
  47. package/docs/VDOCIPHER_CLONE_REQUIREMENTS.md +403 -0
  48. package/examples/android/JavaSampleApp/MainActivity.java +641 -0
  49. package/examples/android/JavaSampleApp/activity_main.xml +226 -0
  50. package/examples/android/SampleApp/MainActivity.kt +430 -0
  51. package/examples/ios/SampleApp/ViewController.swift +337 -0
  52. package/examples/ios/SwiftUISampleApp/ContentView.swift +304 -0
  53. package/iOS_IMPLEMENTATION_OPTIONS.md +470 -0
  54. package/ios/UnifiedVideoPlayer/UnifiedVideoPlayer.podspec +33 -0
  55. package/jest.config.js +33 -0
  56. package/jitpack.yml +5 -0
  57. package/lerna.json +35 -0
  58. package/package.json +69 -0
  59. package/packages/PLATFORM_STATUS.md +163 -0
  60. package/packages/android/build.gradle +135 -0
  61. package/packages/android/src/main/AndroidManifest.xml +36 -0
  62. package/packages/android/src/main/java/com/unifiedvideo/player/PlayerConfiguration.java +221 -0
  63. package/packages/android/src/main/java/com/unifiedvideo/player/UnifiedVideoPlayer.java +1037 -0
  64. package/packages/android/src/main/java/com/unifiedvideo/player/UnifiedVideoPlayer.kt +707 -0
  65. package/packages/android/src/main/java/com/unifiedvideo/player/analytics/AnalyticsProvider.java +9 -0
  66. package/packages/android/src/main/java/com/unifiedvideo/player/cast/CastManager.java +141 -0
  67. package/packages/android/src/main/java/com/unifiedvideo/player/cast/CastOptionsProvider.java +29 -0
  68. package/packages/android/src/main/java/com/unifiedvideo/player/overlay/WatermarkOverlayView.java +88 -0
  69. package/packages/android/src/main/java/com/unifiedvideo/player/pip/PipActionReceiver.java +33 -0
  70. package/packages/android/src/main/java/com/unifiedvideo/player/services/PlaybackService.java +110 -0
  71. package/packages/android/src/main/java/com/unifiedvideo/player/services/PlayerHolder.java +19 -0
  72. package/packages/core/package.json +34 -0
  73. package/packages/core/src/BasePlayer.ts +250 -0
  74. package/packages/core/src/VideoPlayer.ts +237 -0
  75. package/packages/core/src/VideoPlayerFactory.ts +145 -0
  76. package/packages/core/src/index.ts +20 -0
  77. package/packages/core/src/interfaces/IVideoPlayer.ts +184 -0
  78. package/packages/core/src/interfaces.ts +240 -0
  79. package/packages/core/src/utils/EventEmitter.ts +66 -0
  80. package/packages/core/src/utils/PlatformDetector.ts +300 -0
  81. package/packages/core/tsconfig.json +20 -0
  82. package/packages/enact/package.json +51 -0
  83. package/packages/enact/src/VideoPlayer.js +365 -0
  84. package/packages/enact/src/adapters/TizenAdapter.js +354 -0
  85. package/packages/enact/src/index.js +82 -0
  86. package/packages/ios/BUILD_INSTRUCTIONS.md +108 -0
  87. package/packages/ios/FIX_EMBED_ISSUE.md +142 -0
  88. package/packages/ios/GETTING_STARTED.md +100 -0
  89. package/packages/ios/Package.swift +35 -0
  90. package/packages/ios/README.md +84 -0
  91. package/packages/ios/Sources/UnifiedVideoPlayer/Analytics/AnalyticsEmitter.swift +26 -0
  92. package/packages/ios/Sources/UnifiedVideoPlayer/DRM/FairPlayDRMManager.swift +102 -0
  93. package/packages/ios/Sources/UnifiedVideoPlayer/Info.plist +24 -0
  94. package/packages/ios/Sources/UnifiedVideoPlayer/Remote/RemoteCommandCenter.swift +109 -0
  95. package/packages/ios/Sources/UnifiedVideoPlayer/UnifiedVideoPlayer.swift +811 -0
  96. package/packages/ios/Sources/UnifiedVideoPlayer/UnifiedVideoPlayerView.swift +640 -0
  97. package/packages/ios/Sources/UnifiedVideoPlayer/Utilities/Color+Hex.swift +36 -0
  98. package/packages/ios/UnifiedVideoPlayer.podspec +27 -0
  99. package/packages/ios/UnifiedVideoPlayer.xcodeproj/project.pbxproj +385 -0
  100. package/packages/ios/build_framework.sh +55 -0
  101. package/packages/react-native/android/src/main/java/com/unifiedvideo/UnifiedVideoPlayerModule.kt +482 -0
  102. package/packages/react-native/ios/UnifiedVideoPlayer.swift +436 -0
  103. package/packages/react-native/package.json +51 -0
  104. package/packages/react-native/src/ReactNativePlayer.tsx +423 -0
  105. package/packages/react-native/src/VideoPlayer.tsx +224 -0
  106. package/packages/react-native/src/index.ts +28 -0
  107. package/packages/react-native/src/utils/EventEmitter.ts +66 -0
  108. package/packages/react-native/tsconfig.json +31 -0
  109. package/packages/roku/components/UnifiedVideoPlayer.brs +400 -0
  110. package/packages/roku/package.json +44 -0
  111. package/packages/roku/source/VideoPlayer.brs +231 -0
  112. package/packages/roku/source/main.brs +28 -0
  113. package/packages/web/GETTING_STARTED.md +292 -0
  114. package/packages/web/jest.config.js +28 -0
  115. package/packages/web/jest.setup.ts +110 -0
  116. package/packages/web/package.json +50 -0
  117. package/packages/web/src/SecureVideoPlayer.ts +1164 -0
  118. package/packages/web/src/WebPlayer.ts +3110 -0
  119. package/packages/web/src/__tests__/WebPlayer.test.ts +314 -0
  120. package/packages/web/src/index.ts +14 -0
  121. package/packages/web/src/paywall/PaywallController.ts +215 -0
  122. package/packages/web/src/react/WebPlayerView.tsx +177 -0
  123. package/packages/web/tsconfig.json +23 -0
  124. package/packages/web/webpack.config.js +45 -0
  125. package/server.js +131 -0
  126. package/server.py +84 -0
  127. package/test-urls.ps1 +97 -0
  128. package/test-video-urls.ps1 +87 -0
  129. package/tsconfig.json +39 -0
@@ -0,0 +1,811 @@
1
+ //
2
+ // UnifiedVideoPlayer.swift
3
+ // UnifiedVideoPlayer
4
+ //
5
+ // Unified Video Framework - iOS Native SDK
6
+ //
7
+
8
+ import Foundation
9
+ import AVFoundation
10
+ import AVKit
11
+ import UIKit
12
+ import MediaPlayer
13
+
14
+ // MARK: - Player Configuration
15
+ @objc public class PlayerConfiguration: NSObject {
16
+ @objc public var autoPlay: Bool = false
17
+ @objc public var controls: Bool = true
18
+ @objc public var muted: Bool = false
19
+ @objc public var loop: Bool = false
20
+ @objc public var preload: String = "auto"
21
+ @objc public var startTime: Double = 0
22
+ @objc public var playbackRate: Float = 1.0
23
+ @objc public var volume: Float = 1.0
24
+ @objc public var debug: Bool = false
25
+ // Dynamic theme color for controls (e.g., #FF5722 or #AAFF5722)
26
+ @objc public var themeColorHex: String? = nil
27
+
28
+ @objc public override init() {
29
+ super.init()
30
+ }
31
+
32
+ @objc public init(dictionary: [String: Any]) {
33
+ super.init()
34
+ autoPlay = dictionary["autoPlay"] as? Bool ?? false
35
+ controls = dictionary["controls"] as? Bool ?? true
36
+ muted = dictionary["muted"] as? Bool ?? false
37
+ loop = dictionary["loop"] as? Bool ?? false
38
+ preload = dictionary["preload"] as? String ?? "auto"
39
+ startTime = dictionary["startTime"] as? Double ?? 0
40
+ playbackRate = dictionary["playbackRate"] as? Float ?? 1.0
41
+ volume = dictionary["volume"] as? Float ?? 1.0
42
+ debug = dictionary["debug"] as? Bool ?? false
43
+ themeColorHex = dictionary["themeColorHex"] as? String
44
+ }
45
+ }
46
+
47
+ // MARK: - Media Source
48
+ @objc public class MediaSource: NSObject {
49
+ @objc public var url: String
50
+ @objc public var type: String?
51
+ @objc public var drm: DRMConfiguration?
52
+ @objc public var metadata: [String: Any]?
53
+ @objc public var subtitles: [SubtitleTrack]?
54
+
55
+ @objc public init(url: String, type: String? = nil) {
56
+ self.url = url
57
+ self.type = type ?? MediaSource.detectType(from: url)
58
+ }
59
+
60
+ private static func detectType(from url: String) -> String {
61
+ if url.contains(".m3u8") { return "hls" }
62
+ if url.contains(".mpd") { return "dash" }
63
+ if url.contains(".mp4") { return "mp4" }
64
+ if url.contains(".webm") { return "webm" }
65
+ return "mp4"
66
+ }
67
+ }
68
+
69
+ // MARK: - DRM Configuration
70
+ @objc public class DRMConfiguration: NSObject {
71
+ @objc public var type: String // fairplay, widevine
72
+ @objc public var licenseUrl: String
73
+ @objc public var certificateUrl: String?
74
+ @objc public var headers: [String: String]?
75
+
76
+ @objc public init(type: String, licenseUrl: String) {
77
+ self.type = type
78
+ self.licenseUrl = licenseUrl
79
+ }
80
+ }
81
+
82
+ // MARK: - Subtitle Track
83
+ @objc public class SubtitleTrack: NSObject {
84
+ @objc public var url: String
85
+ @objc public var language: String
86
+ @objc public var label: String
87
+ @objc public var kind: String // subtitles, captions
88
+
89
+ @objc public init(url: String, language: String, label: String, kind: String = "subtitles") {
90
+ self.url = url
91
+ self.language = language
92
+ self.label = label
93
+ self.kind = kind
94
+ }
95
+ }
96
+
97
+ // MARK: - Player State
98
+ @objc public enum PlayerState: Int {
99
+ case idle = 0
100
+ case loading = 1
101
+ case ready = 2
102
+ case playing = 3
103
+ case paused = 4
104
+ case buffering = 5
105
+ case seeking = 6
106
+ case ended = 7
107
+ case error = 8
108
+ }
109
+
110
+ // MARK: - Main Player Class
111
+ @objc public class UnifiedVideoPlayer: NSObject {
112
+
113
+ // MARK: Properties
114
+ private var player: AVPlayer?
115
+ private var playerLayer: AVPlayerLayer?
116
+ private var playerViewController: AVPlayerViewController?
117
+ private var containerView: UIView?
118
+ private var configuration: PlayerConfiguration?
119
+ private var currentSource: MediaSource?
120
+
121
+ private var timeObserver: Any?
122
+ private var statusObserver: NSKeyValueObservation?
123
+ private var rateObserver: NSKeyValueObservation?
124
+ private var currentItemObserver: NSKeyValueObservation?
125
+
126
+ // Add-ons
127
+ private var drmManager: FairPlayDRMManager?
128
+ private var pipController: AVPictureInPictureController?
129
+ private let analyticsEmitter = AnalyticsEmitter()
130
+ private let remoteCenter = RemoteCommandCenter()
131
+ private var themeUIColor: UIColor? = nil
132
+
133
+ @objc public private(set) var state: PlayerState = .idle
134
+ @objc public private(set) var isPlaying: Bool = false
135
+ @objc public private(set) var duration: Double = 0
136
+ @objc public private(set) var currentTime: Double = 0
137
+ @objc public private(set) var bufferedTime: Double = 0
138
+
139
+ // MARK: - Event Callbacks
140
+ @objc public var onReady: (() -> Void)?
141
+ @objc public var onPlay: (() -> Void)?
142
+ @objc public var onPause: (() -> Void)?
143
+ @objc public var onTimeUpdate: ((Double) -> Void)?
144
+ @objc public var onBuffering: ((Bool) -> Void)?
145
+ @objc public var onSeek: ((Double) -> Void)?
146
+ @objc public var onEnded: (() -> Void)?
147
+ @objc public var onError: ((Error) -> Void)?
148
+ @objc public var onLoadedMetadata: (([String: Any]) -> Void)?
149
+ @objc public var onVolumeChange: ((Float) -> Void)?
150
+ @objc public var onStateChange: ((PlayerState) -> Void)?
151
+ @objc public var onProgress: ((Double) -> Void)?
152
+ @objc public var onQualityChange: ((Double) -> Void)?
153
+
154
+ // MARK: - Initialization
155
+
156
+ @objc public override init() {
157
+ super.init()
158
+ }
159
+
160
+ @objc public func initialize(container: UIView, configuration: PlayerConfiguration? = nil) {
161
+ self.containerView = container
162
+ self.configuration = configuration ?? PlayerConfiguration()
163
+
164
+ setupPlayer()
165
+ applyConfiguration()
166
+ }
167
+
168
+ @objc public func initializeWithViewController(viewController: UIViewController, configuration: PlayerConfiguration? = nil) {
169
+ self.configuration = configuration ?? PlayerConfiguration()
170
+
171
+ playerViewController = AVPlayerViewController()
172
+ setupPlayer()
173
+ applyConfiguration()
174
+
175
+ if let playerVC = playerViewController {
176
+ playerVC.player = player
177
+ viewController.present(playerVC, animated: true)
178
+ }
179
+ }
180
+
181
+ private func setupPlayer() {
182
+ player = AVPlayer()
183
+
184
+ // Background audio / playback category
185
+ do {
186
+ try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback, options: [])
187
+ try AVAudioSession.sharedInstance().setActive(true)
188
+ } catch {
189
+ if configuration?.debug == true {
190
+ print("[UnifiedVideoPlayer] Failed to set audio session: \(error)")
191
+ }
192
+ }
193
+
194
+ if let container = containerView {
195
+ playerLayer = AVPlayerLayer(player: player)
196
+ playerLayer?.frame = container.bounds
197
+ playerLayer?.videoGravity = .resizeAspect
198
+
199
+ if let layer = playerLayer {
200
+ container.layer.addSublayer(layer)
201
+ }
202
+
203
+ // Add default controls if enabled
204
+ if configuration?.controls == true && playerViewController == nil {
205
+ addDefaultControls()
206
+ }
207
+ }
208
+
209
+ setupObservers()
210
+ updateState(.idle)
211
+ }
212
+
213
+ private func applyConfiguration() {
214
+ guard let config = configuration else { return }
215
+
216
+ player?.volume = config.volume
217
+ player?.rate = config.playbackRate
218
+ player?.isMuted = config.muted
219
+
220
+ if let hex = config.themeColorHex, let ui = UIColor(hex: hex) {
221
+ themeUIColor = ui
222
+ } else {
223
+ themeUIColor = nil
224
+ }
225
+
226
+ if config.debug {
227
+ enableDebugLogging()
228
+ }
229
+ }
230
+
231
+ // MARK: - Loading Content
232
+
233
+ @objc public func load(source: MediaSource) {
234
+ currentSource = source
235
+ updateState(.loading)
236
+
237
+ guard let url = URL(string: source.url) else {
238
+ let error = NSError(domain: "UnifiedVideoPlayer", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])
239
+ handleError(error)
240
+ return
241
+ }
242
+
243
+ // Handle DRM if configured
244
+ if let drm = source.drm, drm.type == "fairplay" {
245
+ loadFairPlayContent(url: url, drm: drm)
246
+ } else {
247
+ loadStandardContent(url: url)
248
+ }
249
+
250
+ // Load subtitles if provided
251
+ if let subtitles = source.subtitles {
252
+ loadSubtitles(subtitles)
253
+ }
254
+ }
255
+
256
+ @objc public func load(url: String) {
257
+ let source = MediaSource(url: url)
258
+ load(source: source)
259
+ }
260
+
261
+ private func loadStandardContent(url: URL) {
262
+ let asset = AVAsset(url: url)
263
+ let playerItem = AVPlayerItem(asset: asset)
264
+
265
+ // Add observers for the player item
266
+ observePlayerItem(playerItem)
267
+
268
+ player?.replaceCurrentItem(with: playerItem)
269
+
270
+ // Analytics & Remote Center
271
+ analyticsEmitter.onBitrate = { [weak self] br in self?.onQualityChange?(br) }
272
+ analyticsEmitter.startObserving(item: playerItem)
273
+ if let p = player {
274
+ let title = currentSource?.metadata?["title"] as? String
275
+ remoteCenter.enable(for: p, title: title, duration: nil, artworkURL: nil)
276
+ }
277
+
278
+ // Apply start time if configured
279
+ if let startTime = configuration?.startTime, startTime > 0 {
280
+ seek(to: startTime)
281
+ }
282
+
283
+ // Auto-play if configured
284
+ if configuration?.autoPlay == true {
285
+ play()
286
+ }
287
+ }
288
+
289
+ private func loadFairPlayContent(url: URL, drm: DRMConfiguration) {
290
+ // FairPlay implementation
291
+ let asset = AVURLAsset(url: url)
292
+
293
+ // Configure FairPlay
294
+ drmManager = FairPlayDRMManager(drm: drm)
295
+ asset.resourceLoader.setDelegate(drmManager, queue: DispatchQueue.main)
296
+
297
+ let playerItem = AVPlayerItem(asset: asset)
298
+ observePlayerItem(playerItem)
299
+ player?.replaceCurrentItem(with: playerItem)
300
+
301
+ // Analytics & Remote Center
302
+ analyticsEmitter.onBitrate = { [weak self] br in self?.onQualityChange?(br) }
303
+ analyticsEmitter.startObserving(item: playerItem)
304
+ if let p = player {
305
+ let title = currentSource?.metadata?["title"] as? String
306
+ remoteCenter.enable(for: p, title: title, duration: nil, artworkURL: nil)
307
+ }
308
+
309
+ if configuration?.autoPlay == true {
310
+ play()
311
+ }
312
+ }
313
+
314
+ private func loadSubtitles(_ subtitles: [SubtitleTrack]) {
315
+ guard let playerItem = player?.currentItem else { return }
316
+
317
+ for subtitle in subtitles {
318
+ guard let url = URL(string: subtitle.url) else { continue }
319
+
320
+ let asset = AVAsset(url: url)
321
+ let track = AVMutableMetadataItem()
322
+ track.identifier = .commonIdentifierTitle
323
+ track.value = subtitle.label as NSString
324
+ track.extendedLanguageTag = subtitle.language
325
+
326
+ // Note: Full subtitle implementation would require more complex AVAssetReader setup
327
+ }
328
+ }
329
+
330
+ // MARK: - Playback Control
331
+
332
+ @objc public func play() {
333
+ player?.play()
334
+ isPlaying = true
335
+ updateState(.playing)
336
+ updatePlayPauseIcon()
337
+ onPlay?()
338
+ }
339
+
340
+ @objc public func pause() {
341
+ player?.pause()
342
+ isPlaying = false
343
+ updateState(.paused)
344
+ updatePlayPauseIcon()
345
+ onPause?()
346
+ }
347
+
348
+ @objc public func stop() {
349
+ player?.pause()
350
+ player?.seek(to: .zero)
351
+ isPlaying = false
352
+ updateState(.idle)
353
+ }
354
+
355
+ @objc public func togglePlayPause() {
356
+ if isPlaying {
357
+ pause()
358
+ } else {
359
+ play()
360
+ }
361
+ }
362
+
363
+ @objc public func seek(to time: Double) {
364
+ updateState(.seeking)
365
+ let cmTime = CMTime(seconds: time, preferredTimescale: 1000)
366
+
367
+ player?.seek(to: cmTime, completionHandler: { [weak self] _ in
368
+ self?.updateState(self?.isPlaying == true ? .playing : .paused)
369
+ self?.onSeek?(time)
370
+ })
371
+ }
372
+
373
+ @objc public func seekForward(seconds: Double = 10) {
374
+ let newTime = currentTime + seconds
375
+ seek(to: min(newTime, duration))
376
+ }
377
+
378
+ @objc public func seekBackward(seconds: Double = 10) {
379
+ let newTime = currentTime - seconds
380
+ seek(to: max(newTime, 0))
381
+ }
382
+
383
+ // MARK: - Volume Control
384
+
385
+ @objc public func setVolume(_ volume: Float) {
386
+ let clampedVolume = max(0, min(1, volume))
387
+ player?.volume = clampedVolume
388
+ onVolumeChange?(clampedVolume)
389
+ }
390
+
391
+ @objc public func mute() {
392
+ player?.isMuted = true
393
+ }
394
+
395
+ @objc public func unmute() {
396
+ player?.isMuted = false
397
+ }
398
+
399
+ @objc public func toggleMute() {
400
+ player?.isMuted = !(player?.isMuted ?? false)
401
+ }
402
+
403
+ // MARK: - Playback Rate
404
+
405
+ @objc public func setPlaybackRate(_ rate: Float) {
406
+ player?.rate = rate
407
+ }
408
+
409
+ @objc public func getPlaybackRate() -> Float {
410
+ return player?.rate ?? 1.0
411
+ }
412
+
413
+ // MARK: - State Management
414
+
415
+ private func updateState(_ newState: PlayerState) {
416
+ state = newState
417
+ onStateChange?(newState)
418
+
419
+ if configuration?.debug == true {
420
+ print("[UnifiedVideoPlayer] State changed to: \(newState)")
421
+ }
422
+ }
423
+
424
+ // MARK: - Observers
425
+
426
+ private func setupObservers() {
427
+ // Time observer
428
+ let interval = CMTime(seconds: 0.1, preferredTimescale: 1000)
429
+ timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
430
+ self?.handleTimeUpdate(time)
431
+ }
432
+
433
+ // Rate observer (play/pause)
434
+ rateObserver = player?.observe(\.rate, options: [.new]) { [weak self] player, _ in
435
+ self?.handleRateChange(player.rate)
436
+ }
437
+
438
+ // Status observer
439
+ statusObserver = player?.observe(\.status, options: [.new]) { [weak self] player, _ in
440
+ self?.handleStatusChange(player.status)
441
+ }
442
+
443
+ // Notification observers
444
+ NotificationCenter.default.addObserver(
445
+ self,
446
+ selector: #selector(playerDidFinishPlaying),
447
+ name: .AVPlayerItemDidPlayToEndTime,
448
+ object: nil
449
+ )
450
+
451
+ NotificationCenter.default.addObserver(
452
+ self,
453
+ selector: #selector(handlePlaybackStalled),
454
+ name: .AVPlayerItemPlaybackStalled,
455
+ object: nil
456
+ )
457
+ }
458
+
459
+ private func observePlayerItem(_ playerItem: AVPlayerItem) {
460
+ currentItemObserver = playerItem.observe(\.status, options: [.new]) { [weak self] item, _ in
461
+ self?.handleItemStatusChange(item.status)
462
+ }
463
+
464
+ // Observe buffering
465
+ playerItem.addObserver(self, forKeyPath: "playbackBufferEmpty", options: .new, context: nil)
466
+ playerItem.addObserver(self, forKeyPath: "playbackLikelyToKeepUp", options: .new, context: nil)
467
+ playerItem.addObserver(self, forKeyPath: "playbackBufferFull", options: .new, context: nil)
468
+ }
469
+
470
+ override public func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
471
+ if keyPath == "playbackBufferEmpty" {
472
+ updateState(.buffering)
473
+ onBuffering?(true)
474
+ } else if keyPath == "playbackLikelyToKeepUp" || keyPath == "playbackBufferFull" {
475
+ if isPlaying {
476
+ updateState(.playing)
477
+ } else {
478
+ updateState(.paused)
479
+ }
480
+ onBuffering?(false)
481
+ }
482
+ }
483
+
484
+ private func handleTimeUpdate(_ time: CMTime) {
485
+ currentTime = time.seconds
486
+ onTimeUpdate?(currentTime)
487
+
488
+ // Update buffered time
489
+ if let timeRanges = player?.currentItem?.loadedTimeRanges,
490
+ let range = timeRanges.first?.timeRangeValue {
491
+ let bufferedEnd = range.start + range.duration
492
+ bufferedTime = bufferedEnd.seconds
493
+ onProgress?(bufferedTime)
494
+ }
495
+
496
+ // Sync lock screen info
497
+ remoteCenter.updateProgress(elapsed: currentTime, duration: duration, isPlaying: player?.timeControlStatus == .playing)
498
+ }
499
+
500
+ private func handleRateChange(_ rate: Float) {
501
+ isPlaying = rate > 0
502
+ }
503
+
504
+ private func handleStatusChange(_ status: AVPlayer.Status) {
505
+ switch status {
506
+ case .readyToPlay:
507
+ updateDuration()
508
+ updateState(.ready)
509
+ onReady?()
510
+ emitLoadedMetadata()
511
+ case .failed:
512
+ if let error = player?.error {
513
+ handleError(error)
514
+ }
515
+ default:
516
+ break
517
+ }
518
+ }
519
+
520
+ private func handleItemStatusChange(_ status: AVPlayerItem.Status) {
521
+ switch status {
522
+ case .readyToPlay:
523
+ updateDuration()
524
+ case .failed:
525
+ if let error = player?.currentItem?.error {
526
+ handleError(error)
527
+ }
528
+ default:
529
+ break
530
+ }
531
+ }
532
+
533
+ @objc private func playerDidFinishPlaying() {
534
+ updateState(.ended)
535
+ onEnded?()
536
+
537
+ if configuration?.loop == true {
538
+ seek(to: 0)
539
+ play()
540
+ }
541
+ }
542
+
543
+ @objc private func handlePlaybackStalled() {
544
+ updateState(.buffering)
545
+ onBuffering?(true)
546
+ }
547
+
548
+ private func updateDuration() {
549
+ if let duration = player?.currentItem?.duration {
550
+ self.duration = duration.seconds
551
+ }
552
+ }
553
+
554
+ private func emitLoadedMetadata() {
555
+ guard let item = player?.currentItem else { return }
556
+
557
+ var metadata: [String: Any] = [:]
558
+ metadata["duration"] = duration
559
+
560
+ if let videoTrack = item.asset.tracks(withMediaType: .video).first {
561
+ metadata["width"] = videoTrack.naturalSize.width
562
+ metadata["height"] = videoTrack.naturalSize.height
563
+ metadata["fps"] = videoTrack.nominalFrameRate
564
+ }
565
+
566
+ if let audioTrack = item.asset.tracks(withMediaType: .audio).first {
567
+ metadata["audioChannels"] = audioTrack.formatDescriptions
568
+ }
569
+
570
+ onLoadedMetadata?(metadata)
571
+ }
572
+
573
+ // MARK: - Tracks / PiP / AirPlay
574
+
575
+ @objc public func audioTracks() -> [String] {
576
+ guard let group = player?.currentItem?.asset.mediaSelectionGroup(forMediaCharacteristic: .audible) else { return [] }
577
+ return group.options.map { $0.displayName }
578
+ }
579
+
580
+ @objc public func subtitleTracks() -> [String] {
581
+ guard let group = player?.currentItem?.asset.mediaSelectionGroup(forMediaCharacteristic: .legible) else { return [] }
582
+ return group.options.map { $0.displayName }
583
+ }
584
+
585
+ @objc public func selectAudioTrack(index: Int) {
586
+ guard let group = player?.currentItem?.asset.mediaSelectionGroup(forMediaCharacteristic: .audible), index >= 0, index < group.options.count else { return }
587
+ let option = group.options[index]
588
+ player?.currentItem?.select(option, in: group)
589
+ }
590
+
591
+ @objc public func selectSubtitleTrack(index: Int) {
592
+ guard let group = player?.currentItem?.asset.mediaSelectionGroup(forMediaCharacteristic: .legible) else { return }
593
+ if index >= 0, index < group.options.count {
594
+ let option = group.options[index]
595
+ player?.currentItem?.select(option, in: group)
596
+ } else {
597
+ player?.currentItem?.select(nil, in: group)
598
+ }
599
+ }
600
+
601
+ @objc public func startPictureInPicture() {
602
+ guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
603
+ if pipController == nil, let layer = playerLayer { pipController = AVPictureInPictureController(playerLayer: layer) }
604
+ pipController?.startPictureInPicture()
605
+ }
606
+
607
+ @objc public func stopPictureInPicture() {
608
+ pipController?.stopPictureInPicture()
609
+ }
610
+
611
+ @objc public func makeAirPlayPickerView(frame: CGRect = .zero) -> AVRoutePickerView {
612
+ return AVRoutePickerView(frame: frame)
613
+ }
614
+
615
+ // MARK: - UI Controls
616
+
617
+ private func addDefaultControls() {
618
+ guard let container = containerView else { return }
619
+
620
+ // Create a simple control overlay
621
+ let controlsView = UIView(frame: container.bounds)
622
+ controlsView.backgroundColor = UIColor.black.withAlphaComponent(0.3)
623
+ controlsView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
624
+ controlsView.tag = 999 // Tag for identification
625
+
626
+ // Play/Pause button (SF Symbol)
627
+ let playPauseButton = UIButton(type: .system)
628
+ playPauseButton.tag = 1001
629
+ playPauseButton.frame = CGRect(x: container.bounds.width/2 - 30, y: container.bounds.height/2 - 30, width: 60, height: 60)
630
+ let initialIcon = UIImage(systemName: "play.fill")
631
+ playPauseButton.setImage(initialIcon, for: .normal)
632
+ playPauseButton.imageView?.contentMode = .scaleAspectFit
633
+ playPauseButton.tintColor = themeUIColor ?? .white
634
+ playPauseButton.addTarget(self, action: #selector(handlePlayPauseButton), for: .touchUpInside)
635
+
636
+ controlsView.addSubview(playPauseButton)
637
+
638
+ // AirPlay route picker (top-right)
639
+ let routePicker = AVRoutePickerView(frame: CGRect(x: container.bounds.width - 56, y: 16, width: 40, height: 40))
640
+ routePicker.tintColor = themeUIColor ?? .white
641
+ if #available(iOS 13.0, *) { routePicker.activeTintColor = themeUIColor ?? .white }
642
+ routePicker.autoresizingMask = [.flexibleLeftMargin, .flexibleBottomMargin]
643
+ controlsView.addSubview(routePicker)
644
+
645
+ // PiP button next to AirPlay
646
+ let pipButton = UIButton(type: .system)
647
+ pipButton.tag = 1002
648
+ pipButton.frame = CGRect(x: routePicker.frame.minX - 48, y: 16, width: 40, height: 40)
649
+ let pipIcon: UIImage?
650
+ if #available(iOS 15.0, *) { pipIcon = UIImage(systemName: "pip") } else { pipIcon = UIImage(systemName: "rectangle") }
651
+ pipButton.setImage(pipIcon, for: .normal)
652
+ pipButton.tintColor = themeUIColor ?? .white
653
+ pipButton.addTarget(self, action: #selector(handlePiPButton), for: .touchUpInside)
654
+ pipButton.autoresizingMask = [.flexibleLeftMargin, .flexibleBottomMargin]
655
+ controlsView.addSubview(pipButton)
656
+
657
+ container.addSubview(controlsView)
658
+
659
+ // Auto-hide controls
660
+ setupControlsAutoHide(controlsView)
661
+ }
662
+
663
+ private func setupControlsAutoHide(_ controlsView: UIView) {
664
+ // Hide controls after 3 seconds
665
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak controlsView] in
666
+ UIView.animate(withDuration: 0.3) {
667
+ controlsView?.alpha = 0
668
+ }
669
+ }
670
+
671
+ // Show controls on tap
672
+ let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleControlsTap))
673
+ containerView?.addGestureRecognizer(tapGesture)
674
+ }
675
+
676
+ @objc private func handlePlayPauseButton() {
677
+ togglePlayPause()
678
+ }
679
+
680
+ private func updatePlayPauseIcon() {
681
+ guard let controlsView = containerView?.viewWithTag(999),
682
+ let button = controlsView.viewWithTag(1001) as? UIButton else { return }
683
+ let iconName = isPlaying ? "pause.fill" : "play.fill"
684
+ button.setImage(UIImage(systemName: iconName), for: .normal)
685
+ }
686
+
687
+ @objc private func handlePiPButton() {
688
+ if let c = pipController, c.isPictureInPictureActive {
689
+ c.stopPictureInPicture()
690
+ } else {
691
+ startPictureInPicture()
692
+ }
693
+ updatePiPButtonIcon()
694
+ }
695
+
696
+ private func updatePiPButtonIcon() {
697
+ guard let controlsView = containerView?.viewWithTag(999),
698
+ let button = controlsView.viewWithTag(1002) as? UIButton else { return }
699
+ if #available(iOS 15.0, *) {
700
+ let name = (pipController?.isPictureInPictureActive ?? false) ? "pip.exit" : "pip"
701
+ button.setImage(UIImage(systemName: name), for: .normal)
702
+ } else {
703
+ button.setImage(UIImage(systemName: "rectangle"), for: .normal)
704
+ }
705
+ }
706
+
707
+ @objc private func handleControlsTap() {
708
+ guard let controlsView = containerView?.viewWithTag(999) else { return }
709
+
710
+ UIView.animate(withDuration: 0.3) {
711
+ controlsView.alpha = controlsView.alpha == 0 ? 1 : 0
712
+ }
713
+
714
+ if controlsView.alpha == 1 {
715
+ // Auto-hide after 3 seconds
716
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak controlsView] in
717
+ UIView.animate(withDuration: 0.3) {
718
+ controlsView?.alpha = 0
719
+ }
720
+ }
721
+ }
722
+ }
723
+
724
+ // MARK: - Error Handling
725
+
726
+ private func handleError(_ error: Error) {
727
+ updateState(.error)
728
+ onError?(error)
729
+
730
+ if configuration?.debug == true {
731
+ print("[UnifiedVideoPlayer] Error: \(error.localizedDescription)")
732
+ }
733
+ }
734
+
735
+ // MARK: - Debug
736
+
737
+ private func enableDebugLogging() {
738
+ print("[UnifiedVideoPlayer] Debug mode enabled")
739
+ print("[UnifiedVideoPlayer] Configuration: \(String(describing: configuration))")
740
+ }
741
+
742
+ // MARK: - Cleanup
743
+
744
+ @objc public func destroy() {
745
+ player?.pause()
746
+
747
+ if let observer = timeObserver {
748
+ player?.removeTimeObserver(observer)
749
+ }
750
+
751
+ statusObserver?.invalidate()
752
+ rateObserver?.invalidate()
753
+ currentItemObserver?.invalidate()
754
+
755
+ NotificationCenter.default.removeObserver(self)
756
+ analyticsEmitter.stopObserving()
757
+ remoteCenter.disable()
758
+
759
+ playerLayer?.removeFromSuperlayer()
760
+ playerViewController?.dismiss(animated: false)
761
+
762
+ player = nil
763
+ playerLayer = nil
764
+ playerViewController = nil
765
+ containerView = nil
766
+
767
+ updateState(.idle)
768
+ }
769
+
770
+ deinit {
771
+ destroy()
772
+ }
773
+ }
774
+
775
+ // MARK: - AVAssetResourceLoaderDelegate (for FairPlay DRM)
776
+ extension UnifiedVideoPlayer: AVAssetResourceLoaderDelegate {
777
+
778
+ public func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
779
+ // FairPlay DRM implementation
780
+ guard let url = loadingRequest.request.url,
781
+ url.scheme == "skd",
782
+ let drm = currentSource?.drm,
783
+ drm.type == "fairplay" else {
784
+ return false
785
+ }
786
+
787
+ // Handle FairPlay license request
788
+ handleFairPlayRequest(loadingRequest, drm: drm)
789
+ return true
790
+ }
791
+
792
+ private func handleFairPlayRequest(_ loadingRequest: AVAssetResourceLoadingRequest, drm: DRMConfiguration) {
793
+ // Implementation for FairPlay license acquisition
794
+ // This would include:
795
+ // 1. Getting the content ID from the URL
796
+ // 2. Getting the SPC (Server Playback Context) from the request
797
+ // 3. Sending the SPC to the license server
798
+ // 4. Processing the CKC (Content Key Context) response
799
+ // 5. Providing the CKC to the loading request
800
+
801
+ // Simplified example:
802
+ guard let certificateUrl = drm.certificateUrl,
803
+ let certificateURL = URL(string: certificateUrl) else {
804
+ loadingRequest.finishLoading(with: NSError(domain: "UnifiedVideoPlayer", code: -2, userInfo: nil))
805
+ return
806
+ }
807
+
808
+ // This is a simplified implementation
809
+ // Real implementation would involve proper FairPlay flow
810
+ }
811
+ }