@capgo/capacitor-video-player 8.2.4 → 8.2.7

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.
@@ -22,7 +22,10 @@ class FullscreenVideoPlayer: NSObject {
22
22
  private var rate: Float
23
23
  private var audioCategory: String?
24
24
  private var didActivateAudioSession: Bool = false
25
- private var didEmitExit: Bool = false
25
+ private var didEmitReady: Bool = false
26
+ private var exitEmission = ExitEmissionGuard()
27
+ private var isTransitioningToPictureInPicture = false
28
+ private var suppressDismissExit = false
26
29
  private var timeObserver: Any?
27
30
  private var onPlay: (() -> Void)?
28
31
  private var onPause: (() -> Void)?
@@ -38,6 +41,11 @@ class FullscreenVideoPlayer: NSObject {
38
41
  private weak var presentingViewController: UIViewController?
39
42
  private var subtitleTracks: [VideoSubtitleTrack] = []
40
43
  private var hlsResourceLoader: HLSSubtitleResourceLoader?
44
+ private var subtitleButton: UIButton?
45
+ private var subtitleSelectionObserver: NSObjectProtocol?
46
+ private var nonHlsSubtitleModeEnabled: Bool = false
47
+ private var selectedNonHlsSubtitleIndex: Int? = nil
48
+ private var nonHlsBootstrapLoadId: UUID?
41
49
 
42
50
  init(
43
51
  playerId: String,
@@ -92,14 +100,59 @@ class FullscreenVideoPlayer: NSObject {
92
100
  guard let subtitleURL = track.resolvedURL else { return nil }
93
101
  return (subtitleURL, track.language)
94
102
  }
103
+ let isLikelyHlsSource = Self.isLikelyHLS(videoUrl)
95
104
 
96
105
  guard !resolvedTracks.isEmpty else {
106
+ nonHlsSubtitleModeEnabled = false
107
+ selectedNonHlsSubtitleIndex = nil
108
+ nonHlsBootstrapLoadId = nil
97
109
  let asset = makeVideoAsset(url: url, subtitleTracks: [])
98
110
  configurePlayer(with: AVPlayerItem(asset: asset))
99
111
  completion()
100
112
  return
101
113
  }
102
114
 
115
+ // For non-HLS media (e.g. MP4 + sidecar subtitles), initialize with only the
116
+ // first usable subtitle track and switch tracks later via the custom subtitle button.
117
+ if !isLikelyHlsSource {
118
+ nonHlsSubtitleModeEnabled = true
119
+ let firstValidIndex = validNonHlsSubtitleTracks.first?.index
120
+ selectedNonHlsSubtitleIndex = firstValidIndex
121
+ let baseAsset = makeVideoAsset(url: url, subtitleTracks: [])
122
+ configurePlayer(with: AVPlayerItem(asset: baseAsset))
123
+ completion()
124
+
125
+ guard let firstValidIndex else {
126
+ return
127
+ }
128
+
129
+ let bootstrapLoadId = UUID()
130
+ nonHlsBootstrapLoadId = bootstrapLoadId
131
+
132
+ Task { [weak self] in
133
+ guard let self else { return }
134
+ let initialTrack = [self.subtitleTracks[firstValidIndex]]
135
+ let item = await ProgressiveVideoPlayerItemFactory.createPlayerItem(
136
+ videoAsset: baseAsset,
137
+ subtitleTracks: initialTrack
138
+ )
139
+ await MainActor.run {
140
+ guard self.nonHlsSubtitleModeEnabled,
141
+ self.nonHlsBootstrapLoadId == bootstrapLoadId,
142
+ self.selectedNonHlsSubtitleIndex == firstValidIndex else {
143
+ return
144
+ }
145
+ self.replaceCurrentItemPreservingPlayback(with: item)
146
+ self.refreshSubtitleButton()
147
+ }
148
+ }
149
+ return
150
+ }
151
+
152
+ nonHlsSubtitleModeEnabled = false
153
+ selectedNonHlsSubtitleIndex = nil
154
+ nonHlsBootstrapLoadId = nil
155
+
103
156
  // Prefer the resource-loader path when makeAsset can safely wrap the stream
104
157
  // (HLS / remote progressive). Local progressive file:// sources return a nil
105
158
  // loader and must use composition so playback still starts (#78).
@@ -164,14 +217,395 @@ class FullscreenVideoPlayer: NSObject {
164
217
  player = AVPlayer(playerItem: playerItem)
165
218
  player?.rate = rate
166
219
 
167
- playerViewController = AVPlayerViewController()
168
- playerViewController?.player = player
169
- playerViewController?.showsPlaybackControls = showControls
170
- playerViewController?.allowsPictureInPicturePlayback = pipEnabled
171
- playerViewController?.delegate = self
220
+ let playerVC = DismissObservingPlayerViewController()
221
+ playerVC.player = player
222
+ playerVC.showsPlaybackControls = showControls
223
+ playerVC.allowsPictureInPicturePlayback = pipEnabled
224
+ playerVC.delegate = self
225
+ playerVC.shouldReportDismiss = { [weak self] in
226
+ guard let self else { return true }
227
+ return !self.isTransitioningToPictureInPicture && !self.suppressDismissExit
228
+ }
229
+ playerVC.onDismiss = { [weak self] in
230
+ self?.emitExitIfNeeded()
231
+ }
232
+ playerVC.adaptivePresentationDelegate = self
233
+ playerViewController = playerVC
172
234
 
173
235
  setupChromecast()
174
236
  setupObservers()
237
+ setupSubtitleSelectionObserver()
238
+ }
239
+
240
+ private func setupSubtitleSelectionObserver() {
241
+ guard subtitleSelectionObserver == nil, let playerItem else { return }
242
+ subtitleSelectionObserver = NotificationCenter.default.addObserver(
243
+ forName: AVPlayerItem.mediaSelectionDidChangeNotification,
244
+ object: playerItem,
245
+ queue: .main
246
+ ) { [weak self] _ in
247
+ self?.refreshSubtitleButton()
248
+ }
249
+ }
250
+
251
+ private static func isLikelyHLS(_ value: String) -> Bool {
252
+ guard let url = resolveMediaURL(value) else { return false }
253
+ return HLSVideoAssetFactory.isHLSStream(url)
254
+ }
255
+
256
+ private var validNonHlsSubtitleTracks: [(index: Int, track: VideoSubtitleTrack)] {
257
+ subtitleTracks.enumerated().compactMap { index, track in
258
+ guard track.resolvedURL != nil else { return nil }
259
+ return (index, track)
260
+ }
261
+ }
262
+
263
+ private func shouldShowCustomSubtitleButton() -> Bool {
264
+ guard showControls else { return false }
265
+ // HLS usually has native subtitle selection UI; this custom button targets MP4 + sidecar subtitles.
266
+ return !Self.isLikelyHLS(videoUrl)
267
+ }
268
+
269
+ private func shouldShowNonHlsSubtitleButton() -> Bool {
270
+ return nonHlsSubtitleModeEnabled && validNonHlsSubtitleTracks.count > 1 && shouldShowCustomSubtitleButton()
271
+ }
272
+
273
+ private func refreshSubtitleButton() {
274
+ DispatchQueue.main.async { [weak self] in
275
+ self?.installOrUpdateSubtitleButtonIfNeeded()
276
+ }
277
+ }
278
+
279
+ private func installOrUpdateSubtitleButtonIfNeeded() {
280
+ guard shouldShowCustomSubtitleButton(),
281
+ let playerVC = playerViewController else {
282
+ subtitleButton?.removeFromSuperview()
283
+ subtitleButton = nil
284
+ return
285
+ }
286
+
287
+ if subtitleButton == nil {
288
+ guard let overlayView = playerVC.contentOverlayView else {
289
+ return
290
+ }
291
+ let button = UIButton(type: .system)
292
+ button.translatesAutoresizingMaskIntoConstraints = false
293
+ if #available(iOS 13.0, *) {
294
+ button.setImage(UIImage(systemName: "captions.bubble"), for: .normal)
295
+ } else {
296
+ button.setTitle("Sub", for: .normal)
297
+ }
298
+ button.tintColor = .white
299
+ button.backgroundColor = UIColor.black.withAlphaComponent(0.45)
300
+ button.layer.cornerRadius = 22
301
+ button.clipsToBounds = true
302
+ button.contentHorizontalAlignment = .center
303
+ button.contentVerticalAlignment = .center
304
+
305
+ if #available(iOS 14.0, *) {
306
+ button.showsMenuAsPrimaryAction = true
307
+ } else {
308
+ button.addTarget(self, action: #selector(showSubtitleActionSheet), for: .touchUpInside)
309
+ }
310
+
311
+ overlayView.addSubview(button)
312
+ NSLayoutConstraint.activate([
313
+ button.topAnchor.constraint(equalTo: overlayView.safeAreaLayoutGuide.topAnchor, constant: 60),
314
+ button.leadingAnchor.constraint(equalTo: overlayView.safeAreaLayoutGuide.leadingAnchor, constant: 16),
315
+ button.widthAnchor.constraint(equalToConstant: 44),
316
+ button.heightAnchor.constraint(equalToConstant: 44)
317
+ ])
318
+ subtitleButton = button
319
+ }
320
+ subtitleButton?.isHidden = false
321
+ subtitleButton?.alpha = 1
322
+
323
+ if shouldShowNonHlsSubtitleButton() {
324
+ if #available(iOS 14.0, *) {
325
+ subtitleButton?.menu = buildNonHlsSubtitleMenu()
326
+ }
327
+ updateNonHlsSubtitleButtonAccessibility()
328
+ return
329
+ }
330
+
331
+ guard let playerItem,
332
+ let group = playerItem.asset.mediaSelectionGroup(forMediaCharacteristic: .legible),
333
+ !group.options.isEmpty else {
334
+ subtitleButton?.removeFromSuperview()
335
+ subtitleButton = nil
336
+ return
337
+ }
338
+
339
+ if #available(iOS 14.0, *) {
340
+ subtitleButton?.menu = buildSubtitleMenu(group: group)
341
+ }
342
+ updateSubtitleButtonAccessibility(group: group)
343
+ }
344
+
345
+ private func updateSubtitleButtonAccessibility(group: AVMediaSelectionGroup) {
346
+ let selectedOption = playerItem?.selectedMediaOption(in: group)
347
+ let currentTitle = selectedOption.map(Self.subtitleDisplayName) ?? "Off"
348
+ subtitleButton?.accessibilityLabel = "Subtitles"
349
+ subtitleButton?.accessibilityValue = currentTitle
350
+ }
351
+
352
+ @available(iOS 14.0, *)
353
+ private func buildSubtitleMenu(group: AVMediaSelectionGroup) -> UIMenu {
354
+ let selectedOption = playerItem?.selectedMediaOption(in: group)
355
+
356
+ let offAction = UIAction(
357
+ title: "Off",
358
+ state: selectedOption == nil ? .on : .off
359
+ ) { [weak self] _ in
360
+ guard let self, let playerItem = self.playerItem else { return }
361
+ playerItem.select(nil, in: group)
362
+ self.refreshSubtitleButton()
363
+ }
364
+
365
+ let trackActions = group.options.map { option in
366
+ UIAction(
367
+ title: Self.subtitleDisplayName(option),
368
+ state: option == selectedOption ? .on : .off
369
+ ) { [weak self] _ in
370
+ guard let self, let playerItem = self.playerItem else { return }
371
+ playerItem.select(option, in: group)
372
+ self.refreshSubtitleButton()
373
+ }
374
+ }
375
+
376
+ return UIMenu(title: "Subtitles", children: [offAction] + trackActions)
377
+ }
378
+
379
+ @available(iOS 14.0, *)
380
+ private func buildNonHlsSubtitleMenu() -> UIMenu {
381
+ let offAction = UIAction(
382
+ title: "Off",
383
+ state: selectedNonHlsSubtitleIndex == nil ? .on : .off
384
+ ) { [weak self] _ in
385
+ self?.switchNonHlsSubtitleTrack(index: nil)
386
+ }
387
+
388
+ let trackActions = validNonHlsSubtitleTracks.map { index, track in
389
+ UIAction(
390
+ title: Self.nonHlsSubtitleDisplayName(track: track, index: index),
391
+ state: selectedNonHlsSubtitleIndex == index ? .on : .off
392
+ ) { [weak self] _ in
393
+ self?.switchNonHlsSubtitleTrack(index: index)
394
+ }
395
+ }
396
+
397
+ return UIMenu(title: "Subtitles", children: [offAction] + trackActions)
398
+ }
399
+
400
+ @objc
401
+ private func showSubtitleActionSheet() {
402
+ if shouldShowNonHlsSubtitleButton() {
403
+ showNonHlsSubtitleActionSheet()
404
+ return
405
+ }
406
+
407
+ guard let playerVC = playerViewController,
408
+ let playerItem,
409
+ let group = playerItem.asset.mediaSelectionGroup(forMediaCharacteristic: .legible),
410
+ !group.options.isEmpty else {
411
+ return
412
+ }
413
+
414
+ let sheet = UIAlertController(title: "Subtitles", message: nil, preferredStyle: .actionSheet)
415
+ let selectedOption = playerItem.selectedMediaOption(in: group)
416
+
417
+ let offTitle = selectedOption == nil ? "Off ✓" : "Off"
418
+ sheet.addAction(UIAlertAction(title: offTitle, style: .default) { [weak self] _ in
419
+ guard let self else { return }
420
+ playerItem.select(nil, in: group)
421
+ self.refreshSubtitleButton()
422
+ })
423
+
424
+ for option in group.options {
425
+ let isSelected = option == selectedOption
426
+ let title = isSelected ? "\(Self.subtitleDisplayName(option)) ✓" : Self.subtitleDisplayName(option)
427
+ sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
428
+ guard let self else { return }
429
+ playerItem.select(option, in: group)
430
+ self.refreshSubtitleButton()
431
+ })
432
+ }
433
+
434
+ sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
435
+ if let popover = sheet.popoverPresentationController,
436
+ let subtitleButton {
437
+ popover.sourceView = subtitleButton
438
+ popover.sourceRect = subtitleButton.bounds
439
+ }
440
+ playerVC.present(sheet, animated: true)
441
+ }
442
+
443
+ private func showNonHlsSubtitleActionSheet() {
444
+ guard let playerVC = playerViewController else { return }
445
+
446
+ let sheet = UIAlertController(title: "Subtitles", message: nil, preferredStyle: .actionSheet)
447
+ let offTitle = selectedNonHlsSubtitleIndex == nil ? "Off ✓" : "Off"
448
+ sheet.addAction(UIAlertAction(title: offTitle, style: .default) { [weak self] _ in
449
+ self?.switchNonHlsSubtitleTrack(index: nil)
450
+ })
451
+
452
+ for (index, track) in validNonHlsSubtitleTracks {
453
+ let isSelected = selectedNonHlsSubtitleIndex == index
454
+ let label = Self.nonHlsSubtitleDisplayName(track: track, index: index)
455
+ let title = isSelected ? "\(label) ✓" : label
456
+ sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
457
+ self?.switchNonHlsSubtitleTrack(index: index)
458
+ })
459
+ }
460
+
461
+ sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
462
+ if let popover = sheet.popoverPresentationController,
463
+ let subtitleButton {
464
+ popover.sourceView = subtitleButton
465
+ popover.sourceRect = subtitleButton.bounds
466
+ }
467
+ playerVC.present(sheet, animated: true)
468
+ }
469
+
470
+ private static func subtitleDisplayName(_ option: AVMediaSelectionOption) -> String {
471
+ let optionDisplayName = option.displayName.trimmingCharacters(in: .whitespacesAndNewlines)
472
+ if !optionDisplayName.isEmpty {
473
+ return localizedLanguageName(from: optionDisplayName) ?? optionDisplayName
474
+ }
475
+ if let tag = option.extendedLanguageTag, !tag.isEmpty {
476
+ return localizedLanguageName(from: tag) ?? tag
477
+ }
478
+ if let localeId = option.locale?.identifier, !localeId.isEmpty {
479
+ return localizedLanguageName(from: localeId) ?? localeId
480
+ }
481
+ return "Unknown"
482
+ }
483
+
484
+ private static func nonHlsSubtitleDisplayName(track: VideoSubtitleTrack, index: Int) -> String {
485
+ if let language = track.language?.trimmingCharacters(in: .whitespacesAndNewlines),
486
+ !language.isEmpty {
487
+ return localizedLanguageName(from: language) ?? language
488
+ }
489
+ return "Subtitle \(index + 1)"
490
+ }
491
+
492
+ private static func localizedLanguageName(from rawValue: String) -> String? {
493
+ let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
494
+ guard !trimmed.isEmpty else { return nil }
495
+
496
+ let normalized = trimmed.replacingOccurrences(of: "_", with: "-")
497
+
498
+ if let identifierName = Locale.current.localizedString(forIdentifier: normalized),
499
+ !identifierName.isEmpty,
500
+ identifierName.caseInsensitiveCompare(trimmed) != .orderedSame {
501
+ return identifierName
502
+ }
503
+
504
+ if let languageCodeName = Locale.current.localizedString(forLanguageCode: normalized.lowercased()),
505
+ !languageCodeName.isEmpty,
506
+ languageCodeName.caseInsensitiveCompare(trimmed) != .orderedSame {
507
+ return languageCodeName
508
+ }
509
+
510
+ return nil
511
+ }
512
+
513
+ private func updateNonHlsSubtitleButtonAccessibility() {
514
+ let title: String
515
+ if let index = selectedNonHlsSubtitleIndex,
516
+ subtitleTracks.indices.contains(index) {
517
+ title = Self.nonHlsSubtitleDisplayName(track: subtitleTracks[index], index: index)
518
+ } else {
519
+ title = "Off"
520
+ }
521
+ subtitleButton?.accessibilityLabel = "Subtitles"
522
+ subtitleButton?.accessibilityValue = title
523
+ }
524
+
525
+ private func switchNonHlsSubtitleTrack(index: Int?) {
526
+ guard nonHlsSubtitleModeEnabled,
527
+ selectedNonHlsSubtitleIndex != index else {
528
+ refreshSubtitleButton()
529
+ return
530
+ }
531
+ guard let videoURL = Self.resolveMediaURL(videoUrl) else { return }
532
+
533
+ if let index, !validNonHlsSubtitleTracks.contains(where: { $0.index == index }) {
534
+ return
535
+ }
536
+
537
+ selectedNonHlsSubtitleIndex = index
538
+ nonHlsBootstrapLoadId = nil
539
+
540
+ Task {
541
+ let baseAsset = makeVideoAsset(url: videoURL, subtitleTracks: [])
542
+ let trackSelection: [VideoSubtitleTrack]
543
+ if let index {
544
+ trackSelection = [subtitleTracks[index]]
545
+ } else {
546
+ trackSelection = []
547
+ }
548
+
549
+ let newItem = await ProgressiveVideoPlayerItemFactory.createPlayerItem(
550
+ videoAsset: baseAsset,
551
+ subtitleTracks: trackSelection
552
+ )
553
+
554
+ await MainActor.run {
555
+ guard self.nonHlsSubtitleModeEnabled,
556
+ self.selectedNonHlsSubtitleIndex == index else {
557
+ return
558
+ }
559
+ self.replaceCurrentItemPreservingPlayback(with: newItem)
560
+ self.refreshSubtitleButton()
561
+ }
562
+ }
563
+ }
564
+
565
+ private func replaceCurrentItemPreservingPlayback(with newItem: AVPlayerItem) {
566
+ guard let player else { return }
567
+
568
+ let previousTime = player.currentTime()
569
+ let wasPlaying = player.rate > 0
570
+ let targetRate = rate
571
+
572
+ let oldItem = playerItem
573
+ removePlayerItemObservers(oldItem)
574
+ playerItem = newItem
575
+ player.replaceCurrentItem(with: newItem)
576
+ addPlayerItemObservers(newItem)
577
+ setupSubtitleSelectionObserver()
578
+
579
+ player.seek(to: previousTime) { _ in
580
+ if wasPlaying {
581
+ player.play()
582
+ player.rate = targetRate
583
+ }
584
+ }
585
+ }
586
+
587
+ private func addPlayerItemObservers(_ item: AVPlayerItem) {
588
+ item.addObserver(self, forKeyPath: "status", options: [.new], context: nil)
589
+ NotificationCenter.default.addObserver(
590
+ self,
591
+ selector: #selector(playerDidFinishPlaying),
592
+ name: .AVPlayerItemDidPlayToEndTime,
593
+ object: item
594
+ )
595
+ }
596
+
597
+ private func removePlayerItemObservers(_ item: AVPlayerItem?) {
598
+ guard let item else { return }
599
+ item.removeObserver(self, forKeyPath: "status")
600
+ NotificationCenter.default.removeObserver(
601
+ self,
602
+ name: .AVPlayerItemDidPlayToEndTime,
603
+ object: item
604
+ )
605
+ if let subtitleSelectionObserver {
606
+ NotificationCenter.default.removeObserver(subtitleSelectionObserver)
607
+ self.subtitleSelectionObserver = nil
608
+ }
175
609
  }
