diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e9083e9..f5d00e38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Code/Sideloaded-TextTracks/README.md b/Code/Sideloaded-TextTracks/README.md index 8b546f95..278a504f 100644 --- a/Code/Sideloaded-TextTracks/README.md +++ b/Code/Sideloaded-TextTracks/README.md @@ -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) return SourceDescription(source: typedSource, textTracks: [textTrack]) } ``` @@ -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. diff --git a/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/AVSubtitlesLoader.swift b/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/AVSubtitlesLoader.swift index 1479d301..ecc75f7a 100644 --- a/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/AVSubtitlesLoader.swift +++ b/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/AVSubtitlesLoader.swift @@ -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 @@ -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 { @@ -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 { @@ -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 @@ -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 diff --git a/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/MasterPlaylistParser.swift b/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/MasterPlaylistParser.swift index 218aa558..292fcdb5 100644 --- a/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/MasterPlaylistParser.swift +++ b/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/MasterPlaylistParser.swift @@ -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() { @@ -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 { @@ -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 } } diff --git a/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift b/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift index 8867623e..d50dbf81 100644 --- a/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift +++ b/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift @@ -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 { diff --git a/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/VariantPlaylistParser.swift b/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/VariantPlaylistParser.swift index e7f27322..72adf59d 100644 --- a/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/VariantPlaylistParser.swift +++ b/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/VariantPlaylistParser.swift @@ -16,26 +16,19 @@ class VariantPlaylistParser: PlaylistParser { super.init(url: url) } - func parse(completion: @escaping (_ playlist: VariantPlaylistParser?) -> ()) { - self.loadManifest { succ in - if succ { - self.parseManifest() - let constructed = self.constructedManifestArray.joined(separator: "\n") - - if THEOplayerConnectorSideloadedSubtitle.SHOW_DEBUG_LOGS { - print("[AVSubtitlesLoader] VARIANT: +++++++") - print(constructed) - print("[AVSubtitlesLoader] VARIANT: ------") - } - - if let data = constructed.data(using: .utf8) { - self.manifestData = data - } - completion(self) - } else { - completion(nil) - } + func parse() async -> VariantPlaylistParser? { + guard let _ = await self.loadManifest() else { return nil } + self.parseManifest() + let constructed = self.constructedManifestArray.joined(separator: "\n") + if THEOplayerConnectorSideloadedSubtitle.SHOW_DEBUG_LOGS { + print("[AVSubtitlesLoader] VARIANT: +++++++") + print(constructed) + print("[AVSubtitlesLoader] VARIANT: ------") + } + if let data = constructed.data(using: .utf8) { + self.manifestData = data } + return self } fileprivate func parseManifest() { diff --git a/THEOplayer-Connector-SideloadedSubtitle.podspec b/THEOplayer-Connector-SideloadedSubtitle.podspec index 4bdd9a1c..06daa043 100644 --- a/THEOplayer-Connector-SideloadedSubtitle.podspec +++ b/THEOplayer-Connector-SideloadedSubtitle.podspec @@ -19,7 +19,7 @@ Pod::Spec.new do |s| s.static_framework = true s.swift_versions = ['5.3', '5.4', '5.5', '5.6', '5.7'] - s.dependency 'THEOplayerSDK-core', "~> 10" + s.dependency 'THEOplayerSDK-core', "~> 10.7" s.dependency 'SwiftSubtitles', '0.9.1' s.dependency 'Swifter', '1.5.0' end