Skip to content

Commit 2a4cc6a

Browse files
authored
Merge branch 'main' into bugfix/conviva/persistent-metadata
2 parents bf8a5c1 + b577618 commit 2a4cc6a

7 files changed

Lines changed: 137 additions & 165 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Fixed an issue where the initial metadata passed to the convivaConnector was not persistent across Conviva sessions.
1414
- Fixed an issue where the session's assetName was not configurable by setContentInfo, which has higher precedence than source.metadata.title.
1515

16+
### Added
17+
18+
- SideloadedSubtitle
19+
- Added support for simultaneous multi-source caching with sideloaded subtitles. Previously there was a limitation of caching only a single task at a time.
20+
- Added support to make a sideloaded subtitle selected for caching by default. Use the `isDefault` property in `SSTextTrackDescription` or `TextTrackDescription`. Only one default track can be added.
21+
1622
## [10.7.0] - 2025-12-18
1723

1824
### Changed

Code/Sideloaded-TextTracks/README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ All that is needed is a source with a text track description:
171171
```swift
172172
public static var sourceWithSideloadedTextTrack: SourceDescription {
173173
let typedSource = TypedSource(src: "https://sourceURL.com/manifest.m3u8, type: "application/x-mpegurl")
174-
let textTrack = TextTrackDescription(src: "https://sideloadedurl.com/subtitle.vtt", srclang: "language_code", isDefault: false, kind: .subtitles, label: "Label", format: .WebVTT)
174+
let textTrack = TextTrackDescription(src: "https://sideloadedurl.com/subtitle.vtt", srclang: "language_code", isDefault: true, kind: .subtitles, label: "Label", format: .WebVTT)
175175
return SourceDescription(source: typedSource, textTracks: [textTrack])
176176
}
177177
```
@@ -188,5 +188,4 @@ For more information on how to implement offline playback with caching, please r
188188

189189
### Limitations
190190

191-
1. Caching sources with sideloaded subtitles can only be done one task at a time. This is due to some technical complexities in the underlying implementation. This limitation may be addressed in future releases.
192-
2. Caching is only available on iOS.
191+
1. Caching is only available on iOS.

Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/AVSubtitlesLoader.swift

Lines changed: 95 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -10,94 +10,72 @@ import AVFoundation
1010
import THEOplayerSDK
1111