176
610
 
177
611
  private func setupChromecast() {
@@ -210,16 +644,9 @@ class FullscreenVideoPlayer: NSObject {
210
644
  private func setupObservers() {
211
645
  guard let player = player else { return }
212
646
 
213
- // Observe player status
214
- playerItem?.addObserver(self, forKeyPath: "status", options: [.new], context: nil)
215
-
216
- // Observe playback end
217
- NotificationCenter.default.addObserver(
218
- self,
219
- selector: #selector(playerDidFinishPlaying),
220
- name: .AVPlayerItemDidPlayToEndTime,
221
- object: playerItem
222
- )
647
+ if let playerItem {
648
+ addPlayerItemObservers(playerItem)
649
+ }
223
650
 
224
651
  // Observe time updates
225
652
  let interval = CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
@@ -235,7 +662,8 @@ class FullscreenVideoPlayer: NSObject {
235
662
  if keyPath == "status" {
236
663
  if let item = object as? AVPlayerItem {
237
664
  if item.status == .readyToPlay {
238
- onReady?()
665
+ refreshSubtitleButton()
666
+ emitReadyIfNeeded()
239
667
  }
240
668
  }
241
669
  } else if keyPath == "rate" {
@@ -277,10 +705,12 @@ class FullscreenVideoPlayer: NSObject {
277
705
 
278
706
  presentingViewController = viewController
279
707
  viewController.present(playerVC, animated: true) {
280
- playerVC.presentationController?.delegate = self
708
+ self.refreshSubtitleButton()
709
+ self.castController?.installOverlayIfNeeded()
281
710
  self.play()
282
711
  completion()
283
712
  }
713
+ configurePresentationDelegate(for: playerVC)
284
714
  }
285
715
 
286
716
  func show(on viewController: UIViewController, completion: @escaping () -> Void) {
@@ -294,9 +724,9 @@ class FullscreenVideoPlayer: NSObject {
294
724
  }
295
725
  presentingViewController = viewController
296
726
  viewController.present(playerVC, animated: true) {
297
- playerVC.presentationController?.delegate = self
298
727
  completion()
299
728
  }
729
+ configurePresentationDelegate(for: playerVC)
300
730
  }
301
731
 
302
732
  func hide(completion: @escaping () -> Void) {
@@ -308,7 +738,9 @@ class FullscreenVideoPlayer: NSObject {
308
738
  completion()
309
739
  return
310
740
  }
311
- playerVC.dismiss(animated: true) {
741
+ suppressDismissExit = true
742
+ playerVC.dismiss(animated: true) { [weak self] in
743
+ self?.suppressDismissExit = false
312
744
  completion()
313
745
  }
314
746
  }
@@ -321,8 +753,15 @@ class FullscreenVideoPlayer: NSObject {
321
753
  }
322
754
 
323
755
  private func cleanup(stopRemoteMedia: Bool = false) {
756
+ nonHlsBootstrapLoadId = nil
324
757
  castController?.detach(stopRemoteMedia: stopRemoteMedia)
325
758
  castController = nil
759
+ if let subtitleSelectionObserver {
760
+ NotificationCenter.default.removeObserver(subtitleSelectionObserver)
761
+ self.subtitleSelectionObserver = nil
762
+ }
763
+ subtitleButton?.removeFromSuperview()
764
+ subtitleButton = nil
326
765
  contentKeySession?.setDelegate(nil, queue: nil)
327
766
  contentKeySession = nil
328
767
  hlsResourceLoader = nil
@@ -331,7 +770,7 @@ class FullscreenVideoPlayer: NSObject {
331
770
  player?.removeTimeObserver(observer)
332
771
  timeObserver = nil
333
772
  }
334
- playerItem?.removeObserver(self, forKeyPath: "status")
773
+ removePlayerItemObservers(playerItem)
335
774
  NotificationCenter.default.removeObserver(self)
336
775
  player?.pause()
337
776
  player = nil
@@ -382,13 +821,22 @@ class FullscreenVideoPlayer: NSObject {
382
821
  }
383
822
  }
384
823
 
824
+ private func configurePresentationDelegate(for playerVC: UIViewController) {
825
+ playerVC.presentationController?.delegate = self
826
+ }
827
+
385
828
  private func emitExitIfNeeded(currentTime: Double? = nil) {
386
- guard !didEmitExit else { return }
387
- didEmitExit = true
829
+ exitEmission.emitIfNeeded {
830
+ let time = currentTime ?? getCurrentTime()
831
+ cleanup(stopRemoteMedia: true)
832
+ onExit?(time)
833
+ }
834
+ }
388
835
 
389
- let time = currentTime ?? getCurrentTime()
390
- cleanup(stopRemoteMedia: true)
391
- onExit?(time)
836
+ private func emitReadyIfNeeded() {
837
+ guard !didEmitReady, let onReady else { return }
838
+ didEmitReady = true
839
+ onReady()
392
840
  }
393
841
 
394
842
  // MARK: - Playback Control
@@ -503,6 +951,9 @@ class FullscreenVideoPlayer: NSObject {
503
951
 
504
952
  func setOnReady(_ callback: @escaping () -> Void) {
505
953
  self.onReady = callback
954
+ if playerItem?.status == .readyToPlay {
955
+ emitReadyIfNeeded()
956
+ }
506
957
  }
507
958
 
508
959
  func setOnEnd(_ callback: @escaping () -> Void) {
@@ -534,6 +985,14 @@ extension FullscreenVideoPlayer: AVPlayerViewControllerDelegate {
534
985
  emitExitIfNeeded()
535
986
  }
536
987
 
988
+ func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
989
+ isTransitioningToPictureInPicture = true
990
+ }
991
+
992
+ func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
993
+ isTransitioningToPictureInPicture = false
994
+ }
995
+
537
996
  func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(
538
997
  _ playerViewController: AVPlayerViewController
539
998
  ) -> Bool {