@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.
- package/android/src/main/java/com/capgo/videoplayer/VideoPlayerPlugin.java +1 -1
- package/ios/Sources/VideoPlayerPlugin/AVPlayerControlsVisibilityObserver.swift +227 -0
- package/ios/Sources/VideoPlayerPlugin/DismissObservingPlayerViewController.swift +20 -0
- package/ios/Sources/VideoPlayerPlugin/ExitEmissionGuard.swift +11 -0
- package/ios/Sources/VideoPlayerPlugin/FullscreenVideoPlayer.swift +485 -26
- package/ios/Sources/VideoPlayerPlugin/VideoPlayerCastController.swift +200 -63
- package/ios/Sources/VideoPlayerPlugin/VideoPlayerPlugin.swift +1 -1
- package/ios/Tests/VideoPlayerPluginTests/HLSSubtitlePlaylistTests.swift +10 -0
- package/ios/Tests/VideoPlayerPluginTests/VideoPlayerCastControllerTests.swift +103 -0
- package/ios/Tests/VideoPlayerPluginTests/VideoPlayerPluginTests.swift +9 -4
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
387
|
-
|
|
829
|
+
exitEmission.emitIfNeeded {
|
|
830
|
+
let time = currentTime ?? getCurrentTime()
|
|
831
|
+
cleanup(stopRemoteMedia: true)
|
|
832
|
+
onExit?(time)
|
|
833
|
+
}
|
|
834
|
+
}
|
|
388
835
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 {
|