@capgo/capacitor-video-player 8.1.21 → 8.1.22

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.
@@ -39,7 +39,7 @@ import java.util.Map;
39
39
  )
40
40
  public class VideoPlayerPlugin extends Plugin {
41
41
 
42
- private final String pluginVersion = "8.1.21";
42
+ private final String pluginVersion = "8.1.22";
43
43
 
44
44
  // Permission alias constants
45
45
  private static final String PERMISSION_DENIED_ERROR = "Unable to access media videos, user denied permission request";
@@ -31,6 +31,7 @@ class FullscreenVideoPlayer: NSObject {
31
31
  private weak var presentingViewController: UIViewController?
32
32
  private var subtitleUrl: String?
33
33
  private var subtitleLanguage: String?
34
+ private var hlsResourceLoader: HLSSubtitleResourceLoader?
34
35
 
35
36
  init(
36
37
  playerId: String,
@@ -73,18 +74,29 @@ class FullscreenVideoPlayer: NSObject {
73
74
  return
74
75
  }
75
76
 
76
- let asset = makeVideoAsset(url: url)
77
-
78
77
  guard let subtitleUrlString = subtitleUrl,
79
78
  !subtitleUrlString.isEmpty,
80
79
  let subtitleURL = URL(string: subtitleUrlString) else {
80
+ let asset = makeVideoAsset(url: url, subtitleURL: nil)
81
+ configurePlayer(with: AVPlayerItem(asset: asset))
82
+ completion()
83
+ return
84
+ }
85
+
86
+ if HLSVideoAssetFactory.isHLSStream(url) {
87
+ let asset = makeVideoAsset(url: url, subtitleURL: subtitleURL)
81
88
  configurePlayer(with: AVPlayerItem(asset: asset))
82
89
  completion()
83
90
  return
84
91
  }
85
92
 
86
93
  Task {
87
- let item = await createPlayerItem(videoAsset: asset, subtitleURL: subtitleURL)
94
+ let asset = makeVideoAsset(url: url, subtitleURL: nil)
95
+ let item = await ProgressiveVideoPlayerItemFactory.createPlayerItem(
96
+ videoAsset: asset,
97
+ subtitleURL: subtitleURL,
98
+ subtitleLanguage: subtitleLanguage
99
+ )
88
100
  await MainActor.run {
89
101
  self.configurePlayer(with: item)
90
102
  completion()
@@ -92,8 +104,14 @@ class FullscreenVideoPlayer: NSObject {
92
104
  }
93
105
  }
94
106
 
95
- private func makeVideoAsset(url: URL) -> AVURLAsset {
96
- let asset = AVURLAsset(url: url)
107
+ private func makeVideoAsset(url: URL, subtitleURL: URL?) -> AVURLAsset {
108
+ let result = HLSVideoAssetFactory.makeAsset(
109
+ videoURL: url,
110
+ subtitleURL: subtitleURL,
111
+ language: subtitleLanguage ?? "en"
112
+ )
113
+ hlsResourceLoader = result.resourceLoader
114
+ let asset = result.asset
97
115
 
98
116
  if let certUrl = fairplayCertificateUrl, !certUrl.isEmpty {
99
117
  let session = AVContentKeySession(keySystem: .fairPlayStreaming)
@@ -105,95 +123,6 @@ class FullscreenVideoPlayer: NSObject {
105
123
  return asset
106
124
  }
107
125
 
108
- private func createPlayerItem(videoAsset: AVURLAsset, subtitleURL: URL) async -> AVPlayerItem {
109
- let subtitleAsset = AVURLAsset(url: subtitleURL)
110
-
111
- do {
112
- let videoDuration = try await videoAsset.load(.duration)
113
- let composition = AVMutableComposition()
114
-
115
- try await insertTracks(
116
- from: videoAsset,
117
- mediaTypes: [.video, .audio],
118
- duration: videoDuration,
119
- into: composition
120
- )
121
-
122
- var subtitleAdded = false
123
- let subtitleTracks = try await subtitleAsset.loadTracks(withMediaType: .text)
124
- if let subtitleTrack = subtitleTracks.first {
125
- let compositionTrack = composition.addMutableTrack(
126
- withMediaType: .text,
127
- preferredTrackID: kCMPersistentTrackID_Invalid
128
- )
129
- let subtitleDuration = try await subtitleAsset.load(.duration)
130
- let duration = CMTimeCompare(subtitleDuration, videoDuration) < 0 ? subtitleDuration : videoDuration
131
- try compositionTrack?.insertTimeRange(
132
- CMTimeRange(start: .zero, duration: duration),
133
- of: subtitleTrack,
134
- at: CMTime.zero
135
- )
136
- subtitleAdded = true
137
- }
138
-
139
- let playerItem = AVPlayerItem(asset: composition)
140
-
141
- if subtitleAdded {
142
- selectSubtitle(in: playerItem, language: subtitleLanguage)
143
- }
144
-
145
- return playerItem
146
- } catch {
147
- return AVPlayerItem(asset: videoAsset)
148
- }
149
- }
150
-
151
- private func selectSubtitle(in playerItem: AVPlayerItem, language: String?) {
152
- guard let group = playerItem.asset.mediaSelectionGroup(forMediaCharacteristic: .legible) else {
153
- return
154
- }
155
-
156
- let selectedOption = selectSubtitleOption(in: group, language: language)
157
- ?? group.defaultOption
158
- ?? group.options.first
159
-
160
- if let selectedOption {
161
- playerItem.select(selectedOption, in: group)
162
- }
163
- }
164
-
165
- private func insertTracks(
166
- from asset: AVURLAsset,
167
- mediaTypes: [AVMediaType],
168
- duration: CMTime,
169
- into composition: AVMutableComposition
170
- ) async throws {
171
- for mediaType in mediaTypes {
172
- let tracks = try await asset.loadTracks(withMediaType: mediaType)
173
- guard let sourceTrack = tracks.first else { continue }
174
-
175
- let compositionTrack = composition.addMutableTrack(
176
- withMediaType: mediaType,
177
- preferredTrackID: kCMPersistentTrackID_Invalid
178
- )
179
- try compositionTrack?.insertTimeRange(
180
- CMTimeRange(start: .zero, duration: duration),
181
- of: sourceTrack,
182
- at: .zero
183
- )
184
- }
185
- }
186
-
187
- private func selectSubtitleOption(in group: AVMediaSelectionGroup, language: String?) -> AVMediaSelectionOption? {
188
- guard let language, !language.isEmpty else { return nil }
189
-
190
- return group.options.first { option in
191
- option.extendedLanguageTag == language
192
- || option.locale?.identifier == language
193
- || option.locale?.languageCode == language
194
- }
195
- }
196
-
197
126
  private func configurePlayer(with item: AVPlayerItem) {
198
127
  playerItem = item
199
128
  player = AVPlayer(playerItem: playerItem)
@@ -323,6 +252,7 @@ class FullscreenVideoPlayer: NSObject {
323
252
  private func cleanup() {
324
253
  castController?.detach(stopRemoteMedia: false)
325
254
  castController = nil
255
+ hlsResourceLoader = nil
326
256
  if let observer = timeObserver {
327
257
  player?.removeObserver(self, forKeyPath: "rate")
328
258
  player?.removeTimeObserver(observer)
@@ -0,0 +1,185 @@
1
+ import AVFoundation
2
+ import Foundation
3
+
4
+ enum HLSVideoAssetFactory {
5
+ static func isHLSStream(_ url: URL) -> Bool {
6
+ let urlString = url.absoluteString.lowercased()
7
+ let path = url.path.lowercased()
8
+
9
+ return path.hasSuffix(".m3u8")
10
+ || urlString.contains(".m3u8")
11
+ || urlString.contains("mpegurl")
12
+ || urlString.contains("hls_playlist")
13
+ || urlString.contains("hls_manifest")
14
+ }
15
+
16
+ static func makeAsset(
17
+ videoURL: URL,
18
+ subtitleURL: URL?,
19
+ language: String
20
+ ) -> (asset: AVURLAsset, resourceLoader: HLSSubtitleResourceLoader?) {
21
+ guard let subtitleURL, isHLSStream(videoURL) else {
22
+ return (AVURLAsset(url: videoURL), nil)
23
+ }
24
+
25
+ let resourceLoader = HLSSubtitleResourceLoader(
26
+ videoURL: videoURL,
27
+ subtitleURL: subtitleURL,
28
+ language: language
29
+ )
30
+ guard let assetURL = HLSSubtitleResourceLoader.assetURL(for: videoURL) else {
31
+ return (AVURLAsset(url: videoURL), nil)
32
+ }
33
+
34
+ let asset = AVURLAsset(url: assetURL)
35
+ asset.resourceLoader.setDelegate(resourceLoader, queue: DispatchQueue.global(qos: .userInitiated))
36
+ return (asset, resourceLoader)
37
+ }
38
+ }
39
+
40
+ /// Injects external WebVTT subtitles into HLS master playlists via AVAssetResourceLoaderDelegate.
41
+ /// AVMutableComposition cannot demux HLS tracks, so sidecar subtitles must be wired through the manifest.
42
+ final class HLSSubtitleResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
43
+ static let videoScheme = "capgohls"
44
+ static let subtitlePlaylistScheme = "capgohls-sub"
45
+ private static let subtitleGroupID = "capgosubs"
46
+
47
+ private let originalVideoURL: URL
48
+ private let subtitleURL: URL
49
+ private let language: String
50
+
51
+ init(videoURL: URL, subtitleURL: URL, language: String) {
52
+ self.originalVideoURL = videoURL
53
+ self.subtitleURL = subtitleURL
54
+ self.language = language
55
+ }
56
+
57
+ static func assetURL(for videoURL: URL) -> URL? {
58
+ guard var components = URLComponents(url: videoURL, resolvingAgainstBaseURL: false) else {
59
+ return nil
60
+ }
61
+ components.scheme = videoScheme
62
+ return components.url
63
+ }
64
+
65
+ static func subtitlePlaylistURL(for videoURL: URL) -> URL? {
66
+ guard var components = URLComponents(url: videoURL, resolvingAgainstBaseURL: false) else {
67
+ return nil
68
+ }
69
+ components.scheme = subtitlePlaylistScheme
70
+ return components.url
71
+ }
72
+
73
+ func resourceLoader(
74
+ _ resourceLoader: AVAssetResourceLoader,
75
+ shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
76
+ ) -> Bool {
77
+ guard let requestURL = loadingRequest.request.url else {
78
+ return false
79
+ }
80
+
81
+ Task {
82
+ do {
83
+ let data = try await loadData(for: requestURL)
84
+ fulfill(loadingRequest, with: data)
85
+ } catch {
86
+ loadingRequest.finishLoading(with: error)
87
+ }
88
+ }
89
+
90
+ return true
91
+ }
92
+
93
+ private func loadData(for requestURL: URL) async throws -> Data {
94
+ if requestURL.scheme == Self.subtitlePlaylistScheme {
95
+ return Data(subtitleMediaPlaylist().utf8)
96
+ }
97
+
98
+ let fetchURL = originalURL(for: requestURL)
99
+ let (data, _) = try await URLSession.shared.data(from: fetchURL)
100
+ let manifest = String(data: data, encoding: .utf8) ?? ""
101
+
102
+ guard hasStreamInfTags(manifest) else {
103
+ return data
104
+ }
105
+
106
+ guard let subtitlePlaylistURI = Self.subtitlePlaylistURL(for: originalVideoURL)?.absoluteString else {
107
+ return data
108
+ }
109
+
110
+ let modified = injectSubtitle(into: manifest, subtitlePlaylistURI: subtitlePlaylistURI)
111
+ return Data(modified.utf8)
112
+ }
113
+
114
+ private func originalURL(for requestURL: URL) -> URL {
115
+ guard var components = URLComponents(url: requestURL, resolvingAgainstBaseURL: false),
116
+ components.scheme == Self.videoScheme else {
117
+ return requestURL
118
+ }
119
+
120
+ components.scheme = originalVideoURL.scheme ?? "https"
121
+ return components.url ?? originalVideoURL
122
+ }
123
+
124
+ private func hasStreamInfTags(_ manifest: String) -> Bool {
125
+ manifest.contains("#EXT-X-STREAM-INF")
126
+ }
127
+
128
+ private func injectSubtitle(into manifest: String, subtitlePlaylistURI: String) -> String {
129
+ let mediaTag = """
130
+ #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="\(Self.subtitleGroupID)",NAME="Subtitles",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO,LANGUAGE="\(language)",URI="\(subtitlePlaylistURI)"
131
+ """
132
+
133
+ var lines = manifest.components(separatedBy: .newlines)
134
+ var result: [String] = []
135
+ var insertedMediaTag = false
136
+
137
+ for line in lines {
138
+ if line.hasPrefix("#EXT-X-STREAM-INF") {
139
+ if !insertedMediaTag {
140
+ result.append(mediaTag)
141
+ insertedMediaTag = true
142
+ }
143
+
144
+ if line.contains("SUBTITLES=") {
145
+ result.append(line)
146
+ } else {
147
+ result.append("\(line),SUBTITLES=\"\(Self.subtitleGroupID)\"")
148
+ }
149
+ continue
150
+ }
151
+
152
+ result.append(line)
153
+ }
154
+
155
+ if !insertedMediaTag, let extm3uIndex = result.firstIndex(where: { $0.hasPrefix("#EXTM3U") }) {
156
+ result.insert(mediaTag, at: extm3uIndex + 1)
157
+ }
158
+
159
+ return result.joined(separator: "\n")
160
+ }
161
+
162
+ private func subtitleMediaPlaylist() -> String {
163
+ """
164
+ #EXTM3U
165
+ #EXT-X-VERSION:3
166
+ #EXT-X-TARGETDURATION:600
167
+ #EXT-X-MEDIA-SEQUENCE:0
168
+ #EXT-X-PLAYLIST-TYPE:VOD
169
+ #EXTINF:600.0,
170
+ \(subtitleURL.absoluteString)
171
+ #EXT-X-ENDLIST
172
+ """
173
+ }
174
+
175
+ private func fulfill(_ loadingRequest: AVAssetResourceLoadingRequest, with data: Data) {
176
+ if let contentRequest = loadingRequest.contentInformationRequest {
177
+ contentRequest.contentType = "application/vnd.apple.mpegurl"
178
+ contentRequest.contentLength = Int64(data.count)
179
+ contentRequest.isByteRangeAccessSupported = false
180
+ }
181
+
182
+ loadingRequest.dataRequest?.respond(with: data)
183
+ loadingRequest.finishLoading()
184
+ }
185
+ }
@@ -0,0 +1,105 @@
1
+ import AVFoundation
2
+ import Foundation
3
+
4
+ enum ProgressiveVideoPlayerItemFactory {
5
+ static func createPlayerItem(
6
+ videoAsset: AVURLAsset,
7
+ subtitleURL: URL,
8
+ subtitleLanguage: String?
9
+ ) async -> AVPlayerItem {
10
+ let subtitleAsset = AVURLAsset(url: subtitleURL)
11
+
12
+ do {
13
+ let videoDuration = try await videoAsset.load(.duration)
14
+ let composition = AVMutableComposition()
15
+
16
+ try await insertTracks(
17
+ from: videoAsset,
18
+ mediaTypes: [.video, .audio],
19
+ duration: videoDuration,
20
+ into: composition
21
+ )
22
+
23
+ let hasPlayableTracks = composition.tracks.contains {
24
+ $0.mediaType == .video || $0.mediaType == .audio
25
+ }
26
+ if !hasPlayableTracks {
27
+ return AVPlayerItem(asset: videoAsset)
28
+ }
29
+
30
+ var subtitleAdded = false
31
+ let subtitleTracks = try await subtitleAsset.loadTracks(withMediaType: .text)
32
+ if let subtitleTrack = subtitleTracks.first {
33
+ let compositionTrack = composition.addMutableTrack(
34
+ withMediaType: .text,
35
+ preferredTrackID: kCMPersistentTrackID_Invalid
36
+ )
37
+ let subtitleDuration = try await subtitleAsset.load(.duration)
38
+ let duration = CMTimeCompare(subtitleDuration, videoDuration) < 0 ? subtitleDuration : videoDuration
39
+ try compositionTrack?.insertTimeRange(
40
+ CMTimeRange(start: .zero, duration: duration),
41
+ of: subtitleTrack,
42
+ at: CMTime.zero
43
+ )
44
+ subtitleAdded = true
45
+ }
46
+
47
+ let playerItem = AVPlayerItem(asset: composition)
48
+
49
+ if subtitleAdded {
50
+ selectSubtitle(in: playerItem, language: subtitleLanguage)
51
+ }
52
+
53
+ return playerItem
54
+ } catch {
55
+ return AVPlayerItem(asset: videoAsset)
56
+ }
57
+ }
58
+
59
+ private static func selectSubtitle(in playerItem: AVPlayerItem, language: String?) {
60
+ guard let group = playerItem.asset.mediaSelectionGroup(forMediaCharacteristic: .legible) else {
61
+ return
62
+ }
63
+
64
+ let selectedOption = selectSubtitleOption(in: group, language: language)
65
+ ?? group.defaultOption
66
+ ?? group.options.first
67
+
68
+ if let selectedOption {
69
+ playerItem.select(selectedOption, in: group)
70
+ }
71
+ }
72
+
73
+ private static func insertTracks(
74
+ from asset: AVURLAsset,
75
+ mediaTypes: [AVMediaType],
76
+ duration: CMTime,
77
+ into composition: AVMutableComposition
78
+ ) async throws {
79
+ for mediaType in mediaTypes {
80
+ let tracks = try await asset.loadTracks(withMediaType: mediaType)
81
+ guard let sourceTrack = tracks.first else { continue }
82
+
83
+ let compositionTrack = composition.addMutableTrack(
84
+ withMediaType: mediaType,
85
+ preferredTrackID: kCMPersistentTrackID_Invalid
86
+ )
87
+ try compositionTrack?.insertTimeRange(
88
+ CMTimeRange(start: .zero, duration: duration),
89
+ of: sourceTrack,
90
+ at: .zero
91
+ )
92
+ }
93
+ }
94
+
95
+ private static func selectSubtitleOption(in group: AVMediaSelectionGroup, language: String?) -> AVMediaSelectionOption? {
96
+ guard let language, !language.isEmpty else { return nil }
97
+
98
+ return group.options.first { option in
99
+ option.extendedLanguageTag == language
100
+ || option.locale?.identifier == language
101
+ || option.locale?.languageCode == language
102
+ }
103
+ }
104
+
105
+ }
@@ -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.1.21"
11
+ private let pluginVersion: String = "8.1.22"
12
12
  public let identifier = "VideoPlayerPlugin"
13
13
  public let jsName = "VideoPlayer"
14
14
  public let pluginMethods: [CAPPluginMethod] = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-video-player",
3
- "version": "8.1.21",
3
+ "version": "8.1.22",
4
4
  "description": "Capacitor plugin to play video in native player",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",