@capgo/capacitor-video-player 8.1.23 → 8.1.25
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.
- package/README.md +62 -29
- package/android/src/main/java/com/capgo/videoplayer/FullscreenExoPlayerFragment.java +38 -0
- package/android/src/main/java/com/capgo/videoplayer/VideoPlayer.java +2 -0
- package/android/src/main/java/com/capgo/videoplayer/VideoPlayerPlugin.java +83 -1
- package/dist/docs.json +46 -1
- package/dist/esm/definitions.d.ts +33 -0
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web-utils/videoplayer.d.ts +4 -0
- package/dist/esm/web-utils/videoplayer.js +29 -0
- package/dist/esm/web-utils/videoplayer.js.map +1 -1
- package/dist/esm/web.d.ts +2 -0
- package/dist/esm/web.js +36 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +65 -0
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +65 -0
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/VideoPlayerPlugin/FullscreenVideoPlayer.swift +227 -18
- package/ios/Sources/VideoPlayerPlugin/HLSSubtitleResourceLoader.swift +59 -15
- package/ios/Sources/VideoPlayerPlugin/VideoPlayerCastController.swift +8 -2
- package/ios/Sources/VideoPlayerPlugin/VideoPlayerPlugin.swift +69 -3
- package/package.json +1 -1
|
@@ -14,10 +14,14 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
14
14
|
private var pipEnabled: Bool
|
|
15
15
|
private var showControls: Bool
|
|
16
16
|
private var chromecast: Bool
|
|
17
|
+
private var chromecastUrl: String?
|
|
17
18
|
private var title: String?
|
|
18
19
|
private var smallTitle: String?
|
|
19
20
|
private var artwork: String?
|
|
20
21
|
private var rate: Float
|
|
22
|
+
private var audioCategory: String?
|
|
23
|
+
private var didActivateAudioSession: Bool = false
|
|
24
|
+
private var didEmitExit: Bool = false
|
|
21
25
|
private var timeObserver: Any?
|
|
22
26
|
private var onPlay: (() -> Void)?
|
|
23
27
|
private var onPause: (() -> Void)?
|
|
@@ -26,6 +30,8 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
26
30
|
private var onExit: ((Double) -> Void)?
|
|
27
31
|
private var fairplayCertificateUrl: String?
|
|
28
32
|
private var fairplayContentKeySpcUrl: String?
|
|
33
|
+
private var fairplayAssetId: String?
|
|
34
|
+
private var widevineLicenseUrl: String?
|
|
29
35
|
private var contentKeySession: AVContentKeySession?
|
|
30
36
|
private var castController: VideoPlayerCastController?
|
|
31
37
|
private weak var presentingViewController: UIViewController?
|
|
@@ -42,13 +48,17 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
42
48
|
pipEnabled: Bool,
|
|
43
49
|
showControls: Bool,
|
|
44
50
|
chromecast: Bool,
|
|
51
|
+
chromecastUrl: String? = nil,
|
|
45
52
|
title: String? = nil,
|
|
46
53
|
smallTitle: String? = nil,
|
|
47
54
|
artwork: String? = nil,
|
|
48
55
|
subtitleUrl: String? = nil,
|
|
49
56
|
subtitleLanguage: String? = nil,
|
|
50
57
|
fairplayCertificateUrl: String? = nil,
|
|
51
|
-
fairplayContentKeySpcUrl: String? = nil
|
|
58
|
+
fairplayContentKeySpcUrl: String? = nil,
|
|
59
|
+
fairplayAssetId: String? = nil,
|
|
60
|
+
widevineLicenseUrl: String? = nil,
|
|
61
|
+
audioCategory: String? = nil
|
|
52
62
|
) {
|
|
53
63
|
self.playerId = playerId
|
|
54
64
|
self.videoUrl = url
|
|
@@ -58,6 +68,7 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
58
68
|
self.pipEnabled = pipEnabled
|
|
59
69
|
self.showControls = showControls
|
|
60
70
|
self.chromecast = chromecast
|
|
71
|
+
self.chromecastUrl = chromecastUrl
|
|
61
72
|
self.title = title
|
|
62
73
|
self.smallTitle = smallTitle
|
|
63
74
|
self.artwork = artwork
|
|
@@ -65,6 +76,9 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
65
76
|
self.subtitleLanguage = subtitleLanguage
|
|
66
77
|
self.fairplayCertificateUrl = fairplayCertificateUrl
|
|
67
78
|
self.fairplayContentKeySpcUrl = fairplayContentKeySpcUrl
|
|
79
|
+
self.fairplayAssetId = fairplayAssetId
|
|
80
|
+
self.widevineLicenseUrl = widevineLicenseUrl
|
|
81
|
+
self.audioCategory = audioCategory
|
|
68
82
|
super.init()
|
|
69
83
|
}
|
|
70
84
|
|
|
@@ -74,6 +88,8 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
74
88
|
return
|
|
75
89
|
}
|
|
76
90
|
|
|
91
|
+
configureAudioSession()
|
|
92
|
+
|
|
77
93
|
guard let subtitleUrlString = subtitleUrl,
|
|
78
94
|
!subtitleUrlString.isEmpty,
|
|
79
95
|
let subtitleURL = URL(string: subtitleUrlString) else {
|
|
@@ -113,7 +129,8 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
113
129
|
hlsResourceLoader = result.resourceLoader
|
|
114
130
|
let asset = result.asset
|
|
115
131
|
|
|
116
|
-
if let certUrl = fairplayCertificateUrl, !certUrl.isEmpty
|
|
132
|
+
if let certUrl = fairplayCertificateUrl, !certUrl.isEmpty,
|
|
133
|
+
let spcUrl = fairplayContentKeySpcUrl, !spcUrl.isEmpty {
|
|
117
134
|
let session = AVContentKeySession(keySystem: .fairPlayStreaming)
|
|
118
135
|
session.setDelegate(self, queue: DispatchQueue.global(qos: .default))
|
|
119
136
|
session.addContentKeyRecipient(asset)
|
|
@@ -132,9 +149,7 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
132
149
|
playerViewController?.player = player
|
|
133
150
|
playerViewController?.showsPlaybackControls = showControls
|
|
134
151
|
playerViewController?.allowsPictureInPicturePlayback = pipEnabled
|
|
135
|
-
|
|
136
|
-
playerViewController?.delegate = self
|
|
137
|
-
}
|
|
152
|
+
playerViewController?.delegate = self
|
|
138
153
|
|
|
139
154
|
setupChromecast()
|
|
140
155
|
setupObservers()
|
|
@@ -147,11 +162,19 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
147
162
|
return
|
|
148
163
|
}
|
|
149
164
|
|
|
165
|
+
let castVideoUrl: String
|
|
166
|
+
if let chromecastUrl = chromecastUrl, !chromecastUrl.isEmpty {
|
|
167
|
+
castVideoUrl = chromecastUrl
|
|
168
|
+
} else {
|
|
169
|
+
castVideoUrl = videoUrl
|
|
170
|
+
}
|
|
171
|
+
|
|
150
172
|
castController = VideoPlayerCastController(
|
|
151
|
-
videoUrl:
|
|
173
|
+
videoUrl: castVideoUrl,
|
|
152
174
|
title: title,
|
|
153
175
|
smallTitle: smallTitle,
|
|
154
|
-
artwork: artwork
|
|
176
|
+
artwork: artwork,
|
|
177
|
+
widevineLicenseUrl: widevineLicenseUrl
|
|
155
178
|
)
|
|
156
179
|
castController?.setOnPlay { [weak self] in
|
|
157
180
|
self?.onPlay?()
|
|
@@ -235,23 +258,54 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
235
258
|
|
|
236
259
|
presentingViewController = viewController
|
|
237
260
|
viewController.present(playerVC, animated: true) {
|
|
261
|
+
playerVC.presentationController?.delegate = self
|
|
238
262
|
self.play()
|
|
239
263
|
completion()
|
|
240
264
|
}
|
|
241
265
|
}
|
|
242
266
|
|
|
267
|
+
func show(on viewController: UIViewController, completion: @escaping () -> Void) {
|
|
268
|
+
guard let playerVC = playerViewController else {
|
|
269
|
+
completion()
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
if playerVC.presentingViewController != nil {
|
|
273
|
+
completion()
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
presentingViewController = viewController
|
|
277
|
+
viewController.present(playerVC, animated: true) {
|
|
278
|
+
playerVC.presentationController?.delegate = self
|
|
279
|
+
completion()
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
func hide(completion: @escaping () -> Void) {
|
|
284
|
+
guard let playerVC = playerViewController else {
|
|
285
|
+
completion()
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
if playerVC.presentingViewController == nil {
|
|
289
|
+
completion()
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
playerVC.dismiss(animated: true) {
|
|
293
|
+
completion()
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
243
297
|
func dismiss() {
|
|
244
298
|
let currentTime = getCurrentTime()
|
|
245
|
-
castController?.detach(stopRemoteMedia: true)
|
|
246
299
|
playerViewController?.dismiss(animated: true) { [weak self] in
|
|
247
|
-
self?.
|
|
248
|
-
self?.onExit?(currentTime)
|
|
300
|
+
self?.emitExitIfNeeded(currentTime: currentTime)
|
|
249
301
|
}
|
|
250
302
|
}
|
|
251
303
|
|
|
252
|
-
private func cleanup() {
|
|
253
|
-
castController?.detach(stopRemoteMedia:
|
|
304
|
+
private func cleanup(stopRemoteMedia: Bool = false) {
|
|
305
|
+
castController?.detach(stopRemoteMedia: stopRemoteMedia)
|
|
254
306
|
castController = nil
|
|
307
|
+
contentKeySession?.setDelegate(nil, queue: nil)
|
|
308
|
+
contentKeySession = nil
|
|
255
309
|
hlsResourceLoader = nil
|
|
256
310
|
if let observer = timeObserver {
|
|
257
311
|
player?.removeObserver(self, forKeyPath: "rate")
|
|
@@ -264,6 +318,58 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
264
318
|
player = nil
|
|
265
319
|
playerItem = nil
|
|
266
320
|
playerViewController = nil
|
|
321
|
+
deactivateAudioSessionIfNeeded()
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private func configureAudioSession() {
|
|
325
|
+
guard let audioCategory else { return }
|
|
326
|
+
|
|
327
|
+
let session = AVAudioSession.sharedInstance()
|
|
328
|
+
do {
|
|
329
|
+
switch audioCategory {
|
|
330
|
+
case "ambient":
|
|
331
|
+
try session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
|
|
332
|
+
case "playback":
|
|
333
|
+
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
|
334
|
+
case "moviePlayback":
|
|
335
|
+
if #available(iOS 13.0, *) {
|
|
336
|
+
try session.setCategory(
|
|
337
|
+
.playback,
|
|
338
|
+
mode: .moviePlayback,
|
|
339
|
+
policy: .longFormVideo,
|
|
340
|
+
options: [.mixWithOthers]
|
|
341
|
+
)
|
|
342
|
+
} else {
|
|
343
|
+
try session.setCategory(.playback, mode: .moviePlayback, options: [.mixWithOthers])
|
|
344
|
+
}
|
|
345
|
+
default:
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
try session.setActive(true)
|
|
350
|
+
didActivateAudioSession = true
|
|
351
|
+
} catch {
|
|
352
|
+
print("Error configuring AVAudioSession: \(error)")
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private func deactivateAudioSessionIfNeeded() {
|
|
357
|
+
guard didActivateAudioSession else { return }
|
|
358
|
+
do {
|
|
359
|
+
try AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
|
|
360
|
+
didActivateAudioSession = false
|
|
361
|
+
} catch {
|
|
362
|
+
print("Error deactivating AVAudioSession: \(error)")
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private func emitExitIfNeeded(currentTime: Double? = nil) {
|
|
367
|
+
guard !didEmitExit else { return }
|
|
368
|
+
didEmitExit = true
|
|
369
|
+
|
|
370
|
+
let time = currentTime ?? getCurrentTime()
|
|
371
|
+
cleanup(stopRemoteMedia: true)
|
|
372
|
+
onExit?(time)
|
|
267
373
|
}
|
|
268
374
|
|
|
269
375
|
// MARK: - Playback Control
|
|
@@ -396,6 +502,19 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
396
502
|
// MARK: - AVPlayerViewControllerDelegate (Picture in Picture)
|
|
397
503
|
|
|
398
504
|
extension FullscreenVideoPlayer: AVPlayerViewControllerDelegate {
|
|
505
|
+
func playerViewControllerWillEndFullScreenPresentation(
|
|
506
|
+
_ playerViewController: AVPlayerViewController,
|
|
507
|
+
withAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
|
|
508
|
+
) {
|
|
509
|
+
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
|
|
510
|
+
self?.emitExitIfNeeded()
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
func playerViewControllerDidEndFullScreenPresentation(_ playerViewController: AVPlayerViewController) {
|
|
515
|
+
emitExitIfNeeded()
|
|
516
|
+
}
|
|
517
|
+
|
|
399
518
|
func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(
|
|
400
519
|
_ playerViewController: AVPlayerViewController
|
|
401
520
|
) -> Bool {
|
|
@@ -423,6 +542,14 @@ extension FullscreenVideoPlayer: AVPlayerViewControllerDelegate {
|
|
|
423
542
|
}
|
|
424
543
|
}
|
|
425
544
|
|
|
545
|
+
// MARK: - UIAdaptivePresentationControllerDelegate
|
|
546
|
+
|
|
547
|
+
extension FullscreenVideoPlayer: UIAdaptivePresentationControllerDelegate {
|
|
548
|
+
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
|
549
|
+
emitExitIfNeeded()
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
426
553
|
// MARK: - AVContentKeySessionDelegate (FairPlay DRM)
|
|
427
554
|
|
|
428
555
|
extension FullscreenVideoPlayer: AVContentKeySessionDelegate {
|
|
@@ -434,6 +561,61 @@ extension FullscreenVideoPlayer: AVContentKeySessionDelegate {
|
|
|
434
561
|
handleFairPlayKeyRequest(keyRequest)
|
|
435
562
|
}
|
|
436
563
|
|
|
564
|
+
private func fairPlayContentIdentifierData(from identifier: Any?) -> Data? {
|
|
565
|
+
if let data = identifier as? Data {
|
|
566
|
+
return data.isEmpty ? nil : data
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
let identifierString: String?
|
|
570
|
+
if let url = identifier as? URL {
|
|
571
|
+
identifierString = url.absoluteString
|
|
572
|
+
} else if let string = identifier as? String {
|
|
573
|
+
identifierString = string
|
|
574
|
+
} else {
|
|
575
|
+
identifierString = nil
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
guard var string = identifierString?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
579
|
+
!string.isEmpty else {
|
|
580
|
+
return nil
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if string.hasPrefix("skd://") {
|
|
584
|
+
string.removeFirst("skd://".count)
|
|
585
|
+
} else if string.hasPrefix("skd:") {
|
|
586
|
+
string.removeFirst("skd:".count)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
while string.hasPrefix("/") {
|
|
590
|
+
string.removeFirst()
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return string.data(using: .utf8)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private func normalizeFairPlayCkcData(_ data: Data) -> Data {
|
|
597
|
+
if let object = try? JSONSerialization.jsonObject(with: data),
|
|
598
|
+
let dict = object as? [String: Any] {
|
|
599
|
+
let candidateKeys = ["ckc", "CKC", "license", "License", "data"]
|
|
600
|
+
for key in candidateKeys {
|
|
601
|
+
if let value = dict[key] as? String,
|
|
602
|
+
let decoded = Data(base64Encoded: value) {
|
|
603
|
+
return decoded
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if let string = String(data: data, encoding: .utf8) {
|
|
609
|
+
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
610
|
+
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
|
|
611
|
+
if let decoded = Data(base64Encoded: trimmed) {
|
|
612
|
+
return decoded
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return data
|
|
617
|
+
}
|
|
618
|
+
|
|
437
619
|
private func handleFairPlayKeyRequest(_ keyRequest: AVContentKeyRequest) {
|
|
438
620
|
guard let certUrlString = fairplayCertificateUrl,
|
|
439
621
|
let certUrl = URL(string: certUrlString),
|
|
@@ -447,7 +629,17 @@ extension FullscreenVideoPlayer: AVContentKeySessionDelegate {
|
|
|
447
629
|
}
|
|
448
630
|
|
|
449
631
|
// 1. Fetch the FairPlay certificate
|
|
450
|
-
URLSession.shared.dataTask(with: certUrl) { certData,
|
|
632
|
+
URLSession.shared.dataTask(with: certUrl) { [self] certData, certResponse, certError in
|
|
633
|
+
if let httpResponse = certResponse as? HTTPURLResponse,
|
|
634
|
+
!(200...299).contains(httpResponse.statusCode) {
|
|
635
|
+
keyRequest.processContentKeyResponseError(
|
|
636
|
+
NSError(domain: "VideoPlayer", code: -2, userInfo: [
|
|
637
|
+
NSLocalizedDescriptionKey: "Failed to fetch FairPlay certificate (HTTP \(httpResponse.statusCode))"
|
|
638
|
+
])
|
|
639
|
+
)
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
|
|
451
643
|
guard let certData = certData else {
|
|
452
644
|
keyRequest.processContentKeyResponseError(
|
|
453
645
|
certError ?? NSError(domain: "VideoPlayer", code: -2,
|
|
@@ -457,11 +649,17 @@ extension FullscreenVideoPlayer: AVContentKeySessionDelegate {
|
|
|
457
649
|
}
|
|
458
650
|
|
|
459
651
|
// 2. Create SPC (Server Playback Context) using the certificate
|
|
460
|
-
|
|
461
|
-
|
|
652
|
+
let contentIdentifier: Data? = {
|
|
653
|
+
if let assetId = fairplayAssetId?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
654
|
+
!assetId.isEmpty {
|
|
655
|
+
return assetId.data(using: .utf8)
|
|
656
|
+
}
|
|
657
|
+
return fairPlayContentIdentifierData(from: keyRequest.identifier)
|
|
658
|
+
}()
|
|
659
|
+
|
|
462
660
|
keyRequest.makeStreamingContentKeyRequestData(
|
|
463
661
|
forApp: certData,
|
|
464
|
-
contentIdentifier:
|
|
662
|
+
contentIdentifier: contentIdentifier,
|
|
465
663
|
options: [AVContentKeyRequestProtocolVersionsKey: [1]]
|
|
466
664
|
) { spcData, spcError in
|
|
467
665
|
guard let spcData = spcData else {
|
|
@@ -478,7 +676,17 @@ extension FullscreenVideoPlayer: AVContentKeySessionDelegate {
|
|
|
478
676
|
spcRequest.httpBody = spcData
|
|
479
677
|
spcRequest.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
|
|
480
678
|
|
|
481
|
-
URLSession.shared.dataTask(with: spcRequest) { ckcData,
|
|
679
|
+
URLSession.shared.dataTask(with: spcRequest) { [self] ckcData, ckcResponse, ckcError in
|
|
680
|
+
if let httpResponse = ckcResponse as? HTTPURLResponse,
|
|
681
|
+
!(200...299).contains(httpResponse.statusCode) {
|
|
682
|
+
keyRequest.processContentKeyResponseError(
|
|
683
|
+
NSError(domain: "VideoPlayer", code: -3, userInfo: [
|
|
684
|
+
NSLocalizedDescriptionKey: "Failed to obtain FairPlay CKC (HTTP \(httpResponse.statusCode))"
|
|
685
|
+
])
|
|
686
|
+
)
|
|
687
|
+
return
|
|
688
|
+
}
|
|
689
|
+
|
|
482
690
|
guard let ckcData = ckcData else {
|
|
483
691
|
keyRequest.processContentKeyResponseError(
|
|
484
692
|
ckcError ?? NSError(domain: "VideoPlayer", code: -3,
|
|
@@ -488,7 +696,8 @@ extension FullscreenVideoPlayer: AVContentKeySessionDelegate {
|
|
|
488
696
|
}
|
|
489
697
|
|
|
490
698
|
// 4. Provide the CKC to AVFoundation to decrypt the content
|
|
491
|
-
let
|
|
699
|
+
let normalizedCkcData = normalizeFairPlayCkcData(ckcData)
|
|
700
|
+
let keyResponse = AVContentKeyResponse(fairPlayStreamingKeyResponseData: normalizedCkcData)
|
|
492
701
|
keyRequest.processContentKeyResponse(keyResponse)
|
|
493
702
|
}.resume()
|
|
494
703
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import AVFoundation
|
|
2
2
|
import Foundation
|
|
3
|
+
import UniformTypeIdentifiers
|
|
3
4
|
|
|
4
5
|
enum HLSVideoAssetFactory {
|
|
5
6
|
static func isHLSStream(_ url: URL) -> Bool {
|
|
@@ -43,11 +44,17 @@ final class HLSSubtitleResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
|
|
43
44
|
static let videoScheme = "capgohls"
|
|
44
45
|
static let subtitlePlaylistScheme = "capgohls-sub"
|
|
45
46
|
private static let subtitleGroupID = "capgosubs"
|
|
47
|
+
private static let mpegURLContentType = UTType.m3uPlaylist.identifier
|
|
46
48
|
|
|
47
49
|
private let originalVideoURL: URL
|
|
48
50
|
private let subtitleURL: URL
|
|
49
51
|
private let language: String
|
|
50
52
|
|
|
53
|
+
private struct LoadedResource {
|
|
54
|
+
let data: Data
|
|
55
|
+
let contentType: String?
|
|
56
|
+
}
|
|
57
|
+
|
|
51
58
|
init(videoURL: URL, subtitleURL: URL, language: String) {
|
|
52
59
|
self.originalVideoURL = videoURL
|
|
53
60
|
self.subtitleURL = subtitleURL
|
|
@@ -80,8 +87,8 @@ final class HLSSubtitleResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
|
|
80
87
|
|
|
81
88
|
Task {
|
|
82
89
|
do {
|
|
83
|
-
let
|
|
84
|
-
fulfill(loadingRequest, with:
|
|
90
|
+
let resource = try await loadData(for: requestURL)
|
|
91
|
+
fulfill(loadingRequest, with: resource)
|
|
85
92
|
} catch {
|
|
86
93
|
loadingRequest.finishLoading(with: error)
|
|
87
94
|
}
|
|
@@ -90,25 +97,36 @@ final class HLSSubtitleResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
|
|
90
97
|
return true
|
|
91
98
|
}
|
|
92
99
|
|
|
93
|
-
private func loadData(for requestURL: URL) async throws ->
|
|
100
|
+
private func loadData(for requestURL: URL) async throws -> LoadedResource {
|
|
94
101
|
if requestURL.scheme == Self.subtitlePlaylistScheme {
|
|
95
|
-
|
|
102
|
+
let data = Data(subtitleMediaPlaylist().utf8)
|
|
103
|
+
return LoadedResource(data: data, contentType: Self.mpegURLContentType)
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
let fetchURL = originalURL(for: requestURL)
|
|
99
|
-
let (data,
|
|
107
|
+
let (data, response) = try await URLSession.shared.data(from: fetchURL)
|
|
108
|
+
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
|
109
|
+
throw URLError(.badServerResponse)
|
|
110
|
+
}
|
|
111
|
+
|
|
100
112
|
let manifest = String(data: data, encoding: .utf8) ?? ""
|
|
101
113
|
|
|
102
114
|
guard hasStreamInfTags(manifest) else {
|
|
103
|
-
|
|
115
|
+
let contentType = Self.contentTypeIdentifier(from: response)
|
|
116
|
+
return LoadedResource(data: data, contentType: contentType)
|
|
104
117
|
}
|
|
105
118
|
|
|
106
119
|
guard let subtitlePlaylistURI = Self.subtitlePlaylistURL(for: originalVideoURL)?.absoluteString else {
|
|
107
|
-
|
|
120
|
+
let contentType = Self.contentTypeIdentifier(from: response) ?? Self.mpegURLContentType
|
|
121
|
+
return LoadedResource(data: data, contentType: contentType)
|
|
108
122
|
}
|
|
109
123
|
|
|
110
|
-
let modified = injectSubtitle(
|
|
111
|
-
|
|
124
|
+
let modified = injectSubtitle(
|
|
125
|
+
into: manifest,
|
|
126
|
+
subtitlePlaylistURI: subtitlePlaylistURI,
|
|
127
|
+
baseURL: response.url ?? fetchURL
|
|
128
|
+
)
|
|
129
|
+
return LoadedResource(data: Data(modified.utf8), contentType: Self.mpegURLContentType)
|
|
112
130
|
}
|
|
113
131
|
|
|
114
132
|
private func originalURL(for requestURL: URL) -> URL {
|
|
@@ -125,7 +143,22 @@ final class HLSSubtitleResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
|
|
125
143
|
manifest.contains("#EXT-X-STREAM-INF")
|
|
126
144
|
}
|
|
127
145
|
|
|
128
|
-
private func
|
|
146
|
+
private static func contentTypeIdentifier(from response: URLResponse) -> String? {
|
|
147
|
+
guard let mimeType = (response as? HTTPURLResponse)?.mimeType else {
|
|
148
|
+
return nil
|
|
149
|
+
}
|
|
150
|
+
return UTType(mimeType: mimeType)?.identifier
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private func absoluteURI(for uri: String, relativeTo baseURL: URL) -> String {
|
|
154
|
+
let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
155
|
+
guard let url = URL(string: trimmed, relativeTo: baseURL)?.absoluteURL else {
|
|
156
|
+
return trimmed
|
|
157
|
+
}
|
|
158
|
+
return url.absoluteString
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private func injectSubtitle(into manifest: String, subtitlePlaylistURI: String, baseURL: URL) -> String {
|
|
129
162
|
let mediaTag = """
|
|
130
163
|
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="\(Self.subtitleGroupID)",NAME="Subtitles",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO,LANGUAGE="\(language)",URI="\(subtitlePlaylistURI)"
|
|
131
164
|
"""
|
|
@@ -133,9 +166,10 @@ final class HLSSubtitleResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
|
|
133
166
|
var lines = manifest.components(separatedBy: .newlines)
|
|
134
167
|
var result: [String] = []
|
|
135
168
|
var insertedMediaTag = false
|
|
169
|
+
var expectingVariantURI = false
|
|
136
170
|
|
|
137
171
|
for line in lines {
|
|
138
|
-
if line.hasPrefix("#EXT-X-STREAM-INF") {
|
|
172
|
+
if line.hasPrefix("#EXT-X-STREAM-INF") || line.hasPrefix("#EXT-X-I-FRAME-STREAM-INF") {
|
|
139
173
|
if !insertedMediaTag {
|
|
140
174
|
result.append(mediaTag)
|
|
141
175
|
insertedMediaTag = true
|
|
@@ -146,9 +180,17 @@ final class HLSSubtitleResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
|
|
146
180
|
} else {
|
|
147
181
|
result.append("\(line),SUBTITLES=\"\(Self.subtitleGroupID)\"")
|
|
148
182
|
}
|
|
183
|
+
expectingVariantURI = true
|
|
149
184
|
continue
|
|
150
185
|
}
|
|
151
186
|
|
|
187
|
+
if expectingVariantURI, !line.hasPrefix("#"), !line.isEmpty {
|
|
188
|
+
result.append(absoluteURI(for: line, relativeTo: baseURL))
|
|
189
|
+
expectingVariantURI = false
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
expectingVariantURI = false
|
|
152
194
|
result.append(line)
|
|
153
195
|
}
|
|
154
196
|
|
|
@@ -172,14 +214,16 @@ final class HLSSubtitleResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
|
|
172
214
|
"""
|
|
173
215
|
}
|
|
174
216
|
|
|
175
|
-
private func fulfill(_ loadingRequest: AVAssetResourceLoadingRequest, with
|
|
217
|
+
private func fulfill(_ loadingRequest: AVAssetResourceLoadingRequest, with resource: LoadedResource) {
|
|
176
218
|
if let contentRequest = loadingRequest.contentInformationRequest {
|
|
177
|
-
|
|
178
|
-
|
|
219
|
+
if let contentType = resource.contentType {
|
|
220
|
+
contentRequest.contentType = contentType
|
|
221
|
+
}
|
|
222
|
+
contentRequest.contentLength = Int64(resource.data.count)
|
|
179
223
|
contentRequest.isByteRangeAccessSupported = false
|
|
180
224
|
}
|
|
181
225
|
|
|
182
|
-
loadingRequest.dataRequest?.respond(with: data)
|
|
226
|
+
loadingRequest.dataRequest?.respond(with: resource.data)
|
|
183
227
|
loadingRequest.finishLoading()
|
|
184
228
|
}
|
|
185
229
|
}
|
|
@@ -20,6 +20,7 @@ final class VideoPlayerCastController: NSObject {
|
|
|
20
20
|
private let title: String?
|
|
21
21
|
private let smallTitle: String?
|
|
22
22
|
private let artwork: String?
|
|
23
|
+
private let widevineLicenseUrl: String?
|
|
23
24
|
|
|
24
25
|
private weak var player: AVPlayer?
|
|
25
26
|
private weak var playerViewController: AVPlayerViewController?
|
|
@@ -52,11 +53,12 @@ final class VideoPlayerCastController: NSObject {
|
|
|
52
53
|
return GCKCastContext.sharedInstance().sessionManager.currentCastSession?.remoteMediaClient
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
init(videoUrl: String, title: String?, smallTitle: String?, artwork: String?) {
|
|
56
|
+
init(videoUrl: String, title: String?, smallTitle: String?, artwork: String?, widevineLicenseUrl: String?) {
|
|
56
57
|
self.videoUrl = videoUrl
|
|
57
58
|
self.title = title
|
|
58
59
|
self.smallTitle = smallTitle
|
|
59
60
|
self.artwork = artwork
|
|
61
|
+
self.widevineLicenseUrl = widevineLicenseUrl
|
|
60
62
|
super.init()
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -529,6 +531,10 @@ private extension VideoPlayerCastController {
|
|
|
529
531
|
builder.streamType = .buffered
|
|
530
532
|
builder.contentType = contentType(for: url)
|
|
531
533
|
builder.metadata = metadata
|
|
534
|
+
if let widevineLicenseUrl = widevineLicenseUrl,
|
|
535
|
+
!widevineLicenseUrl.isEmpty {
|
|
536
|
+
builder.customData = ["laurl": widevineLicenseUrl]
|
|
537
|
+
}
|
|
532
538
|
if let duration = player?.currentItem?.duration.seconds,
|
|
533
539
|
duration.isFinite,
|
|
534
540
|
duration > 0 {
|
|
@@ -737,7 +743,7 @@ final class VideoPlayerCastController {
|
|
|
737
743
|
return false
|
|
738
744
|
}
|
|
739
745
|
|
|
740
|
-
init(videoUrl: String, title: String?, smallTitle: String?, artwork: String?) {
|
|
746
|
+
init(videoUrl: String, title: String?, smallTitle: String?, artwork: String?, widevineLicenseUrl: String?) {
|
|
741
747
|
_ = videoUrl
|
|
742
748
|
_ = title
|
|
743
749
|
_ = smallTitle
|