@capgo/capacitor-video-player 8.2.3 → 8.2.5
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/build.gradle +1 -1
- package/android/src/main/java/com/capgo/videoplayer/VideoPlayerPlugin.java +1 -1
- package/ios/Sources/VideoPlayerPlugin/FullscreenVideoPlayer.swift +446 -12
- package/ios/Sources/VideoPlayerPlugin/VideoPlayerPlugin.swift +1 -1
- package/ios/Tests/VideoPlayerPluginTests/HLSSubtitlePlaylistTests.swift +10 -0
- package/package.json +1 -1
package/android/build.gradle
CHANGED
|
@@ -56,7 +56,7 @@ dependencies {
|
|
|
56
56
|
implementation 'androidx.recyclerview:recyclerview:1.4.0'
|
|
57
57
|
implementation 'androidx.cardview:cardview:1.0.0'
|
|
58
58
|
implementation 'androidx.gridlayout:gridlayout:1.1.0'
|
|
59
|
-
implementation 'androidx.constraintlayout:constraintlayout:2.2.
|
|
59
|
+
implementation 'androidx.constraintlayout:constraintlayout:2.2.2'
|
|
60
60
|
implementation 'com.google.android.exoplayer:exoplayer-core:2.19.1'
|
|
61
61
|
implementation 'com.google.android.exoplayer:exoplayer-ui:2.19.1'
|
|
62
62
|
implementation 'com.google.android.exoplayer:exoplayer-hls:2.19.1'
|
|
@@ -46,7 +46,7 @@ import org.json.JSONObject;
|
|
|
46
46
|
)
|
|
47
47
|
public class VideoPlayerPlugin extends Plugin {
|
|
48
48
|
|
|
49
|
-
private final String pluginVersion = "8.2.
|
|
49
|
+
private final String pluginVersion = "8.2.5";
|
|
50
50
|
|
|
51
51
|
// Permission alias constants
|
|
52
52
|
private static final String PERMISSION_DENIED_ERROR = "Unable to access media videos, user denied permission request";
|
|
@@ -22,6 +22,7 @@ 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 didEmitReady: Bool = false
|
|
25
26
|
private var didEmitExit: Bool = false
|
|
26
27
|
private var timeObserver: Any?
|
|
27
28
|
private var onPlay: (() -> Void)?
|
|
@@ -38,6 +39,11 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
38
39
|
private weak var presentingViewController: UIViewController?
|
|
39
40
|
private var subtitleTracks: [VideoSubtitleTrack] = []
|
|
40
41
|
private var hlsResourceLoader: HLSSubtitleResourceLoader?
|
|
42
|
+
private var subtitleButton: UIButton?
|
|
43
|
+
private var subtitleSelectionObserver: NSObjectProtocol?
|
|
44
|
+
private var nonHlsSubtitleModeEnabled: Bool = false
|
|
45
|
+
private var selectedNonHlsSubtitleIndex: Int? = nil
|
|
46
|
+
private var nonHlsBootstrapLoadId: UUID?
|
|
41
47
|
|
|
42
48
|
init(
|
|
43
49
|
playerId: String,
|
|
@@ -92,14 +98,59 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
92
98
|
guard let subtitleURL = track.resolvedURL else { return nil }
|
|
93
99
|
return (subtitleURL, track.language)
|
|
94
100
|
}
|
|
101
|
+
let isLikelyHlsSource = Self.isLikelyHLS(videoUrl)
|
|
95
102
|
|
|
96
103
|
guard !resolvedTracks.isEmpty else {
|
|
104
|
+
nonHlsSubtitleModeEnabled = false
|
|
105
|
+
selectedNonHlsSubtitleIndex = nil
|
|
106
|
+
nonHlsBootstrapLoadId = nil
|
|
97
107
|
let asset = makeVideoAsset(url: url, subtitleTracks: [])
|
|
98
108
|
configurePlayer(with: AVPlayerItem(asset: asset))
|
|
99
109
|
completion()
|
|
100
110
|
return
|
|
101
111
|
}
|
|
102
112
|
|
|
113
|
+
// For non-HLS media (e.g. MP4 + sidecar subtitles), initialize with only the
|
|
114
|
+
// first usable subtitle track and switch tracks later via the custom subtitle button.
|
|
115
|
+
if !isLikelyHlsSource {
|
|
116
|
+
nonHlsSubtitleModeEnabled = true
|
|
117
|
+
let firstValidIndex = validNonHlsSubtitleTracks.first?.index
|
|
118
|
+
selectedNonHlsSubtitleIndex = firstValidIndex
|
|
119
|
+
let baseAsset = makeVideoAsset(url: url, subtitleTracks: [])
|
|
120
|
+
configurePlayer(with: AVPlayerItem(asset: baseAsset))
|
|
121
|
+
completion()
|
|
122
|
+
|
|
123
|
+
guard let firstValidIndex else {
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let bootstrapLoadId = UUID()
|
|
128
|
+
nonHlsBootstrapLoadId = bootstrapLoadId
|
|
129
|
+
|
|
130
|
+
Task { [weak self] in
|
|
131
|
+
guard let self else { return }
|
|
132
|
+
let initialTrack = [self.subtitleTracks[firstValidIndex]]
|
|
133
|
+
let item = await ProgressiveVideoPlayerItemFactory.createPlayerItem(
|
|
134
|
+
videoAsset: baseAsset,
|
|
135
|
+
subtitleTracks: initialTrack
|
|
136
|
+
)
|
|
137
|
+
await MainActor.run {
|
|
138
|
+
guard self.nonHlsSubtitleModeEnabled,
|
|
139
|
+
self.nonHlsBootstrapLoadId == bootstrapLoadId,
|
|
140
|
+
self.selectedNonHlsSubtitleIndex == firstValidIndex else {
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
self.replaceCurrentItemPreservingPlayback(with: item)
|
|
144
|
+
self.refreshSubtitleButton()
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
nonHlsSubtitleModeEnabled = false
|
|
151
|
+
selectedNonHlsSubtitleIndex = nil
|
|
152
|
+
nonHlsBootstrapLoadId = nil
|
|
153
|
+
|
|
103
154
|
// Prefer the resource-loader path when makeAsset can safely wrap the stream
|
|
104
155
|
// (HLS / remote progressive). Local progressive file:// sources return a nil
|
|
105
156
|
// loader and must use composition so playback still starts (#78).
|
|
@@ -172,6 +223,378 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
172
223
|
|
|
173
224
|
setupChromecast()
|
|
174
225
|
setupObservers()
|
|
226
|
+
setupSubtitleSelectionObserver()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private func setupSubtitleSelectionObserver() {
|
|
230
|
+
guard subtitleSelectionObserver == nil, let playerItem else { return }
|
|
231
|
+
subtitleSelectionObserver = NotificationCenter.default.addObserver(
|
|
232
|
+
forName: AVPlayerItem.mediaSelectionDidChangeNotification,
|
|
233
|
+
object: playerItem,
|
|
234
|
+
queue: .main
|
|
235
|
+
) { [weak self] _ in
|
|
236
|
+
self?.refreshSubtitleButton()
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private static func isLikelyHLS(_ value: String) -> Bool {
|
|
241
|
+
guard let url = resolveMediaURL(value) else { return false }
|
|
242
|
+
return HLSVideoAssetFactory.isHLSStream(url)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private var validNonHlsSubtitleTracks: [(index: Int, track: VideoSubtitleTrack)] {
|
|
246
|
+
subtitleTracks.enumerated().compactMap { index, track in
|
|
247
|
+
guard track.resolvedURL != nil else { return nil }
|
|
248
|
+
return (index, track)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private func shouldShowCustomSubtitleButton() -> Bool {
|
|
253
|
+
guard showControls else { return false }
|
|
254
|
+
// HLS usually has native subtitle selection UI; this custom button targets MP4 + sidecar subtitles.
|
|
255
|
+
return !Self.isLikelyHLS(videoUrl)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private func shouldShowNonHlsSubtitleButton() -> Bool {
|
|
259
|
+
return nonHlsSubtitleModeEnabled && validNonHlsSubtitleTracks.count > 1 && shouldShowCustomSubtitleButton()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private func refreshSubtitleButton() {
|
|
263
|
+
DispatchQueue.main.async { [weak self] in
|
|
264
|
+
self?.installOrUpdateSubtitleButtonIfNeeded()
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private func installOrUpdateSubtitleButtonIfNeeded() {
|
|
269
|
+
guard shouldShowCustomSubtitleButton(),
|
|
270
|
+
let playerVC = playerViewController else {
|
|
271
|
+
subtitleButton?.removeFromSuperview()
|
|
272
|
+
subtitleButton = nil
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if subtitleButton == nil {
|
|
277
|
+
guard let overlayView = playerVC.contentOverlayView else {
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
let button = UIButton(type: .system)
|
|
281
|
+
button.translatesAutoresizingMaskIntoConstraints = false
|
|
282
|
+
if #available(iOS 13.0, *) {
|
|
283
|
+
button.setImage(UIImage(systemName: "captions.bubble"), for: .normal)
|
|
284
|
+
} else {
|
|
285
|
+
button.setTitle("Sub", for: .normal)
|
|
286
|
+
}
|
|
287
|
+
button.tintColor = .white
|
|
288
|
+
button.backgroundColor = UIColor.black.withAlphaComponent(0.45)
|
|
289
|
+
button.layer.cornerRadius = 22
|
|
290
|
+
button.clipsToBounds = true
|
|
291
|
+
button.contentHorizontalAlignment = .center
|
|
292
|
+
button.contentVerticalAlignment = .center
|
|
293
|
+
|
|
294
|
+
if #available(iOS 14.0, *) {
|
|
295
|
+
button.showsMenuAsPrimaryAction = true
|
|
296
|
+
} else {
|
|
297
|
+
button.addTarget(self, action: #selector(showSubtitleActionSheet), for: .touchUpInside)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
overlayView.addSubview(button)
|
|
301
|
+
NSLayoutConstraint.activate([
|
|
302
|
+
button.topAnchor.constraint(equalTo: overlayView.safeAreaLayoutGuide.topAnchor, constant: 60),
|
|
303
|
+
button.leadingAnchor.constraint(equalTo: overlayView.safeAreaLayoutGuide.leadingAnchor, constant: 16),
|
|
304
|
+
button.widthAnchor.constraint(equalToConstant: 44),
|
|
305
|
+
button.heightAnchor.constraint(equalToConstant: 44)
|
|
306
|
+
])
|
|
307
|
+
subtitleButton = button
|
|
308
|
+
}
|
|
309
|
+
subtitleButton?.isHidden = false
|
|
310
|
+
subtitleButton?.alpha = 1
|
|
311
|
+
|
|
312
|
+
if shouldShowNonHlsSubtitleButton() {
|
|
313
|
+
if #available(iOS 14.0, *) {
|
|
314
|
+
subtitleButton?.menu = buildNonHlsSubtitleMenu()
|
|
315
|
+
}
|
|
316
|
+
updateNonHlsSubtitleButtonAccessibility()
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
guard let playerItem,
|
|
321
|
+
let group = playerItem.asset.mediaSelectionGroup(forMediaCharacteristic: .legible),
|
|
322
|
+
!group.options.isEmpty else {
|
|
323
|
+
subtitleButton?.removeFromSuperview()
|
|
324
|
+
subtitleButton = nil
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if #available(iOS 14.0, *) {
|
|
329
|
+
subtitleButton?.menu = buildSubtitleMenu(group: group)
|
|
330
|
+
}
|
|
331
|
+
updateSubtitleButtonAccessibility(group: group)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private func updateSubtitleButtonAccessibility(group: AVMediaSelectionGroup) {
|
|
335
|
+
let selectedOption = playerItem?.selectedMediaOption(in: group)
|
|
336
|
+
let currentTitle = selectedOption.map(Self.subtitleDisplayName) ?? "Off"
|
|
337
|
+
subtitleButton?.accessibilityLabel = "Subtitles"
|
|
338
|
+
subtitleButton?.accessibilityValue = currentTitle
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
@available(iOS 14.0, *)
|
|
342
|
+
private func buildSubtitleMenu(group: AVMediaSelectionGroup) -> UIMenu {
|
|
343
|
+
let selectedOption = playerItem?.selectedMediaOption(in: group)
|
|
344
|
+
|
|
345
|
+
let offAction = UIAction(
|
|
346
|
+
title: "Off",
|
|
347
|
+
state: selectedOption == nil ? .on : .off
|
|
348
|
+
) { [weak self] _ in
|
|
349
|
+
guard let self, let playerItem = self.playerItem else { return }
|
|
350
|
+
playerItem.select(nil, in: group)
|
|
351
|
+
self.refreshSubtitleButton()
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
let trackActions = group.options.map { option in
|
|
355
|
+
UIAction(
|
|
356
|
+
title: Self.subtitleDisplayName(option),
|
|
357
|
+
state: option == selectedOption ? .on : .off
|
|
358
|
+
) { [weak self] _ in
|
|
359
|
+
guard let self, let playerItem = self.playerItem else { return }
|
|
360
|
+
playerItem.select(option, in: group)
|
|
361
|
+
self.refreshSubtitleButton()
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return UIMenu(title: "Subtitles", children: [offAction] + trackActions)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
@available(iOS 14.0, *)
|
|
369
|
+
private func buildNonHlsSubtitleMenu() -> UIMenu {
|
|
370
|
+
let offAction = UIAction(
|
|
371
|
+
title: "Off",
|
|
372
|
+
state: selectedNonHlsSubtitleIndex == nil ? .on : .off
|
|
373
|
+
) { [weak self] _ in
|
|
374
|
+
self?.switchNonHlsSubtitleTrack(index: nil)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let trackActions = validNonHlsSubtitleTracks.map { index, track in
|
|
378
|
+
UIAction(
|
|
379
|
+
title: Self.nonHlsSubtitleDisplayName(track: track, index: index),
|
|
380
|
+
state: selectedNonHlsSubtitleIndex == index ? .on : .off
|
|
381
|
+
) { [weak self] _ in
|
|
382
|
+
self?.switchNonHlsSubtitleTrack(index: index)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return UIMenu(title: "Subtitles", children: [offAction] + trackActions)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
@objc
|
|
390
|
+
private func showSubtitleActionSheet() {
|
|
391
|
+
if shouldShowNonHlsSubtitleButton() {
|
|
392
|
+
showNonHlsSubtitleActionSheet()
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
guard let playerVC = playerViewController,
|
|
397
|
+
let playerItem,
|
|
398
|
+
let group = playerItem.asset.mediaSelectionGroup(forMediaCharacteristic: .legible),
|
|
399
|
+
!group.options.isEmpty else {
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let sheet = UIAlertController(title: "Subtitles", message: nil, preferredStyle: .actionSheet)
|
|
404
|
+
let selectedOption = playerItem.selectedMediaOption(in: group)
|
|
405
|
+
|
|
406
|
+
let offTitle = selectedOption == nil ? "Off ✓" : "Off"
|
|
407
|
+
sheet.addAction(UIAlertAction(title: offTitle, style: .default) { [weak self] _ in
|
|
408
|
+
guard let self else { return }
|
|
409
|
+
playerItem.select(nil, in: group)
|
|
410
|
+
self.refreshSubtitleButton()
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
for option in group.options {
|
|
414
|
+
let isSelected = option == selectedOption
|
|
415
|
+
let title = isSelected ? "\(Self.subtitleDisplayName(option)) ✓" : Self.subtitleDisplayName(option)
|
|
416
|
+
sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
|
417
|
+
guard let self else { return }
|
|
418
|
+
playerItem.select(option, in: group)
|
|
419
|
+
self.refreshSubtitleButton()
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
|
424
|
+
if let popover = sheet.popoverPresentationController,
|
|
425
|
+
let subtitleButton {
|
|
426
|
+
popover.sourceView = subtitleButton
|
|
427
|
+
popover.sourceRect = subtitleButton.bounds
|
|
428
|
+
}
|
|
429
|
+
playerVC.present(sheet, animated: true)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private func showNonHlsSubtitleActionSheet() {
|
|
433
|
+
guard let playerVC = playerViewController else { return }
|
|
434
|
+
|
|
435
|
+
let sheet = UIAlertController(title: "Subtitles", message: nil, preferredStyle: .actionSheet)
|
|
436
|
+
let offTitle = selectedNonHlsSubtitleIndex == nil ? "Off ✓" : "Off"
|
|
437
|
+
sheet.addAction(UIAlertAction(title: offTitle, style: .default) { [weak self] _ in
|
|
438
|
+
self?.switchNonHlsSubtitleTrack(index: nil)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
for (index, track) in validNonHlsSubtitleTracks {
|
|
442
|
+
let isSelected = selectedNonHlsSubtitleIndex == index
|
|
443
|
+
let label = Self.nonHlsSubtitleDisplayName(track: track, index: index)
|
|
444
|
+
let title = isSelected ? "\(label) ✓" : label
|
|
445
|
+
sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
|
446
|
+
self?.switchNonHlsSubtitleTrack(index: index)
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
|
451
|
+
if let popover = sheet.popoverPresentationController,
|
|
452
|
+
let subtitleButton {
|
|
453
|
+
popover.sourceView = subtitleButton
|
|
454
|
+
popover.sourceRect = subtitleButton.bounds
|
|
455
|
+
}
|
|
456
|
+
playerVC.present(sheet, animated: true)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private static func subtitleDisplayName(_ option: AVMediaSelectionOption) -> String {
|
|
460
|
+
let optionDisplayName = option.displayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
461
|
+
if !optionDisplayName.isEmpty {
|
|
462
|
+
return localizedLanguageName(from: optionDisplayName) ?? optionDisplayName
|
|
463
|
+
}
|
|
464
|
+
if let tag = option.extendedLanguageTag, !tag.isEmpty {
|
|
465
|
+
return localizedLanguageName(from: tag) ?? tag
|
|
466
|
+
}
|
|
467
|
+
if let localeId = option.locale?.identifier, !localeId.isEmpty {
|
|
468
|
+
return localizedLanguageName(from: localeId) ?? localeId
|
|
469
|
+
}
|
|
470
|
+
return "Unknown"
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private static func nonHlsSubtitleDisplayName(track: VideoSubtitleTrack, index: Int) -> String {
|
|
474
|
+
if let language = track.language?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
475
|
+
!language.isEmpty {
|
|
476
|
+
return localizedLanguageName(from: language) ?? language
|
|
477
|
+
}
|
|
478
|
+
return "Subtitle \(index + 1)"
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private static func localizedLanguageName(from rawValue: String) -> String? {
|
|
482
|
+
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
483
|
+
guard !trimmed.isEmpty else { return nil }
|
|
484
|
+
|
|
485
|
+
let normalized = trimmed.replacingOccurrences(of: "_", with: "-")
|
|
486
|
+
|
|
487
|
+
if let identifierName = Locale.current.localizedString(forIdentifier: normalized),
|
|
488
|
+
!identifierName.isEmpty,
|
|
489
|
+
identifierName.caseInsensitiveCompare(trimmed) != .orderedSame {
|
|
490
|
+
return identifierName
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if let languageCodeName = Locale.current.localizedString(forLanguageCode: normalized.lowercased()),
|
|
494
|
+
!languageCodeName.isEmpty,
|
|
495
|
+
languageCodeName.caseInsensitiveCompare(trimmed) != .orderedSame {
|
|
496
|
+
return languageCodeName
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return nil
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private func updateNonHlsSubtitleButtonAccessibility() {
|
|
503
|
+
let title: String
|
|
504
|
+
if let index = selectedNonHlsSubtitleIndex,
|
|
505
|
+
subtitleTracks.indices.contains(index) {
|
|
506
|
+
title = Self.nonHlsSubtitleDisplayName(track: subtitleTracks[index], index: index)
|
|
507
|
+
} else {
|
|
508
|
+
title = "Off"
|
|
509
|
+
}
|
|
510
|
+
subtitleButton?.accessibilityLabel = "Subtitles"
|
|
511
|
+
subtitleButton?.accessibilityValue = title
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private func switchNonHlsSubtitleTrack(index: Int?) {
|
|
515
|
+
guard nonHlsSubtitleModeEnabled,
|
|
516
|
+
selectedNonHlsSubtitleIndex != index else {
|
|
517
|
+
refreshSubtitleButton()
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
guard let videoURL = Self.resolveMediaURL(videoUrl) else { return }
|
|
521
|
+
|
|
522
|
+
if let index, !validNonHlsSubtitleTracks.contains(where: { $0.index == index }) {
|
|
523
|
+
return
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
selectedNonHlsSubtitleIndex = index
|
|
527
|
+
nonHlsBootstrapLoadId = nil
|
|
528
|
+
|
|
529
|
+
Task {
|
|
530
|
+
let baseAsset = makeVideoAsset(url: videoURL, subtitleTracks: [])
|
|
531
|
+
let trackSelection: [VideoSubtitleTrack]
|
|
532
|
+
if let index {
|
|
533
|
+
trackSelection = [subtitleTracks[index]]
|
|
534
|
+
} else {
|
|
535
|
+
trackSelection = []
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
let newItem = await ProgressiveVideoPlayerItemFactory.createPlayerItem(
|
|
539
|
+
videoAsset: baseAsset,
|
|
540
|
+
subtitleTracks: trackSelection
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
await MainActor.run {
|
|
544
|
+
guard self.nonHlsSubtitleModeEnabled,
|
|
545
|
+
self.selectedNonHlsSubtitleIndex == index else {
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
self.replaceCurrentItemPreservingPlayback(with: newItem)
|
|
549
|
+
self.refreshSubtitleButton()
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private func replaceCurrentItemPreservingPlayback(with newItem: AVPlayerItem) {
|
|
555
|
+
guard let player else { return }
|
|
556
|
+
|
|
557
|
+
let previousTime = player.currentTime()
|
|
558
|
+
let wasPlaying = player.rate > 0
|
|
559
|
+
let targetRate = rate
|
|
560
|
+
|
|
561
|
+
let oldItem = playerItem
|
|
562
|
+
removePlayerItemObservers(oldItem)
|
|
563
|
+
playerItem = newItem
|
|
564
|
+
player.replaceCurrentItem(with: newItem)
|
|
565
|
+
addPlayerItemObservers(newItem)
|
|
566
|
+
setupSubtitleSelectionObserver()
|
|
567
|
+
|
|
568
|
+
player.seek(to: previousTime) { _ in
|
|
569
|
+
if wasPlaying {
|
|
570
|
+
player.play()
|
|
571
|
+
player.rate = targetRate
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private func addPlayerItemObservers(_ item: AVPlayerItem) {
|
|
577
|
+
item.addObserver(self, forKeyPath: "status", options: [.new], context: nil)
|
|
578
|
+
NotificationCenter.default.addObserver(
|
|
579
|
+
self,
|
|
580
|
+
selector: #selector(playerDidFinishPlaying),
|
|
581
|
+
name: .AVPlayerItemDidPlayToEndTime,
|
|
582
|
+
object: item
|
|
583
|
+
)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
private func removePlayerItemObservers(_ item: AVPlayerItem?) {
|
|
587
|
+
guard let item else { return }
|
|
588
|
+
item.removeObserver(self, forKeyPath: "status")
|
|
589
|
+
NotificationCenter.default.removeObserver(
|
|
590
|
+
self,
|
|
591
|
+
name: .AVPlayerItemDidPlayToEndTime,
|
|
592
|
+
object: item
|
|
593
|
+
)
|
|
594
|
+
if let subtitleSelectionObserver {
|
|
595
|
+
NotificationCenter.default.removeObserver(subtitleSelectionObserver)
|
|
596
|
+
self.subtitleSelectionObserver = nil
|
|
597
|
+
}
|
|
175
598
|
}
|
|
176
599
|
|
|
177
600
|
private func setupChromecast() {
|
|
@@ -210,16 +633,9 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
210
633
|
private func setupObservers() {
|
|
211
634
|
guard let player = player else { return }
|
|
212
635
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
// Observe playback end
|
|
217
|
-
NotificationCenter.default.addObserver(
|
|
218
|
-
self,
|
|
219
|
-
selector: #selector(playerDidFinishPlaying),
|
|
220
|
-
name: .AVPlayerItemDidPlayToEndTime,
|
|
221
|
-
object: playerItem
|
|
222
|
-
)
|
|
636
|
+
if let playerItem {
|
|
637
|
+
addPlayerItemObservers(playerItem)
|
|
638
|
+
}
|
|
223
639
|
|
|
224
640
|
// Observe time updates
|
|
225
641
|
let interval = CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
|
@@ -235,7 +651,8 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
235
651
|
if keyPath == "status" {
|
|
236
652
|
if let item = object as? AVPlayerItem {
|
|
237
653
|
if item.status == .readyToPlay {
|
|
238
|
-
|
|
654
|
+
refreshSubtitleButton()
|
|
655
|
+
emitReadyIfNeeded()
|
|
239
656
|
}
|
|
240
657
|
}
|
|
241
658
|
} else if keyPath == "rate" {
|
|
@@ -278,6 +695,7 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
278
695
|
presentingViewController = viewController
|
|
279
696
|
viewController.present(playerVC, animated: true) {
|
|
280
697
|
playerVC.presentationController?.delegate = self
|
|
698
|
+
self.refreshSubtitleButton()
|
|
281
699
|
self.play()
|
|
282
700
|
completion()
|
|
283
701
|
}
|
|
@@ -321,8 +739,15 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
321
739
|
}
|
|
322
740
|
|
|
323
741
|
private func cleanup(stopRemoteMedia: Bool = false) {
|
|
742
|
+
nonHlsBootstrapLoadId = nil
|
|
324
743
|
castController?.detach(stopRemoteMedia: stopRemoteMedia)
|
|
325
744
|
castController = nil
|
|
745
|
+
if let subtitleSelectionObserver {
|
|
746
|
+
NotificationCenter.default.removeObserver(subtitleSelectionObserver)
|
|
747
|
+
self.subtitleSelectionObserver = nil
|
|
748
|
+
}
|
|
749
|
+
subtitleButton?.removeFromSuperview()
|
|
750
|
+
subtitleButton = nil
|
|
326
751
|
contentKeySession?.setDelegate(nil, queue: nil)
|
|
327
752
|
contentKeySession = nil
|
|
328
753
|
hlsResourceLoader = nil
|
|
@@ -331,7 +756,7 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
331
756
|
player?.removeTimeObserver(observer)
|
|
332
757
|
timeObserver = nil
|
|
333
758
|
}
|
|
334
|
-
playerItem
|
|
759
|
+
removePlayerItemObservers(playerItem)
|
|
335
760
|
NotificationCenter.default.removeObserver(self)
|
|
336
761
|
player?.pause()
|
|
337
762
|
player = nil
|
|
@@ -391,6 +816,12 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
391
816
|
onExit?(time)
|
|
392
817
|
}
|
|
393
818
|
|
|
819
|
+
private func emitReadyIfNeeded() {
|
|
820
|
+
guard !didEmitReady, let onReady else { return }
|
|
821
|
+
didEmitReady = true
|
|
822
|
+
onReady()
|
|
823
|
+
}
|
|
824
|
+
|
|
394
825
|
// MARK: - Playback Control
|
|
395
826
|
|
|
396
827
|
func play() {
|
|
@@ -503,6 +934,9 @@ class FullscreenVideoPlayer: NSObject {
|
|
|
503
934
|
|
|
504
935
|
func setOnReady(_ callback: @escaping () -> Void) {
|
|
505
936
|
self.onReady = callback
|
|
937
|
+
if playerItem?.status == .readyToPlay {
|
|
938
|
+
emitReadyIfNeeded()
|
|
939
|
+
}
|
|
506
940
|
}
|
|
507
941
|
|
|
508
942
|
func setOnEnd(_ callback: @escaping () -> Void) {
|
|
@@ -8,7 +8,7 @@ import AVKit
|
|
|
8
8
|
*/
|
|
9
9
|
@objc(VideoPlayerPlugin)
|
|
10
10
|
public class VideoPlayerPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
11
|
-
private let pluginVersion: String = "8.2.
|
|
11
|
+
private let pluginVersion: String = "8.2.5"
|
|
12
12
|
public let identifier = "VideoPlayerPlugin"
|
|
13
13
|
public let jsName = "VideoPlayer"
|
|
14
14
|
public let pluginMethods: [CAPPluginMethod] = [
|
|
@@ -77,6 +77,16 @@ final class HLSSubtitlePlaylistTests: XCTestCase {
|
|
|
77
77
|
XCTAssertEqual(result.asset.url.scheme, "file")
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
func testIsHLSStreamDetectsMpegurlQuery() {
|
|
81
|
+
let url = URL(string: "https://cdn.example.com/video?format=mpegurl")!
|
|
82
|
+
XCTAssertTrue(HLSVideoAssetFactory.isHLSStream(url))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func testIsHLSStreamRejectsProgressiveMp4() {
|
|
86
|
+
let url = URL(string: "https://cdn.example.com/video.mp4")!
|
|
87
|
+
XCTAssertFalse(HLSVideoAssetFactory.isHLSStream(url))
|
|
88
|
+
}
|
|
89
|
+
|
|
80
90
|
func testMakeAssetUsesResourceLoaderForLocalHLSSubtitles() {
|
|
81
91
|
let videoURL = URL(fileURLWithPath: "/var/mobile/Containers/Data/Application/stream.m3u8")
|
|
82
92
|
let tracks = [
|