@capgo/capacitor-video-player 8.1.21 → 8.1.23
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/AndroidManifest.xml +6 -2
- package/android/src/main/java/com/capgo/videoplayer/FullscreenExoPlayerFragment.java +15 -6
- package/android/src/main/java/com/capgo/videoplayer/VideoPlayerCastOptionsProvider.java +26 -0
- package/android/src/main/java/com/capgo/videoplayer/VideoPlayerPlugin.java +1 -1
- package/ios/Sources/VideoPlayerPlugin/FullscreenVideoPlayer.swift +24 -94
- package/ios/Sources/VideoPlayerPlugin/HLSSubtitleResourceLoader.swift +185 -0
- package/ios/Sources/VideoPlayerPlugin/ProgressiveVideoPlayerItemFactory.swift +105 -0
- package/ios/Sources/VideoPlayerPlugin/VideoPlayerPlugin.swift +1 -1
- package/package.json +1 -1
|
@@ -1,3 +1,7 @@
|
|
|
1
|
-
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
2
|
-
>
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
2
|
+
<application>
|
|
3
|
+
<meta-data
|
|
4
|
+
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
|
5
|
+
android:value="com.capgo.videoplayer.VideoPlayerCastOptionsProvider" />
|
|
6
|
+
</application>
|
|
3
7
|
</manifest>
|
|
@@ -207,6 +207,10 @@ public class FullscreenExoPlayerFragment extends Fragment {
|
|
|
207
207
|
private CastStateListener castStateListener = null;
|
|
208
208
|
private Boolean playerReady = false;
|
|
209
209
|
|
|
210
|
+
private boolean isChromecastEnabled() {
|
|
211
|
+
return Boolean.TRUE.equals(chromecast);
|
|
212
|
+
}
|
|
213
|
+
|
|
210
214
|
/**
|
|
211
215
|
* Create Fragment View
|
|
212
216
|
* @param inflater
|
|
@@ -254,7 +258,7 @@ public class FullscreenExoPlayerFragment extends Fragment {
|
|
|
254
258
|
styledPlayerView.setUseController(true);
|
|
255
259
|
}
|
|
256
260
|
|
|
257
|
-
if (!
|
|
261
|
+
if (!isChromecastEnabled()) {
|
|
258
262
|
mediaRouteButton.setVisibility(View.GONE);
|
|
259
263
|
} else {
|
|
260
264
|
initializeCastService();
|
|
@@ -708,7 +712,7 @@ public class FullscreenExoPlayerFragment extends Fragment {
|
|
|
708
712
|
|
|
709
713
|
if (styledPlayerView != null) {
|
|
710
714
|
// If cast is playing then it doesn't start the local player once get backs from background
|
|
711
|
-
if (castContext != null &&
|
|
715
|
+
if (isChromecastEnabled() && castContext != null && castPlayer != null && castPlayer.isCastSessionAvailable()) return;
|
|
712
716
|
|
|
713
717
|
initializePlayer();
|
|
714
718
|
if (player.getCurrentPosition() != 0) {
|
|
@@ -746,7 +750,7 @@ public class FullscreenExoPlayerFragment extends Fragment {
|
|
|
746
750
|
@Override
|
|
747
751
|
public void onDestroy() {
|
|
748
752
|
super.onDestroy();
|
|
749
|
-
if (
|
|
753
|
+
if (isChromecastEnabled() && mRouter != null) mRouter.removeCallback(mCallback);
|
|
750
754
|
releasePlayer();
|
|
751
755
|
}
|
|
752
756
|
|
|
@@ -756,7 +760,9 @@ public class FullscreenExoPlayerFragment extends Fragment {
|
|
|
756
760
|
@Override
|
|
757
761
|
public void onPause() {
|
|
758
762
|
super.onPause();
|
|
759
|
-
if (
|
|
763
|
+
if (isChromecastEnabled() && castContext != null && castStateListener != null) castContext.removeCastStateListener(
|
|
764
|
+
castStateListener
|
|
765
|
+
);
|
|
760
766
|
boolean isAppBackground = false;
|
|
761
767
|
if (bkModeEnabled) isAppBackground = isApplicationSentToBackground(context);
|
|
762
768
|
|
|
@@ -792,7 +798,7 @@ public class FullscreenExoPlayerFragment extends Fragment {
|
|
|
792
798
|
player = null;
|
|
793
799
|
showSystemUI();
|
|
794
800
|
resetVariables();
|
|
795
|
-
if (
|
|
801
|
+
if (isChromecastEnabled() && castPlayer != null) {
|
|
796
802
|
castPlayer.release();
|
|
797
803
|
castPlayer = null;
|
|
798
804
|
}
|
|
@@ -1508,7 +1514,10 @@ public class FullscreenExoPlayerFragment extends Fragment {
|
|
|
1508
1514
|
mRouter.addCallback(mSelector, mCallback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY);
|
|
1509
1515
|
} else {
|
|
1510
1516
|
Exception e = task.getException();
|
|
1511
|
-
e
|
|
1517
|
+
Log.e(TAG, "Failed to initialize Chromecast", e);
|
|
1518
|
+
if (mediaRouteButton != null) {
|
|
1519
|
+
mediaRouteButton.setVisibility(View.GONE);
|
|
1520
|
+
}
|
|
1512
1521
|
}
|
|
1513
1522
|
}
|
|
1514
1523
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
package com.capgo.videoplayer;
|
|
2
|
+
|
|
3
|
+
import android.content.Context;
|
|
4
|
+
import com.google.android.gms.cast.CastMediaControlIntent;
|
|
5
|
+
import com.google.android.gms.cast.framework.CastOptions;
|
|
6
|
+
import com.google.android.gms.cast.framework.OptionsProvider;
|
|
7
|
+
import com.google.android.gms.cast.framework.SessionProvider;
|
|
8
|
+
import com.google.android.gms.cast.framework.media.CastMediaOptions;
|
|
9
|
+
import com.google.android.gms.cast.framework.media.NotificationOptions;
|
|
10
|
+
import java.util.List;
|
|
11
|
+
|
|
12
|
+
public class VideoPlayerCastOptionsProvider implements OptionsProvider {
|
|
13
|
+
|
|
14
|
+
@Override
|
|
15
|
+
public CastOptions getCastOptions(Context context) {
|
|
16
|
+
return new CastOptions.Builder()
|
|
17
|
+
.setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
|
|
18
|
+
.setCastMediaOptions(new CastMediaOptions.Builder().setNotificationOptions(new NotificationOptions.Builder().build()).build())
|
|
19
|
+
.build();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@Override
|
|
23
|
+
public List<SessionProvider> getAdditionalSessionProviders(Context context) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -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.
|
|
42
|
+
private final String pluginVersion = "8.1.23";
|
|
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
|
|
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
|
|
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.
|
|
11
|
+
private let pluginVersion: String = "8.1.23"
|
|
12
12
|
public let identifier = "VideoPlayerPlugin"
|
|
13
13
|
public let jsName = "VideoPlayer"
|
|
14
14
|
public let pluginMethods: [CAPPluginMethod] = [
|