Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

- SideloadedSubtitle
- Added support for simultaneous multi-source caching with sideloaded subtitles. Previously there was a limitation of caching only a single task at a time.
- 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.

## [10.7.0] - 2025-12-18

### Changed
Expand Down
5 changes: 2 additions & 3 deletions Code/Sideloaded-TextTracks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ All that is needed is a source with a text track description:
```swift
public static var sourceWithSideloadedTextTrack: SourceDescription {
let typedSource = TypedSource(src: "https://sourceURL.com/manifest.m3u8, type: "application/x-mpegurl")
let textTrack = TextTrackDescription(src: "https://sideloadedurl.com/subtitle.vtt", srclang: "language_code", isDefault: false, kind: .subtitles, label: "Label", format: .WebVTT)
let textTrack = TextTrackDescription(src: "https://sideloadedurl.com/subtitle.vtt", srclang: "language_code", isDefault: true, kind: .subtitles, label: "Label", format: .WebVTT)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did we check the AVplayer behavior in case of 2 DEFAULT=YES subtitles? (most probably one of the already existing subtitles in the manifest could have this set to YES already)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the first is entry is taken into account. I will add that as a limitation.

return SourceDescription(source: typedSource, textTracks: [textTrack])
}
```
Expand All @@ -188,5 +188,4 @@ For more information on how to implement offline playback with caching, please r

### Limitations

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.
2. Caching is only available on iOS.
1. Caching is only available on iOS.
Original file line number Diff line number Diff line change
Expand Up @@ -10,94 +10,72 @@ import AVFoundation
import THEOplayerSDK

