@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.
Files changed (22) hide show
  1. package/README.md +68 -33
  2. package/android/src/main/java/com/capgo/videoplayer/FullscreenExoPlayerFragment.java +145 -53
  3. package/android/src/main/java/com/capgo/videoplayer/SubtitleOptionOverrides.java +84 -0
  4. package/android/src/main/java/com/capgo/videoplayer/VideoChapter.java +14 -0
  5. package/android/src/main/java/com/capgo/videoplayer/VideoPlayer.java +3 -0
  6. package/android/src/main/java/com/capgo/videoplayer/VideoPlayerPlugin.java +48 -2
  7. package/android/src/main/res/drawable/ic_chapters_white.xml +10 -0
  8. package/android/src/main/res/layout/exo_playback_control_view.xml +9 -0
  9. package/android/src/main/res/values/strings.xml +2 -0
  10. package/dist/docs.json +44 -5
  11. package/dist/esm/definitions.d.ts +58 -6
  12. package/dist/esm/definitions.js.map +1 -1
  13. package/ios/Sources/VideoPlayerPlugin/AVPlayerControlsVisibilityObserver.swift +13 -0
  14. package/ios/Sources/VideoPlayerPlugin/FullscreenVideoPlayer.swift +243 -33
  15. package/ios/Sources/VideoPlayerPlugin/SubtitleStyleOptions.swift +7 -0
  16. package/ios/Sources/VideoPlayerPlugin/VideoChapter.swift +45 -0
  17. package/ios/Sources/VideoPlayerPlugin/VideoPlayerAudioSessionCoordinator.swift +101 -0
  18. package/ios/Sources/VideoPlayerPlugin/VideoPlayerCastController.swift +23 -5
  19. package/ios/Sources/VideoPlayerPlugin/VideoPlayerPlugin.swift +49 -1
  20. package/ios/Tests/VideoPlayerPluginTests/VideoChapterTests.swift +30 -0
  21. package/ios/Tests/VideoPlayerPluginTests/VideoPlayerAudioSessionCategoryPolicyTests.swift +52 -0
  22. 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 var didActivateAudioSession: Bool = false
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
- switch audioCategory {
788
- case "ambient":
789
- try session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
790
- case "playback":
791
- try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
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 deactivateAudioSessionIfNeeded() {
815
- guard didActivateAudioSession else { return }
816
- do {
817
- try AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
818
- didActivateAudioSession = false
819
- } catch {
820
- print("Error deactivating AVAudioSession: \(error)")
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,7 @@
1
+ import Foundation
2
+
3
+ struct SubtitleStyleOptions {
4
+ let foregroundColor: String?
5
+ let backgroundColor: String?
6
+ let fontSize: Double?
7
+ }
@@ -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: 44),
369
- button.heightAnchor.constraint(equalToConstant: 44),
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: 60),
373
- button.trailingAnchor.constraint(equalTo: overlayView.safeAreaLayoutGuide.trailingAnchor, constant: -16)
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