@capgo/capacitor-video-player 8.2.14 → 8.3.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.
- package/README.md +68 -33
- package/android/src/main/java/com/capgo/videoplayer/FullscreenExoPlayerFragment.java +145 -53
- package/android/src/main/java/com/capgo/videoplayer/SubtitleOptionOverrides.java +84 -0
- package/android/src/main/java/com/capgo/videoplayer/VideoChapter.java +14 -0
- package/android/src/main/java/com/capgo/videoplayer/VideoPlayer.java +3 -0
- package/android/src/main/java/com/capgo/videoplayer/VideoPlayerPlugin.java +48 -2
- package/android/src/main/res/drawable/ic_chapters_white.xml +10 -0
- package/android/src/main/res/layout/exo_playback_control_view.xml +9 -0
- package/android/src/main/res/values/strings.xml +2 -0
- package/dist/docs.json +44 -5
- package/dist/esm/definitions.d.ts +58 -6
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/VideoPlayerPlugin/AVPlayerControlsVisibilityObserver.swift +13 -0
- package/ios/Sources/VideoPlayerPlugin/FullscreenVideoPlayer.swift +243 -33
- package/ios/Sources/VideoPlayerPlugin/SubtitleStyleOptions.swift +7 -0
- package/ios/Sources/VideoPlayerPlugin/VideoChapter.swift +45 -0
- package/ios/Sources/VideoPlayerPlugin/VideoPlayerAudioSessionCoordinator.swift +101 -0
- package/ios/Sources/VideoPlayerPlugin/VideoPlayerCastController.swift +23 -5
- package/ios/Sources/VideoPlayerPlugin/VideoPlayerPlugin.swift +49 -1
- package/ios/Tests/VideoPlayerPluginTests/VideoChapterTests.swift +30 -0
- package/ios/Tests/VideoPlayerPluginTests/VideoPlayerAudioSessionCategoryPolicyTests.swift +52 -0
- package/package.json +6 -4
|
@@ -13,6 +13,7 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
13
13
|
private var exitOnEnd: Bool
|
|
14
14
|
private var loopOnEnd: Bool
|
|
15
15
|
private var pipEnabled: Bool
|
|
16
|
+
private var bkmodeEnabled: Bool
|
|
16
17
|
private var showControls: Bool
|
|
17
18
|
private var chromecast: Bool
|
|
18
19
|
private var chromecastUrl: String?
|
|
@@ -21,7 +22,8 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
21
22
|
private var artwork: String?
|
|
22
23
|
private var rate: Float
|
|
23
24
|
private var audioCategory: String?
|
|
24
|
-
private
|
|
25
|
+
private let audioSessionRegistrationId = UUID()
|
|
26
|
+
private var holdsSharedAudioSession = false
|
|
25
27
|
private var didEmitReady: Bool = false
|
|
26
28
|
private var exitEmission = ExitEmissionGuard()
|
|
27
29
|
private var isTransitioningToPictureInPicture = false
|
|
@@ -40,6 +42,11 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
40
42
|
private var castController: VideoPlayerCastController?
|
|
41
43
|
private weak var presentingViewController: UIViewController?
|
|
42
44
|
private var subtitleTracks: [VideoSubtitleTrack] = []
|
|
45
|
+
private var subtitleStyleOptions: SubtitleStyleOptions?
|
|
46
|
+
private var chapters: [VideoChapter] = []
|
|
47
|
+
private var chapterButton: UIButton?
|
|
48
|
+
private let controlsVisibilityObserver = AVPlayerControlsVisibilityObserver()
|
|
49
|
+
private var arePlaybackControlsVisible = true
|
|
43
50
|
private var hlsResourceLoader: HLSSubtitleResourceLoader?
|
|
44
51
|
private var subtitleButton: UIButton?
|
|
45
52
|
private var subtitleSelectionObserver: NSObjectProtocol?
|
|
@@ -54,6 +61,7 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
54
61
|
exitOnEnd: Bool,
|
|
55
62
|
loopOnEnd: Bool,
|
|
56
63
|
pipEnabled: Bool,
|
|
64
|
+
bkmodeEnabled: Bool = true,
|
|
57
65
|
showControls: Bool,
|
|
58
66
|
chromecast: Bool,
|
|
59
67
|
chromecastUrl: String? = nil,
|
|
@@ -61,6 +69,8 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
61
69
|
smallTitle: String? = nil,
|
|
62
70
|
artwork: String? = nil,
|
|
63
71
|
subtitleTracks: [VideoSubtitleTrack] = [],
|
|
72
|
+
subtitleStyleOptions: SubtitleStyleOptions? = nil,
|
|
73
|
+
chapters: [VideoChapter] = [],
|
|
64
74
|
fairplayCertificateUrl: String? = nil,
|
|
65
75
|
fairplayContentKeySpcUrl: String? = nil,
|
|
66
76
|
fairplayAssetId: String? = nil,
|
|
@@ -73,6 +83,7 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
73
83
|
self.exitOnEnd = exitOnEnd
|
|
74
84
|
self.loopOnEnd = loopOnEnd
|
|
75
85
|
self.pipEnabled = pipEnabled
|
|
86
|
+
self.bkmodeEnabled = bkmodeEnabled
|
|
76
87
|
self.showControls = showControls
|
|
77
88
|
self.chromecast = chromecast
|
|
78
89
|
self.chromecastUrl = chromecastUrl
|
|
@@ -80,6 +91,8 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
80
91
|
self.smallTitle = smallTitle
|
|
81
92
|
self.artwork = artwork
|
|
82
93
|
self.subtitleTracks = subtitleTracks
|
|
94
|
+
self.subtitleStyleOptions = subtitleStyleOptions
|
|
95
|
+
self.chapters = chapters
|
|
83
96
|
self.fairplayCertificateUrl = fairplayCertificateUrl
|
|
84
97
|
self.fairplayContentKeySpcUrl = fairplayContentKeySpcUrl
|
|
85
98
|
self.fairplayAssetId = fairplayAssetId
|
|
@@ -213,6 +226,7 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
213
226
|
}
|
|
214
227
|
|
|
215
228
|
private func configurePlayer(with item: AVPlayerItem) {
|
|
229
|
+
applySubtitleStyle(to: item)
|
|
216
230
|
playerItem = item
|
|
217
231
|
player = AVPlayer(playerItem: playerItem)
|
|
218
232
|
player?.rate = rate
|
|
@@ -231,12 +245,70 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
231
245
|
}
|
|
232
246
|
playerVC.adaptivePresentationDelegate = self
|
|
233
247
|
playerViewController = playerVC
|
|
248
|
+
if showControls {
|
|
249
|
+
controlsVisibilityObserver.start(observing: playerVC) { [weak self] visible in
|
|
250
|
+
self?.arePlaybackControlsVisible = visible
|
|
251
|
+
self?.applyChapterButtonControlsVisibility()
|
|
252
|
+
}
|
|
253
|
+
}
|
|
234
254
|
|
|
235
255
|
setupChromecast()
|
|
236
256
|
setupObservers()
|
|
237
257
|
setupSubtitleSelectionObserver()
|
|
238
258
|
}
|
|
239
259
|
|
|
260
|
+
private func applySubtitleStyle(to item: AVPlayerItem) {
|
|
261
|
+
guard let subtitleStyleOptions else { return }
|
|
262
|
+
|
|
263
|
+
var attributes: [String: Any] = [:]
|
|
264
|
+
if let fontSize = subtitleStyleOptions.fontSize, fontSize.isFinite, fontSize > 0 {
|
|
265
|
+
let percent = fontSize / 16.0 * 100.0
|
|
266
|
+
attributes[kCMTextMarkupAttribute_RelativeFontSize as String] = NSNumber(value: percent)
|
|
267
|
+
}
|
|
268
|
+
if let foregroundColor = subtitleStyleOptions.foregroundColor,
|
|
269
|
+
let colorComponents = Self.parseRGBAComponents(foregroundColor) {
|
|
270
|
+
attributes[kCMTextMarkupAttribute_ForegroundColorARGB as String] = colorComponents
|
|
271
|
+
}
|
|
272
|
+
if let backgroundColor = subtitleStyleOptions.backgroundColor,
|
|
273
|
+
let colorComponents = Self.parseRGBAComponents(backgroundColor) {
|
|
274
|
+
attributes[kCMTextMarkupAttribute_BackgroundColorARGB as String] = colorComponents
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
guard !attributes.isEmpty,
|
|
278
|
+
let rule = AVTextStyleRule(textMarkupAttributes: attributes as [String: Any]) else {
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
item.textStyleRules = [rule]
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private static func parseRGBAComponents(_ value: String) -> [NSNumber]? {
|
|
285
|
+
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
286
|
+
guard trimmed.hasPrefix("rgba("), trimmed.hasSuffix(")") else {
|
|
287
|
+
return nil
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let inner = trimmed.dropFirst(5).dropLast()
|
|
291
|
+
let parts = inner.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
292
|
+
guard parts.count == 4,
|
|
293
|
+
let red = Double(parts[0]), red.isFinite,
|
|
294
|
+
let green = Double(parts[1]), green.isFinite,
|
|
295
|
+
let blue = Double(parts[2]), blue.isFinite,
|
|
296
|
+
let alpha = Double(parts[3]), alpha.isFinite,
|
|
297
|
+
(0...255).contains(red),
|
|
298
|
+
(0...255).contains(green),
|
|
299
|
+
(0...255).contains(blue),
|
|
300
|
+
(0...1).contains(alpha) else {
|
|
301
|
+
return nil
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return [
|
|
305
|
+
NSNumber(value: alpha),
|
|
306
|
+
NSNumber(value: red / 255.0),
|
|
307
|
+
NSNumber(value: green / 255.0),
|
|
308
|
+
NSNumber(value: blue / 255.0)
|
|
309
|
+
]
|
|
310
|
+
}
|
|
311
|
+
|
|
240
312
|
private func setupSubtitleSelectionObserver() {
|
|
241
313
|
guard subtitleSelectionObserver == nil, let playerItem else { return }
|
|
242
314
|
subtitleSelectionObserver = NotificationCenter.default.addObserver(
|
|
@@ -270,6 +342,144 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
270
342
|
return nonHlsSubtitleModeEnabled && validNonHlsSubtitleTracks.count > 1 && shouldShowCustomSubtitleButton()
|
|
271
343
|
}
|
|
272
344
|
|
|
345
|
+
private func shouldShowChapterButton() -> Bool {
|
|
346
|
+
guard showControls && !chapters.isEmpty else { return false }
|
|
347
|
+
if castController?.isCasting == true {
|
|
348
|
+
return true
|
|
349
|
+
}
|
|
350
|
+
return playerItem?.status == .readyToPlay
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private func refreshChapterButton() {
|
|
354
|
+
DispatchQueue.main.async { [weak self] in
|
|
355
|
+
self?.installOrUpdateChapterButtonIfNeeded()
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private func installOrUpdateChapterButtonIfNeeded() {
|
|
360
|
+
guard shouldShowChapterButton(),
|
|
361
|
+
let playerVC = playerViewController else {
|
|
362
|
+
chapterButton?.removeFromSuperview()
|
|
363
|
+
chapterButton = nil
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if chapterButton == nil {
|
|
368
|
+
guard let overlayView = playerVC.contentOverlayView else {
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
let button = UIButton(type: .system)
|
|
372
|
+
button.translatesAutoresizingMaskIntoConstraints = false
|
|
373
|
+
if #available(iOS 13.0, *) {
|
|
374
|
+
button.setImage(UIImage(systemName: "list.bullet"), for: .normal)
|
|
375
|
+
} else {
|
|
376
|
+
button.setTitle("Ch", for: .normal)
|
|
377
|
+
}
|
|
378
|
+
button.tintColor = .white
|
|
379
|
+
button.backgroundColor = UIColor.black.withAlphaComponent(0.45)
|
|
380
|
+
button.layer.cornerRadius = 22
|
|
381
|
+
button.clipsToBounds = true
|
|
382
|
+
if #available(iOS 14.0, *) {
|
|
383
|
+
button.showsMenuAsPrimaryAction = true
|
|
384
|
+
} else {
|
|
385
|
+
button.addTarget(self, action: #selector(showChapterActionSheet), for: .touchUpInside)
|
|
386
|
+
}
|
|
387
|
+
overlayView.addSubview(button)
|
|
388
|
+
let layout = VideoPlayerCastOverlayLayout.self
|
|
389
|
+
NSLayoutConstraint.activate([
|
|
390
|
+
button.topAnchor.constraint(
|
|
391
|
+
equalTo: overlayView.safeAreaLayoutGuide.topAnchor,
|
|
392
|
+
constant: layout.chapterButtonTopOffset(chromecastEnabled: chromecast)
|
|
393
|
+
),
|
|
394
|
+
button.trailingAnchor.constraint(
|
|
395
|
+
equalTo: overlayView.safeAreaLayoutGuide.trailingAnchor,
|
|
396
|
+
constant: -layout.trailingInset
|
|
397
|
+
),
|
|
398
|
+
button.widthAnchor.constraint(equalToConstant: layout.controlSize),
|
|
399
|
+
button.heightAnchor.constraint(equalToConstant: layout.controlSize)
|
|
400
|
+
])
|
|
401
|
+
chapterButton = button
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if #available(iOS 14.0, *) {
|
|
405
|
+
chapterButton?.menu = buildChapterMenu()
|
|
406
|
+
}
|
|
407
|
+
chapterButton?.accessibilityLabel = "Chapters"
|
|
408
|
+
applyChapterButtonControlsVisibility()
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private func applyChapterButtonControlsVisibility() {
|
|
412
|
+
chapterButton?.isHidden = !arePlaybackControlsVisible
|
|
413
|
+
chapterButton?.alpha = arePlaybackControlsVisible ? 1 : 0
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
@available(iOS 14.0, *)
|
|
417
|
+
private func buildChapterMenu() -> UIMenu {
|
|
418
|
+
let actions = chapters.map { chapter in
|
|
419
|
+
UIAction(title: Self.chapterDisplayName(chapter)) { [weak self] _ in
|
|
420
|
+
self?.seekToChapter(chapter)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return UIMenu(title: "Chapters", children: actions)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
@objc
|
|
427
|
+
private func showChapterActionSheet() {
|
|
428
|
+
guard let playerVC = playerViewController, !chapters.isEmpty else {
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
let sheet = UIAlertController(title: "Chapters", message: nil, preferredStyle: .actionSheet)
|
|
433
|
+
for chapter in chapters {
|
|
434
|
+
sheet.addAction(UIAlertAction(title: Self.chapterDisplayName(chapter), style: .default) { [weak self] _ in
|
|
435
|
+
self?.seekToChapter(chapter)
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
|
439
|
+
if let popover = sheet.popoverPresentationController,
|
|
440
|
+
let chapterButton {
|
|
441
|
+
popover.sourceView = chapterButton
|
|
442
|
+
popover.sourceRect = chapterButton.bounds
|
|
443
|
+
}
|
|
444
|
+
playerVC.present(sheet, animated: true)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private func seekToChapter(_ chapter: VideoChapter) {
|
|
448
|
+
if castController?.setCurrentTime(chapter.startTime) == true {
|
|
449
|
+
play()
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
let target = CMTime(seconds: chapter.startTime, preferredTimescale: 600)
|
|
453
|
+
player?.seek(to: target, toleranceBefore: .zero, toleranceAfter: .zero)
|
|
454
|
+
play()
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private static func chapterDisplayName(_ chapter: VideoChapter) -> String {
|
|
458
|
+
let timeLabel = formatChapterTime(chapter.startTime)
|
|
459
|
+
let title = chapter.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
460
|
+
if title.isEmpty {
|
|
461
|
+
return timeLabel
|
|
462
|
+
}
|
|
463
|
+
return "\(title) (\(timeLabel))"
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private static func formatChapterTime(_ seconds: TimeInterval) -> String {
|
|
467
|
+
guard seconds.isFinite else {
|
|
468
|
+
return "0:00"
|
|
469
|
+
}
|
|
470
|
+
let clamped = min(max(0, seconds), Double(Int.max))
|
|
471
|
+
guard let totalSeconds = Int(exactly: clamped.rounded()) else {
|
|
472
|
+
return "0:00"
|
|
473
|
+
}
|
|
474
|
+
let hours = totalSeconds / 3600
|
|
475
|
+
let minutes = (totalSeconds % 3600) / 60
|
|
476
|
+
let secs = totalSeconds % 60
|
|
477
|
+
if hours > 0 {
|
|
478
|
+
return String(format: "%d:%02d:%02d", hours, minutes, secs)
|
|
479
|
+
}
|
|
480
|
+
return String(format: "%d:%02d", minutes, secs)
|
|
481
|
+
}
|
|
482
|
+
|
|
273
483
|
private func refreshSubtitleButton() {
|
|
274
484
|
DispatchQueue.main.async { [weak self] in
|
|
275
485
|
self?.installOrUpdateSubtitleButtonIfNeeded()
|
|
@@ -571,6 +781,7 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
571
781
|
|
|
572
782
|
let oldItem = playerItem
|
|
573
783
|
removePlayerItemObservers(oldItem)
|
|
784
|
+
applySubtitleStyle(to: newItem)
|
|
574
785
|
playerItem = newItem
|
|
575
786
|
player.replaceCurrentItem(with: newItem)
|
|
576
787
|
addPlayerItemObservers(newItem)
|
|
@@ -638,6 +849,9 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
638
849
|
castController?.setOnEnd { [weak self] in
|
|
639
850
|
self?.handlePlaybackEnded()
|
|
640
851
|
}
|
|
852
|
+
castController?.setOnCastingStateChange { [weak self] in
|
|
853
|
+
self?.refreshChapterButton()
|
|
854
|
+
}
|
|
641
855
|
castController?.attach(to: playerViewController, player: player)
|
|
642
856
|
}
|
|
643
857
|
|
|
@@ -663,6 +877,7 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
663
877
|
if let item = object as? AVPlayerItem {
|
|
664
878
|
if item.status == .readyToPlay {
|
|
665
879
|
refreshSubtitleButton()
|
|
880
|
+
refreshChapterButton()
|
|
666
881
|
emitReadyIfNeeded()
|
|
667
882
|
}
|
|
668
883
|
}
|
|
@@ -706,6 +921,7 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
706
921
|
presentingViewController = viewController
|
|
707
922
|
viewController.present(playerVC, animated: true) {
|
|
708
923
|
self.refreshSubtitleButton()
|
|
924
|
+
self.refreshChapterButton()
|
|
709
925
|
self.castController?.installOverlayIfNeeded()
|
|
710
926
|
self.play()
|
|
711
927
|
completion()
|
|
@@ -753,6 +969,7 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
753
969
|
}
|
|
754
970
|
|
|
755
971
|
private func cleanup(stopRemoteMedia: Bool = false) {
|
|
972
|
+
controlsVisibilityObserver.stop()
|
|
756
973
|
nonHlsBootstrapLoadId = nil
|
|
757
974
|
castController?.detach(stopRemoteMedia: stopRemoteMedia)
|
|
758
975
|
castController = nil
|
|
@@ -762,6 +979,8 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
762
979
|
}
|
|
763
980
|
subtitleButton?.removeFromSuperview()
|
|
764
981
|
subtitleButton = nil
|
|
982
|
+
chapterButton?.removeFromSuperview()
|
|
983
|
+
chapterButton = nil
|
|
765
984
|
contentKeySession?.setDelegate(nil, queue: nil)
|
|
766
985
|
contentKeySession = nil
|
|
767
986
|
hlsResourceLoader = nil
|
|
@@ -780,45 +999,36 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
780
999
|
}
|
|
781
1000
|
|
|
782
1001
|
private func configureAudioSession() {
|
|
783
|
-
guard let audioCategory else { return }
|
|
784
|
-
|
|
785
|
-
let session = AVAudioSession.sharedInstance()
|
|
786
1002
|
do {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
case "moviePlayback":
|
|
793
|
-
if #available(iOS 13.0, *) {
|
|
794
|
-
try session.setCategory(
|
|
795
|
-
.playback,
|
|
796
|
-
mode: .moviePlayback,
|
|
797
|
-
policy: .longFormVideo,
|
|
798
|
-
options: [.mixWithOthers]
|
|
799
|
-
)
|
|
800
|
-
} else {
|
|
801
|
-
try session.setCategory(.playback, mode: .moviePlayback, options: [.mixWithOthers])
|
|
802
|
-
}
|
|
803
|
-
default:
|
|
804
|
-
return
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
try session.setActive(true)
|
|
808
|
-
didActivateAudioSession = true
|
|
1003
|
+
try VideoPlayerAudioSessionCoordinator.acquire(
|
|
1004
|
+
registrationId: audioSessionRegistrationId,
|
|
1005
|
+
needs: currentAudioSessionNeeds()
|
|
1006
|
+
)
|
|
1007
|
+
holdsSharedAudioSession = true
|
|
809
1008
|
} catch {
|
|
1009
|
+
holdsSharedAudioSession = false
|
|
810
1010
|
print("Error configuring AVAudioSession: \(error)")
|
|
811
1011
|
}
|
|
812
1012
|
}
|
|
813
1013
|
|
|
814
|
-
private func
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
}
|
|
820
|
-
|
|
1014
|
+
private func currentAudioSessionNeeds() -> VideoPlayerAudioSessionNeeds {
|
|
1015
|
+
let explicitCategory: String?
|
|
1016
|
+
if let audioCategory {
|
|
1017
|
+
let trimmed = audioCategory.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1018
|
+
explicitCategory = trimmed.isEmpty ? nil : trimmed
|
|
1019
|
+
} else {
|
|
1020
|
+
explicitCategory = nil
|
|
821
1021
|
}
|
|
1022
|
+
return VideoPlayerAudioSessionNeeds(
|
|
1023
|
+
explicitCategory: explicitCategory,
|
|
1024
|
+
needsBackgroundOrPipPlayback: bkmodeEnabled || pipEnabled
|
|
1025
|
+
)
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
private func deactivateAudioSessionIfNeeded() {
|
|
1029
|
+
guard holdsSharedAudioSession else { return }
|
|
1030
|
+
VideoPlayerAudioSessionCoordinator.release(registrationId: audioSessionRegistrationId)
|
|
1031
|
+
holdsSharedAudioSession = false
|
|
822
1032
|
}
|
|
823
1033
|
|
|
824
1034
|
private func configurePresentationDelegate(for playerVC: UIViewController) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
struct VideoChapter {
|
|
4
|
+
let title: String
|
|
5
|
+
let startTime: TimeInterval
|
|
6
|
+
let endTime: TimeInterval?
|
|
7
|
+
|
|
8
|
+
init?(dictionary: [String: Any]) {
|
|
9
|
+
guard let title = dictionary["title"] as? String,
|
|
10
|
+
!title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
11
|
+
return nil
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let startTime: TimeInterval
|
|
15
|
+
if let number = dictionary["startTime"] as? NSNumber {
|
|
16
|
+
if CFGetTypeID(number) == CFBooleanGetTypeID() {
|
|
17
|
+
return nil
|
|
18
|
+
}
|
|
19
|
+
startTime = number.doubleValue
|
|
20
|
+
} else if let value = dictionary["startTime"] as? Double {
|
|
21
|
+
startTime = value
|
|
22
|
+
} else {
|
|
23
|
+
return nil
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if !startTime.isFinite || startTime < 0 || startTime >= Double(Int.max) {
|
|
27
|
+
return nil
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
var endTime: TimeInterval?
|
|
31
|
+
if let number = dictionary["endTime"] as? NSNumber {
|
|
32
|
+
endTime = number.doubleValue
|
|
33
|
+
} else if let value = dictionary["endTime"] as? Double {
|
|
34
|
+
endTime = value
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if let endTimeValue = endTime, endTimeValue <= startTime {
|
|
38
|
+
endTime = nil
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
self.title = title
|
|
42
|
+
self.startTime = startTime
|
|
43
|
+
self.endTime = endTime
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
struct VideoPlayerAudioSessionNeeds: Equatable {
|
|
5
|
+
let explicitCategory: String?
|
|
6
|
+
let needsBackgroundOrPipPlayback: Bool
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
enum VideoPlayerAudioSessionCategoryPolicy {
|
|
10
|
+
static func category(for needs: VideoPlayerAudioSessionNeeds) -> String {
|
|
11
|
+
if let explicitCategory = needs.explicitCategory {
|
|
12
|
+
let trimmed = explicitCategory.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
13
|
+
if !trimmed.isEmpty {
|
|
14
|
+
return trimmed
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if needs.needsBackgroundOrPipPlayback {
|
|
18
|
+
return "playback"
|
|
19
|
+
}
|
|
20
|
+
return "ambient"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static func unionCategory(for players: [VideoPlayerAudioSessionNeeds]) -> String {
|
|
24
|
+
guard !players.isEmpty else {
|
|
25
|
+
return "ambient"
|
|
26
|
+
}
|
|
27
|
+
let categories = players.map(category(for:))
|
|
28
|
+
if categories.contains("moviePlayback") {
|
|
29
|
+
return "moviePlayback"
|
|
30
|
+
}
|
|
31
|
+
if categories.contains("playback") {
|
|
32
|
+
return "playback"
|
|
33
|
+
}
|
|
34
|
+
return "ambient"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
enum VideoPlayerAudioSessionCoordinator {
|
|
39
|
+
private static let lock = NSLock()
|
|
40
|
+
private static var activeRegistrations: [UUID: VideoPlayerAudioSessionNeeds] = [:]
|
|
41
|
+
|
|
42
|
+
static func acquire(registrationId: UUID, needs: VideoPlayerAudioSessionNeeds) throws {
|
|
43
|
+
lock.lock()
|
|
44
|
+
defer { lock.unlock() }
|
|
45
|
+
|
|
46
|
+
let wasEmpty = activeRegistrations.isEmpty
|
|
47
|
+
var stagedRegistrations = activeRegistrations
|
|
48
|
+
stagedRegistrations[registrationId] = needs
|
|
49
|
+
|
|
50
|
+
try applyCategory(for: stagedRegistrations.values)
|
|
51
|
+
if wasEmpty {
|
|
52
|
+
try AVAudioSession.sharedInstance().setActive(true)
|
|
53
|
+
}
|
|
54
|
+
activeRegistrations = stagedRegistrations
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static func release(registrationId: UUID) {
|
|
58
|
+
lock.lock()
|
|
59
|
+
defer { lock.unlock() }
|
|
60
|
+
|
|
61
|
+
activeRegistrations.removeValue(forKey: registrationId)
|
|
62
|
+
if activeRegistrations.isEmpty {
|
|
63
|
+
do {
|
|
64
|
+
try AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
|
|
65
|
+
} catch {
|
|
66
|
+
print("Error deactivating AVAudioSession: \(error)")
|
|
67
|
+
}
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
do {
|
|
72
|
+
try applyCategory(for: activeRegistrations.values)
|
|
73
|
+
} catch {
|
|
74
|
+
print("Error reconfiguring AVAudioSession after player exit: \(error)")
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private static func applyCategory(for players: some Collection<VideoPlayerAudioSessionNeeds>) throws {
|
|
79
|
+
let resolvedCategory = VideoPlayerAudioSessionCategoryPolicy.unionCategory(for: Array(players))
|
|
80
|
+
let session = AVAudioSession.sharedInstance()
|
|
81
|
+
switch resolvedCategory {
|
|
82
|
+
case "ambient":
|
|
83
|
+
try session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
|
|
84
|
+
case "playback":
|
|
85
|
+
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
|
86
|
+
case "moviePlayback":
|
|
87
|
+
if #available(iOS 13.0, *) {
|
|
88
|
+
try session.setCategory(
|
|
89
|
+
.playback,
|
|
90
|
+
mode: .moviePlayback,
|
|
91
|
+
policy: .longFormVideo,
|
|
92
|
+
options: [.mixWithOthers]
|
|
93
|
+
)
|
|
94
|
+
} else {
|
|
95
|
+
try session.setCategory(.playback, mode: .moviePlayback, options: [.mixWithOthers])
|
|
96
|
+
}
|
|
97
|
+
default:
|
|
98
|
+
try session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -29,7 +29,12 @@ final class VideoPlayerCastController: NSObject {
|
|
|
29
29
|
private weak var observedRemoteMediaClient: GCKRemoteMediaClient?
|
|
30
30
|
private var mediaLoadRequest: GCKRequest?
|
|
31
31
|
private var pendingCastCommands: [PendingCastCommand] = []
|
|
32
|
-
private var isLoadedOnCast = false
|
|
32
|
+
private var isLoadedOnCast = false {
|
|
33
|
+
didSet {
|
|
34
|
+
guard oldValue != isLoadedOnCast else { return }
|
|
35
|
+
onCastingStateChange?()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
33
38
|
private var isLoadingOnCast = false
|
|
34
39
|
private var localWasPlaying = false
|
|
35
40
|
private var isDetached = false
|
|
@@ -38,6 +43,7 @@ final class VideoPlayerCastController: NSObject {
|
|
|
38
43
|
private var onPlay: (() -> Void)?
|
|
39
44
|
private var onPause: (() -> Void)?
|
|
40
45
|
private var onEnd: (() -> Void)?
|
|
46
|
+
private var onCastingStateChange: (() -> Void)?
|
|
41
47
|
private let controlsVisibilityObserver = AVPlayerControlsVisibilityObserver()
|
|
42
48
|
private var localSeekTimeObserver: Any?
|
|
43
49
|
private var timeJumpObserver: NSObjectProtocol?
|
|
@@ -275,6 +281,10 @@ final class VideoPlayerCastController: NSObject {
|
|
|
275
281
|
func setOnEnd(_ callback: @escaping () -> Void) {
|
|
276
282
|
onEnd = callback
|
|
277
283
|
}
|
|
284
|
+
|
|
285
|
+
func setOnCastingStateChange(_ callback: @escaping () -> Void) {
|
|
286
|
+
onCastingStateChange = callback
|
|
287
|
+
}
|
|
278
288
|
}
|
|
279
289
|
|
|
280
290
|
private extension VideoPlayerCastController {
|
|
@@ -364,13 +374,17 @@ private extension VideoPlayerCastController {
|
|
|
364
374
|
button.accessibilityLabel = "Cast"
|
|
365
375
|
|
|
366
376
|
overlayView.addSubview(button)
|
|
377
|
+
let layout = VideoPlayerCastOverlayLayout.self
|
|
367
378
|
NSLayoutConstraint.activate([
|
|
368
|
-
button.widthAnchor.constraint(equalToConstant:
|
|
369
|
-
button.heightAnchor.constraint(equalToConstant:
|
|
379
|
+
button.widthAnchor.constraint(equalToConstant: layout.controlSize),
|
|
380
|
+
button.heightAnchor.constraint(equalToConstant: layout.controlSize),
|
|
370
381
|
// Position below AVPlayerViewController's top row of controls (Done/X and route picker)
|
|
371
382
|
// using trailing to stay away from the leading-side dismiss button.
|
|
372
|
-
button.topAnchor.constraint(equalTo: overlayView.safeAreaLayoutGuide.topAnchor, constant:
|
|
373
|
-
button.trailingAnchor.constraint(
|
|
383
|
+
button.topAnchor.constraint(equalTo: overlayView.safeAreaLayoutGuide.topAnchor, constant: layout.topRowOffset),
|
|
384
|
+
button.trailingAnchor.constraint(
|
|
385
|
+
equalTo: overlayView.safeAreaLayoutGuide.trailingAnchor,
|
|
386
|
+
constant: -layout.trailingInset
|
|
387
|
+
)
|
|
374
388
|
])
|
|
375
389
|
|
|
376
390
|
castButton = button
|
|
@@ -930,6 +944,10 @@ final class VideoPlayerCastController {
|
|
|
930
944
|
func setOnEnd(_ callback: @escaping () -> Void) {
|
|
931
945
|
_ = callback
|
|
932
946
|
}
|
|
947
|
+
|
|
948
|
+
func setOnCastingStateChange(_ callback: @escaping () -> Void) {
|
|
949
|
+
_ = callback
|
|
950
|
+
}
|
|
933
951
|
}
|
|
934
952
|
|
|
935
953
|
#endif
|