1212
class AVSubtitlesLoader: NSObject {
13+
private static var instances: [AVSubtitlesLoader] = []
14+
static func addInstance(_ loader: AVSubtitlesLoader) { Self.instances.append(loader) }
15+
static func removeInstance(by id: String) {
16+
Self.instances.removeAll { $0._id == id }
17+
}
18+
1319
private let subtitles: [TextTrackDescription]
14-
private(set) var variantTotalDuration: Double = 0
1520
private let transformer = SubtitlesTransformer()
1621
private let synchronizer: SubtitlesSynchronizer?
22+
private let _id: String
23+
private var variantTotalDuration: Double = 0
1724

18-
init(subtitles: [TextTrackDescription], player: THEOplayer?) {
25+
init(subtitles: [TextTrackDescription], id: String, player: THEOplayer? = nil, cachingTask: CachingTask? = nil) {
1926
self.subtitles = subtitles
27+
self._id = id
2028
self.synchronizer = SubtitlesSynchronizer(player: player)
2129
self.synchronizer?.delegate = self.transformer
30+
31+
super.init()
32+
33+
_ = player?.addEventListener(type: PlayerEventTypes.DESTROY, listener: { [weak self] destroyEvent in self?.handleDestroyEvent() })
34+
_ = cachingTask?.addEventListener(type: CachingTaskEventTypes.STATE_CHANGE, listener: { [weak self] cachingTaskStateChangeEvent in self?.handleCachingTaskStateChangeEvent(task: cachingTask) })
2235
}
23-
24-
func handleMasterManifestRequest(_ request: AVAssetResourceLoadingRequest) -> Bool {
25-
guard let originalURL = request.request.url?.withScheme(newScheme: URLScheme.https) else {
26-
return false
27-
}
28-
29-
MasterPlaylistParser(url: originalURL).sideLoadSubtitles(subtitles: subtitles) { data in
30-
guard let masterManifestData = data else {
31-
print("[AVSubtitlesLoader] ERROR: Couldn't find manifest data")
32-
request.finishLoading(with: URLError(URLError.cannotParseResponse))
33-
return
34-
}
35-
let response = HTTPURLResponse(url: originalURL, statusCode: 200, httpVersion: nil, headerFields: nil)
36-
request.response = response
37-
request.dataRequest?.respond(with: masterManifestData)
38-
request.finishLoading()
36+
37+
func handleMasterManifestRequest(_ url: URL) async -> Data? {
38+
let parser = MasterPlaylistParser(url: url)
39+
40+
guard let responseData = await parser.sideLoadSubtitles(subtitles: subtitles) else {
41+
print("[AVSubtitlesLoader] ERROR: Couldn't find manifest data")
42+
return nil
3943
}
40-
return true
44+
45+
return responseData
4146
}
4247

43-
func handleVariantManifest(_ request: AVAssetResourceLoadingRequest) -> Bool {
44-
guard let customSchemeURL = request.request.url,
45-
let originalURLString = customSchemeURL.absoluteString.byRemovingScheme(scheme: URLScheme.variantm3u8),
46-
let originalURL = URL(string:originalURLString) else {
47-
print("[AVSubtitlesLoader] ERROR: Variant manifest is invalid")
48-
request.finishLoading(with: URLError(URLError.unsupportedURL))
49-
return false
50-
}
51-
52-
VariantPlaylistParser(url: originalURL).parse { playlist in
53-
guard let playlist = playlist, let responseData = playlist.manifestData else {
54-
print("[AVSubtitlesLoader] ERROR: Couldn't find variant data")
55-
request.finishLoading(with: URLError(URLError.cannotParseResponse))
56-
return
57-
}
58-
self.variantTotalDuration = playlist.totalPlayListDuration
59-
let response = HTTPURLResponse(url: originalURL, statusCode: 200, httpVersion: nil, headerFields: nil)
60-
request.response = response
61-
request.dataRequest?.respond(with: responseData)
62-
request.finishLoading()
48+
func handleVariantManifest(_ url: URL) async -> Data? {
49+
let parser = VariantPlaylistParser(url: url)
50+
51+
guard let playlist = await parser.parse(),
52+
let responseData = playlist.manifestData else {
53+
print("[AVSubtitlesLoader] ERROR: Couldn't find variant data")
54+
return nil
6355
}
64-
return true
56+
57+
self.variantTotalDuration = playlist.totalPlayListDuration
58+
return responseData
6559
}
66-
67-
func handleSubtitles(_ request: AVAssetResourceLoadingRequest) -> Bool {
68-
guard let customSchemeURL = request.request.url else {
69-
return false
70-
}
71-
72-
guard let originalURLString = customSchemeURL.absoluteString.byRemovingScheme(scheme: URLScheme.subtitlesm3u8),
73-
let originalURL = URL(string: originalURLString) else {
74-
print("[AVSubtitlesLoader] ERROR: Failed to revert subtitle URL!")
75-
return false
60+
61+
func handleSubtitles(_ url: URL) -> Data? {
62+
guard let trackDescription: THEOplayerSDK.TextTrackDescription = self.findTrackDescription(by: url) else {
63+
return nil
7664
}
77-
78-
let subtitlem3u8 = self.getSubtitleManifest(for: originalURL)
65+
66+
let subtitlem3u8 = self.getSubtitleManifest(for: url, trackDescription: trackDescription)
7967

8068
if THEOplayerConnectorSideloadedSubtitle.SHOW_DEBUG_LOGS {
8169
print("[AVSubtitlesLoader] SUBTITLE: +++++++")
8270
print(subtitlem3u8)
8371
print("[AVSubtitlesLoader] SUBTITLE: ------")
8472
}
85-
86-
guard let data = subtitlem3u8.data(using: .utf8) else {
87-
return false
88-
}
89-
90-
let response = HTTPURLResponse(url: originalURL, statusCode: 200, httpVersion: nil, headerFields: nil)
91-
request.response = response
92-
request.dataRequest?.respond(with: data)
93-
request.finishLoading()
94-
95-
return true
73+
74+
return subtitlem3u8.data(using: .utf8)
9675
}
9776

98-
fileprivate func getSubtitleManifest(for originalURL: URL) -> String {
99-
let trackDescription: THEOplayerSDK.TextTrackDescription? = self.findTrackDescription(by: originalURL)
100-
let format: THEOplayerSDK.TextTrackFormat = trackDescription?.format ?? .WebVTT
77+
fileprivate func getSubtitleManifest(for originalURL: URL, trackDescription: THEOplayerSDK.TextTrackDescription) -> String {
78+
let format: THEOplayerSDK.TextTrackFormat = trackDescription.format ?? .WebVTT
10179
let timestamp: SSTextTrackDescription.WebVttTimestamp? = (trackDescription as? SSTextTrackDescription)?.vttTimestamp
10280
let autosync: Bool? = (trackDescription as? SSTextTrackDescription)?.automaticTimestampSyncEnabled
10381
let subtitlesMediaURL: String
@@ -132,6 +110,16 @@ class AVSubtitlesLoader: NSObject {
132110
}
133111
return track
134112
}
113+
114+
private func handleDestroyEvent() {
115+
Self.removeInstance(by: _id)
116+
}
117+
118+
private func handleCachingTaskStateChangeEvent(task: CachingTask?) {
119+
guard let task,
120+
task.status == .evicted else { return }
121+
Self.removeInstance(by: task.id)
122+
}
135123
}
136124

137125
enum URLScheme: String {
@@ -151,48 +139,40 @@ enum URLScheme: String {
151139
}
152140
}
153141

154-
extension AVSubtitlesLoader: ManifestInterceptor {
155-
var customScheme: String {
156-
//the initial interception scheme
157-
URLScheme.masterm3u8.urlScheme
158-
}
159-
160-
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
142+
extension AVSubtitlesLoader: MediaPlaylistInterceptor {
143+
func shouldInterceptPlaylistRequest(type: HlsPlaylistType) -> Bool { false }
144+
func didInterceptPlaylistRequest(type: HlsPlaylistType, request: URLRequest) async throws -> URLRequest { request }
145+
146+
func failedToPerformURLRequest(request: URLRequest, response: URLResponse) {
161147
if THEOplayerConnectorSideloadedSubtitle.SHOW_DEBUG_LOGS {
162-
print("[AVSubtitlesLoader] loadingRequest", loadingRequest.request.url?.absoluteString ?? "")
148+
print("[AVSubtitlesLoader] failedToPerformURLRequest", request.url?.absoluteString ?? "")
163149
}
164-
return intercept(loadingRequest: loadingRequest)
165-
166150
}
167-
168-
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForRenewalOfRequestedResource renewalRequest: AVAssetResourceRenewalRequest) -> Bool {
151+
152+
func shouldInterceptPlaylistResponse(type: HlsPlaylistType) -> Bool { true }
153+
func didInterceptPlaylistResponse(type: HlsPlaylistType, url: URL, response: URLResponse, data: Data) async throws -> Data {
169154
if THEOplayerConnectorSideloadedSubtitle.SHOW_DEBUG_LOGS {
170-
print("[AVSubtitlesLoader] renewalRequest", renewalRequest.request.url?.absoluteString ?? "")
155+
print("[AVSubtitlesLoader] intercept url", url.absoluteString, self)
171156
}
172-
return intercept(loadingRequest: renewalRequest)
157+
return await interceptResponse(type: type, url: url, data: data)
173158
}
174-
175-
private func intercept(loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
176-
guard let scheme = loadingRequest.request.url?.scheme else {
177-
return false
178-
}
179-
switch scheme {
180-
case URLScheme.masterm3u8.name:
159+
160+
private func interceptResponse(type: HlsPlaylistType, url: URL, data: Data) async -> Data {
161+
switch type {
162+
case .master :
181163
// intercept the master manifest to append the subtitles
182-
return self.handleMasterManifestRequest(loadingRequest)
183-
case URLScheme.variantm3u8.name:
164+
return await self.handleMasterManifestRequest(url) ?? data
165+
case .video:
184166
// intercept the variant manifest to get the duration
185-
return self.handleVariantManifest(loadingRequest)
186-
case URLScheme.subtitlesm3u8.name:
167+
return await self.handleVariantManifest(url) ?? data
168+
case .subtitles:
187169
// intercept the subtitle request to respond with the HLS subtitle
188-
return self.handleSubtitles(loadingRequest)
170+
return self.handleSubtitles(url) ?? data
189171
default:
190172
break
191173
}
192-
193-
return false
174+
return data
194175
}
195-
196176
}
197177

198178
extension THEOplayer {
@@ -202,17 +182,18 @@ extension THEOplayer {
202182
- Remark:
203183
- Once used this method, always use it to set a source (even if there are no sideloaded subtitles in it), otherwise the subtitle helper logic can break the playback behavior
204184
*/
205-
public func setSourceWithSubtitles(source: SourceDescription?){
206-
207-
if let source = source {
208-
if let sideLoadedTextTracks = SourceValidator.getValidTextTracks(source) {
209-
let subtitleLoader = AVSubtitlesLoader(subtitles: sideLoadedTextTracks, player: self)
210-
self.developerSettings?.manifestInterceptor = subtitleLoader
211-
} else {
212-
self.developerSettings?.manifestInterceptor = nil
213-
}
185+
public func setSourceWithSubtitles(source: SourceDescription?) {
186+
if let source = source,
187+
let sideLoadedTextTracks = SourceValidator.getValidTextTracks(source) {
188+
let loader = AVSubtitlesLoader(
189+
subtitles: sideLoadedTextTracks,
190+
id: String(self.uid),
191+
player: self
192+
)
193+
AVSubtitlesLoader.addInstance(loader)
194+
self.network.addMediaPlaylistInterceptor(loader)
214195
} else {
215-
self.developerSettings?.manifestInterceptor = nil
196+
AVSubtitlesLoader.removeInstance(by: String(self.uid))
216197
}
217198

218199
self.source = source
@@ -228,14 +209,18 @@ extension Cache {
228209
- Once used this method, always use it to cache a source (even if there are no sideloaded subtitles in it), otherwise the subtitle helper logic can break the caching behavior
229210
*/
230211
public func createTaskWithSubtitles(source: SourceDescription, parameters: CachingParameters?) -> CachingTask? {
212+
guard let cachingTask = createTask(source: source, parameters: parameters) else { return nil }
231213
if let sideLoadedTextTracks = SourceValidator.getValidTextTracks(source) {
232-
let subtitleLoader = AVSubtitlesLoader(subtitles: sideLoadedTextTracks, player: nil)
233-
self.developerSettings?.manifestInterceptor = subtitleLoader
234-
} else {
235-
self.developerSettings?.manifestInterceptor = nil
214+
let loader = AVSubtitlesLoader(
215+
subtitles: sideLoadedTextTracks,
216+
id: cachingTask.id,
217+
cachingTask: cachingTask
218+
)
219+
AVSubtitlesLoader.addInstance(loader)
220+
cachingTask.network.addMediaPlaylistInterceptor(loader)
236221
}
237222

238-
return createTask(source: source, parameters: parameters)
223+
return cachingTask
239224
}
240225
}
241226
#endif

Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/MasterPlaylistParser.swift

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,17 @@ class MasterPlaylistParser: PlaylistParser {
1717
super.init(url: url)
1818
}
1919

20-
func sideLoadSubtitles(subtitles: [TextTrackDescription], completion: @escaping (_ data: Data?) -> ()) {
21-
self.loadManifest { succ in
22-
if succ {
23-
self.parseManifest()
24-
self.appendSubtitlesLines(subtitles: subtitles)
25-
let constructed = self.constructedManifestArray.joined(separator: "\n")
26-
27-
if THEOplayerConnectorSideloadedSubtitle.SHOW_DEBUG_LOGS {
28-
print("[AVSubtitlesLoader] MASTER: +++++++")
29-
print(constructed)
30-
print("[AVSubtitlesLoader] MASTER: ------")
31-
}
32-
33-
completion(constructed.data(using: .utf8))
34-
} else {
35-
completion(nil)
36-
}
20+
func sideLoadSubtitles(subtitles: [TextTrackDescription]) async -> Data? {
21+
guard let _ = await self.loadManifest() else { return nil }
22+
self.parseManifest()
23+
self.appendSubtitlesLines(subtitles: subtitles)
24+
let constructed = self.constructedManifestArray.joined(separator: "\n")
25+
if THEOplayerConnectorSideloadedSubtitle.SHOW_DEBUG_LOGS {
26+
print("[AVSubtitlesLoader] MASTER: +++++++")
27+
print(constructed)
28+
print("[AVSubtitlesLoader] MASTER: ------")
3729
}
30+
return constructed.data(using: .utf8)
3831
}
3932

4033
fileprivate func parseManifest() {
@@ -83,8 +76,8 @@ class MasterPlaylistParser: PlaylistParser {
8376
func appendSubtitlesLines(subtitles: [TextTrackDescription]) {
8477
for subtitle in subtitles {
8578
if let label = subtitle.label, let encodedURLString = subtitle.src.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) {
86-
let subtitleCustomSchemePath = encodedURLString.byConcatenatingScheme(scheme: URLScheme.subtitlesm3u8)
87-
let subtitleLine = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"\(self.subtitlesGroupId)\",NAME=\"\(label)\",URI=\"\(subtitleCustomSchemePath)\",LANGUAGE=\"\(subtitle.srclang)\""
79+
let defaultValue = subtitle.isDefault == true ? "YES" : "NO"
80+
let subtitleLine = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"\(self.subtitlesGroupId)\",NAME=\"\(label)\",DEFAULT=\(defaultValue),URI=\"\(encodedURLString)\",LANGUAGE=\"\(subtitle.srclang)\""
8881
if let linePosition = self.lastMediaLine {
8982
self.constructedManifestArray.insert("\(subtitleLine)", at: linePosition)
9083
} else {
@@ -98,6 +91,6 @@ class MasterPlaylistParser: PlaylistParser {
9891
guard let variantURL = self.getFullURL(from: path) else {
9992
return path.trimmingCharacters(in: .whitespacesAndNewlines)
10093
}
101-
return variantURL.absoluteString.byConcatenatingScheme(scheme: URLScheme.variantm3u8)
94+
return variantURL.absoluteString
10295
}
10396
}

Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,20 @@ class PlaylistParser {
1616
self.manifestData = nil
1717
}
1818

19-
func loadManifest(completion: @escaping (_ success: Bool) -> ()) {
20-
URLSession.shared.dataTask(with: self.manifestURL) { [weak self] data, response, error in
21-
guard let responseData = data, let self = self else {
22-
completion(false)
23-
return
24-
}
19+
func loadManifest() async -> Data? {
20+
if let (data, response) = try? await URLSession.shared.data(from: self.manifestURL) {
2521
// Update the manifestUrl to the url received in the response (to pickup possible url redirect)
26-
if let responseUrl = response?.url {
22+
if let responseUrl = response.url {
2723
self.manifestURL = responseUrl
2824
}
29-
if self.isValidManifest(data: responseData) {
30-
self.manifestData = responseData
31-
completion(true)
25+
if self.isValidManifest(data: data) {
26+
self.manifestData = data
27+
return data
3228
} else {
33-
completion(false)
29+
return nil
3430
}
3531
}
36-
.resume()
32+
return nil
3733
}
3834

3935
func isValidManifest(data: Data) -> Bool {

0 commit comments

Comments
 (0)