class AVSubtitlesLoader: NSObject {
private static var instances: [AVSubtitlesLoader] = []
static func addInstance(_ loader: AVSubtitlesLoader) { Self.instances.append(loader) }
static func removeInstance(by id: String) {
Self.instances.removeAll { $0._id == id }
}

private let subtitles: [TextTrackDescription]
private(set) var variantTotalDuration: Double = 0
private let transformer = SubtitlesTransformer()
private let synchronizer: SubtitlesSynchronizer?
private let _id: String
private var variantTotalDuration: Double = 0

init(subtitles: [TextTrackDescription], player: THEOplayer?) {
init(subtitles: [TextTrackDescription], id: String, player: THEOplayer? = nil, cachingTask: CachingTask? = nil) {
self.subtitles = subtitles
self._id = id
self.synchronizer = SubtitlesSynchronizer(player: player)
self.synchronizer?.delegate = self.transformer

super.init()

_ = player?.addEventListener(type: PlayerEventTypes.DESTROY, listener: { [weak self] destroyEvent in self?.handleDestroyEvent() })
_ = cachingTask?.addEventListener(type: CachingTaskEventTypes.STATE_CHANGE, listener: { [weak self] cachingTaskStateChangeEvent in self?.handleCachingTaskStateChangeEvent(task: cachingTask) })
}

func handleMasterManifestRequest(_ request: AVAssetResourceLoadingRequest) -> Bool {
guard let originalURL = request.request.url?.withScheme(newScheme: URLScheme.https) else {
return false
}

MasterPlaylistParser(url: originalURL).sideLoadSubtitles(subtitles: subtitles) { data in
guard let masterManifestData = data else {
print("[AVSubtitlesLoader] ERROR: Couldn't find manifest data")
request.finishLoading(with: URLError(URLError.cannotParseResponse))
return
}
let response = HTTPURLResponse(url: originalURL, statusCode: 200, httpVersion: nil, headerFields: nil)
request.response = response
request.dataRequest?.respond(with: masterManifestData)
request.finishLoading()

func handleMasterManifestRequest(_ url: URL) async -> Data? {
let parser = MasterPlaylistParser(url: url)

guard let responseData = await parser.sideLoadSubtitles(subtitles: subtitles) else {
print("[AVSubtitlesLoader] ERROR: Couldn't find manifest data")
return nil
}
return true

return responseData
}

func handleVariantManifest(_ request: AVAssetResourceLoadingRequest) -> Bool {
guard let customSchemeURL = request.request.url,
let originalURLString = customSchemeURL.absoluteString.byRemovingScheme(scheme: URLScheme.variantm3u8),
let originalURL = URL(string:originalURLString) else {
print("[AVSubtitlesLoader] ERROR: Variant manifest is invalid")
request.finishLoading(with: URLError(URLError.unsupportedURL))
return false
}

VariantPlaylistParser(url: originalURL).parse { playlist in
guard let playlist = playlist, let responseData = playlist.manifestData else {
print("[AVSubtitlesLoader] ERROR: Couldn't find variant data")
request.finishLoading(with: URLError(URLError.cannotParseResponse))
return
}
self.variantTotalDuration = playlist.totalPlayListDuration
let response = HTTPURLResponse(url: originalURL, statusCode: 200, httpVersion: nil, headerFields: nil)
request.response = response
request.dataRequest?.respond(with: responseData)
request.finishLoading()
func handleVariantManifest(_ url: URL) async -> Data? {
let parser = VariantPlaylistParser(url: url)

guard let playlist = await parser.parse(),
let responseData = playlist.manifestData else {
print("[AVSubtitlesLoader] ERROR: Couldn't find variant data")
return nil
}
return true

self.variantTotalDuration = playlist.totalPlayListDuration
return responseData
}

func handleSubtitles(_ request: AVAssetResourceLoadingRequest) -> Bool {
guard let customSchemeURL = request.request.url else {
return false
}

guard let originalURLString = customSchemeURL.absoluteString.byRemovingScheme(scheme: URLScheme.subtitlesm3u8),
let originalURL = URL(string: originalURLString) else {
print("[AVSubtitlesLoader] ERROR: Failed to revert subtitle URL!")
return false

func handleSubtitles(_ url: URL) -> Data? {
guard let trackDescription: THEOplayerSDK.TextTrackDescription = self.findTrackDescription(by: url) else {
return nil
}
let subtitlem3u8 = self.getSubtitleManifest(for: originalURL)

let subtitlem3u8 = self.getSubtitleManifest(for: url, trackDescription: trackDescription)

if THEOplayerConnectorSideloadedSubtitle.SHOW_DEBUG_LOGS {
print("[AVSubtitlesLoader] SUBTITLE: +++++++")
print(subtitlem3u8)
print("[AVSubtitlesLoader] SUBTITLE: ------")
}

guard let data = subtitlem3u8.data(using: .utf8) else {
return false
}

let response = HTTPURLResponse(url: originalURL, statusCode: 200, httpVersion: nil, headerFields: nil)
request.response = response
request.dataRequest?.respond(with: data)
request.finishLoading()

return true

return subtitlem3u8.data(using: .utf8)
}

fileprivate func getSubtitleManifest(for originalURL: URL) -> String {
let trackDescription: THEOplayerSDK.TextTrackDescription? = self.findTrackDescription(by: originalURL)
let format: THEOplayerSDK.TextTrackFormat = trackDescription?.format ?? .WebVTT
fileprivate func getSubtitleManifest(for originalURL: URL, trackDescription: THEOplayerSDK.TextTrackDescription) -> String {
let format: THEOplayerSDK.TextTrackFormat = trackDescription.format ?? .WebVTT
let timestamp: SSTextTrackDescription.WebVttTimestamp? = (trackDescription as? SSTextTrackDescription)?.vttTimestamp
let autosync: Bool? = (trackDescription as? SSTextTrackDescription)?.automaticTimestampSyncEnabled
let subtitlesMediaURL: String
Expand Down Expand Up @@ -132,6 +110,16 @@ class AVSubtitlesLoader: NSObject {
}
return track
}

private func handleDestroyEvent() {
Self.removeInstance(by: _id)
}

private func handleCachingTaskStateChangeEvent(task: CachingTask?) {
guard let task,
task.status == .evicted else { return }
Self.removeInstance(by: task.id)
}
}

enum URLScheme: String {
Expand All @@ -151,48 +139,40 @@ enum URLScheme: String {
}
}

extension AVSubtitlesLoader: ManifestInterceptor {
var customScheme: String {
//the initial interception scheme
URLScheme.masterm3u8.urlScheme
}

func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
extension AVSubtitlesLoader: MediaPlaylistInterceptor {
func shouldInterceptPlaylistRequest(type: HlsPlaylistType) -> Bool { false }
func didInterceptPlaylistRequest(type: HlsPlaylistType, request: URLRequest) async throws -> URLRequest { request }

func failedToPerformURLRequest(request: URLRequest, response: URLResponse) {
if THEOplayerConnectorSideloadedSubtitle.SHOW_DEBUG_LOGS {
print("[AVSubtitlesLoader] loadingRequest", loadingRequest.request.url?.absoluteString ?? "")
print("[AVSubtitlesLoader] failedToPerformURLRequest", request.url?.absoluteString ?? "")
}
return intercept(loadingRequest: loadingRequest)

}

func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForRenewalOfRequestedResource renewalRequest: AVAssetResourceRenewalRequest) -> Bool {

func shouldInterceptPlaylistResponse(type: HlsPlaylistType) -> Bool { true }
func didInterceptPlaylistResponse(type: HlsPlaylistType, url: URL, response: URLResponse, data: Data) async throws -> Data {
if THEOplayerConnectorSideloadedSubtitle.SHOW_DEBUG_LOGS {
print("[AVSubtitlesLoader] renewalRequest", renewalRequest.request.url?.absoluteString ?? "")
print("[AVSubtitlesLoader] intercept url", url.absoluteString, self)
}
return intercept(loadingRequest: renewalRequest)
return await interceptResponse(type: type, url: url, data: data)
}

private func intercept(loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
guard let scheme = loadingRequest.request.url?.scheme else {
return false
}
switch scheme {
case URLScheme.masterm3u8.name:

private func interceptResponse(type: HlsPlaylistType, url: URL, data: Data) async -> Data {
switch type {
case .master :
// intercept the master manifest to append the subtitles
return self.handleMasterManifestRequest(loadingRequest)
case URLScheme.variantm3u8.name:
return await self.handleMasterManifestRequest(url) ?? data
case .video:
// intercept the variant manifest to get the duration
return self.handleVariantManifest(loadingRequest)
case URLScheme.subtitlesm3u8.name:
return await self.handleVariantManifest(url) ?? data
case .subtitles:
// intercept the subtitle request to respond with the HLS subtitle
return self.handleSubtitles(loadingRequest)
return self.handleSubtitles(url) ?? data
default:
break
}

return false
return data
}

}

extension THEOplayer {
Expand All @@ -202,17 +182,18 @@ extension THEOplayer {
- Remark:
- 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
*/
public func setSourceWithSubtitles(source: SourceDescription?){

if let source = source {
if let sideLoadedTextTracks = SourceValidator.getValidTextTracks(source) {
let subtitleLoader = AVSubtitlesLoader(subtitles: sideLoadedTextTracks, player: self)
self.developerSettings?.manifestInterceptor = subtitleLoader
} else {
self.developerSettings?.manifestInterceptor = nil
}
public func setSourceWithSubtitles(source: SourceDescription?) {
if let source = source,
let sideLoadedTextTracks = SourceValidator.getValidTextTracks(source) {
let loader = AVSubtitlesLoader(
subtitles: sideLoadedTextTracks,
id: String(self.uid),
player: self
)
AVSubtitlesLoader.addInstance(loader)
self.network.addMediaPlaylistInterceptor(loader)
} else {
self.developerSettings?.manifestInterceptor = nil
AVSubtitlesLoader.removeInstance(by: String(self.uid))
}

self.source = source
Expand All @@ -228,14 +209,18 @@ extension Cache {
- 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
*/
public func createTaskWithSubtitles(source: SourceDescription, parameters: CachingParameters?) -> CachingTask? {
guard let cachingTask = createTask(source: source, parameters: parameters) else { return nil }
if let sideLoadedTextTracks = SourceValidator.getValidTextTracks(source) {
let subtitleLoader = AVSubtitlesLoader(subtitles: sideLoadedTextTracks, player: nil)
self.developerSettings?.manifestInterceptor = subtitleLoader
} else {
self.developerSettings?.manifestInterceptor = nil
let loader = AVSubtitlesLoader(
subtitles: sideLoadedTextTracks,
id: cachingTask.id,
cachingTask: cachingTask
)
AVSubtitlesLoader.addInstance(loader)
cachingTask.network.addMediaPlaylistInterceptor(loader)
}

return createTask(source: source, parameters: parameters)
return cachingTask
}
}
#endif
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,17 @@ class MasterPlaylistParser: PlaylistParser {
super.init(url: url)
}

func sideLoadSubtitles(subtitles: [TextTrackDescription], completion: @escaping (_ data: Data?) -> ()) {
self.loadManifest { succ in
if succ {
self.parseManifest()
self.appendSubtitlesLines(subtitles: subtitles)
let constructed = self.constructedManifestArray.joined(separator: "\n")

if THEOplayerConnectorSideloadedSubtitle.SHOW_DEBUG_LOGS {
print("[AVSubtitlesLoader] MASTER: +++++++")
print(constructed)
print("[AVSubtitlesLoader] MASTER: ------")
}

completion(constructed.data(using: .utf8))
} else {
completion(nil)
}
func sideLoadSubtitles(subtitles: [TextTrackDescription]) async -> Data? {
guard let _ = await self.loadManifest() else { return nil }
self.parseManifest()
self.appendSubtitlesLines(subtitles: subtitles)
let constructed = self.constructedManifestArray.joined(separator: "\n")
if THEOplayerConnectorSideloadedSubtitle.SHOW_DEBUG_LOGS {
print("[AVSubtitlesLoader] MASTER: +++++++")
print(constructed)
print("[AVSubtitlesLoader] MASTER: ------")
}
return constructed.data(using: .utf8)
}

fileprivate func parseManifest() {
Expand Down Expand Up @@ -83,8 +76,8 @@ class MasterPlaylistParser: PlaylistParser {
func appendSubtitlesLines(subtitles: [TextTrackDescription]) {
for subtitle in subtitles {
if let label = subtitle.label, let encodedURLString = subtitle.src.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) {
let subtitleCustomSchemePath = encodedURLString.byConcatenatingScheme(scheme: URLScheme.subtitlesm3u8)
let subtitleLine = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"\(self.subtitlesGroupId)\",NAME=\"\(label)\",URI=\"\(subtitleCustomSchemePath)\",LANGUAGE=\"\(subtitle.srclang)\""
let defaultValue = subtitle.isDefault == true ? "YES" : "NO"
let subtitleLine = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"\(self.subtitlesGroupId)\",NAME=\"\(label)\",DEFAULT=\(defaultValue),URI=\"\(encodedURLString)\",LANGUAGE=\"\(subtitle.srclang)\""
if let linePosition = self.lastMediaLine {
self.constructedManifestArray.insert("\(subtitleLine)", at: linePosition)
} else {
Expand All @@ -98,6 +91,6 @@ class MasterPlaylistParser: PlaylistParser {
guard let variantURL = self.getFullURL(from: path) else {
return path.trimmingCharacters(in: .whitespacesAndNewlines)
}
return variantURL.absoluteString.byConcatenatingScheme(scheme: URLScheme.variantm3u8)
return variantURL.absoluteString
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,20 @@ class PlaylistParser {
self.manifestData = nil
}

func loadManifest(completion: @escaping (_ success: Bool) -> ()) {
URLSession.shared.dataTask(with: self.manifestURL) { [weak self] data, response, error in
guard let responseData = data, let self = self else {
completion(false)
return
}
func loadManifest() async -> Data? {
if let (data, response) = try? await URLSession.shared.data(from: self.manifestURL) {
// Update the manifestUrl to the url received in the response (to pickup possible url redirect)
if let responseUrl = response?.url {
if let responseUrl = response.url {
self.manifestURL = responseUrl
}
if self.isValidManifest(data: responseData) {
self.manifestData = responseData
completion(true)
if self.isValidManifest(data: data) {
self.manifestData = data
return data
} else {
completion(false)
return nil
}
}
.resume()
return nil
}

func isValidManifest(data: Data) -> Bool {
Expand Down
Loading
Loading