import AVFoundation import Foundation import NitroModules class HLSSubtitleInjector: NSObject { private let originalManifestUrl: URL private let externalSubtitles: [NativeExternalSubtitle] private var modifiedManifestContent: String? private static let customScheme = "rnv-hls" private static let subtitleScheme = "rnv-hls-subtitles" private static let subtitleGroupID = "rnv-subs" private static let resourceLoaderQueue = DispatchQueue( label: "com.nitro.HLSSubtitleInjector.resourceLoaderQueue", qos: .userInitiated ) init(manifestUrl: URL, externalSubtitles: [NativeExternalSubtitle]) { self.originalManifestUrl = manifestUrl self.externalSubtitles = externalSubtitles super.init() } func createModifiedAsset() -> AVURLAsset { let customURL = createCustomURL(from: originalManifestUrl) let asset = AVURLAsset(url: customURL) asset.resourceLoader.setDelegate( self, queue: Self.resourceLoaderQueue ) return asset } private func createCustomURL(from originalURL: URL) -> URL { var components = URLComponents( url: originalURL, resolvingAgainstBaseURL: false )! components.scheme = Self.customScheme return components.url! } private func getModifiedManifestContent() async throws -> String { if let cached = modifiedManifestContent { return cached } let originalContent = try await HLSManifestParser.downloadManifest(from: originalManifestUrl) let modifiedContent = try modifyM3U8Content( originalContent, with: externalSubtitles ) modifiedManifestContent = modifiedContent return modifiedContent } private func modifyM3U8Content( _ originalContent: String, with externalSubtitles: [NativeExternalSubtitle] ) throws -> String { let lines = originalContent.components(separatedBy: .newlines) var modifiedLines: [String] = [] var foundExtM3U = false var isAfterVersionOrM3U = false var hasSubtitleGroup = false let baseURL = originalManifestUrl.deletingLastPathComponent() for line in lines { let trimmedLine = line.trimmingCharacters(in: .whitespaces) if trimmedLine.hasPrefix("#EXTM3U") { foundExtM3U = true modifiedLines.append(line) isAfterVersionOrM3U = true continue } if isAfterVersionOrM3U && !hasSubtitleGroup && shouldInsertSubtitlesHere(line: trimmedLine) { for (index, subtitle) in externalSubtitles.enumerated() { let subtitleTrack = createSubtitleTrackEntry(for: subtitle, index: index) modifiedLines.append(subtitleTrack) } hasSubtitleGroup = true isAfterVersionOrM3U = false } let processedLine = HLSManifestParser.convertRelativeURLsToAbsolute( line: line, baseURL: baseURL ) // Handle existing subtitle groups and stream info lines if trimmedLine.hasPrefix("#EXT-X-MEDIA:") && trimmedLine.contains("TYPE=SUBTITLES") { let modifiedMediaLine = replaceSubtitleGroupInMediaLine(processedLine) modifiedLines.append(modifiedMediaLine) } else if trimmedLine.hasPrefix("#EXT-X-STREAM-INF:") { let modifiedStreamLine = replaceSubtitleGroupInStreamInf( processedLine, hasSubtitleGroup: hasSubtitleGroup) modifiedLines.append(modifiedStreamLine) } else { modifiedLines.append(processedLine) } } if foundExtM3U && !hasSubtitleGroup { var finalLines: [String] = [] var insertedSubtitles = false for line in modifiedLines { finalLines.append(line) if !insertedSubtitles && (line.hasPrefix("#EXTM3U") || line.hasPrefix("#EXT-X-VERSION")) { for (index, subtitle) in externalSubtitles.enumerated() { let subtitleTrack = createSubtitleTrackEntry(for: subtitle, index: index) finalLines.append(subtitleTrack) } insertedSubtitles = true } } modifiedLines = finalLines } if !foundExtM3U { throw SourceError.invalidUri(uri: originalManifestUrl.absoluteString) .error() } // Post-process: ensure every variant stream references our subtitle group if we injected it if hasSubtitleGroup { modifiedLines = modifiedLines.map { line in if line.hasPrefix("#EXT-X-STREAM-INF:") && !line.contains("SUBTITLES=") { if line.hasSuffix(",") { return line + "SUBTITLES=\"\(Self.subtitleGroupID)\"" } else { return line + ",SUBTITLES=\"\(Self.subtitleGroupID)\"" } } return line } } return modifiedLines.joined(separator: "\n") } private func shouldInsertSubtitlesHere(line: String) -> Bool { return line.hasPrefix("#EXT-X-STREAM-INF:") || line.hasPrefix("#EXT-X-I-FRAME-STREAM-INF:") || line.hasPrefix("#EXT-X-MEDIA:") || line.hasPrefix("#EXTINF:") || line.hasPrefix("#EXT-X-BYTERANGE:") || (!line.hasPrefix("#") && !line.isEmpty && !line.hasPrefix("#EXT-X-VERSION")) } private func replaceSubtitleGroupInMediaLine(_ line: String) -> String { // Find and replace GROUP-ID in subtitle media lines let groupIdPattern = #"GROUP-ID="[^"]*""# if let regex = try? NSRegularExpression(pattern: groupIdPattern, options: []) { let range = NSRange(location: 0, length: line.utf16.count) let replacement = "GROUP-ID=\"\(Self.subtitleGroupID)\"" return regex.stringByReplacingMatches( in: line, options: [], range: range, withTemplate: replacement) } return line } private func replaceSubtitleGroupInStreamInf(_ line: String, hasSubtitleGroup: Bool) -> String { // First, handle existing SUBTITLES= references let subtitlesPattern = #"SUBTITLES="[^"]*""# var modifiedLine = line if let regex = try? NSRegularExpression(pattern: subtitlesPattern, options: []) { let range = NSRange(location: 0, length: line.utf16.count) let replacement = "SUBTITLES=\"\(Self.subtitleGroupID)\"" modifiedLine = regex.stringByReplacingMatches( in: line, options: [], range: range, withTemplate: replacement) } else if hasSubtitleGroup && !line.contains("SUBTITLES=") { // Add subtitle group reference if we have subtitles but no existing reference if line.hasSuffix(",") { modifiedLine = line + "SUBTITLES=\"\(Self.subtitleGroupID)\"" } else { modifiedLine = line + ",SUBTITLES=\"\(Self.subtitleGroupID)\"" } } return modifiedLine } private func createSubtitleTrackEntry(for subtitle: NativeExternalSubtitle, index: Int) -> String { let subtitleM3U8URI = "\(Self.subtitleScheme)://\(index)/subtitle.m3u8" return "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"\(Self.subtitleGroupID)\",NAME=\"\(subtitle.label)\",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE=\"\(subtitle.language)\",URI=\"\(subtitleM3U8URI)\"" } } // MARK: - AVAssetResourceLoaderDelegate extension HLSSubtitleInjector: AVAssetResourceLoaderDelegate { func resourceLoader( _ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest ) -> Bool { guard let url = loadingRequest.request.url else { return false } switch url.scheme { case Self.customScheme: return handleMainManifest(url: url, loadingRequest: loadingRequest) case Self.subtitleScheme: return handleSubtitleM3U8(url: url, loadingRequest: loadingRequest) default: return false } } private func handleMainManifest(url: URL, loadingRequest: AVAssetResourceLoadingRequest) -> Bool { guard url.path.hasSuffix(".m3u8") else { return false } Task { do { let modifiedContent = try await getModifiedManifestContent() guard let data = modifiedContent.data(using: .utf8) else { throw SourceError.invalidUri(uri: "Failed to encode manifest content") .error() } if let contentRequest = loadingRequest.contentInformationRequest { contentRequest.contentType = "application/x-mpegURL" contentRequest.contentLength = Int64(data.count) contentRequest.isByteRangeAccessSupported = true } if let dataRequest = loadingRequest.dataRequest { let requestedData: Data if dataRequest.requestedOffset > 0 || dataRequest.requestedLength > 0 { let offset = Int(dataRequest.requestedOffset) let length = dataRequest.requestedLength > 0 ? min(Int(dataRequest.requestedLength), data.count - offset) : data.count - offset if offset < data.count && length > 0 { requestedData = data.subdata(in: offset..<(offset + length)) } else { requestedData = Data() } } else { requestedData = data } dataRequest.respond(with: requestedData) } loadingRequest.finishLoading() } catch { loadingRequest.finishLoading(with: error) } } return true } private func handleSubtitleM3U8(url: URL, loadingRequest: AVAssetResourceLoadingRequest) -> Bool { guard let indexString = url.host, let index = Int(indexString) else { return false } guard index < externalSubtitles.count else { return false } let subtitle = externalSubtitles[index] Task { do { guard let subtitleURL = URL(string: subtitle.uri) else { throw SourceError.invalidUri(uri: "Invalid subtitle URI: \(subtitle.uri)").error() } let (vttData, response) = try await URLSession.shared.data(from: subtitleURL) guard let httpResponse = response as? HTTPURLResponse, 200...299 ~= httpResponse.statusCode else { throw SourceError.invalidUri(uri: "Subtitle request failed with status: \(response)") .error() } guard let vttString = String(data: vttData, encoding: .utf8) else { throw SourceError.invalidUri(uri: "Failed to decode VTT content").error() } let duration = extractDurationFromVTT(vttString) let m3u8Wrapper = """ #EXTM3U #EXT-X-VERSION:3 #EXT-X-MEDIA-SEQUENCE:1 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-ALLOW-CACHE:NO #EXT-X-TARGETDURATION:\(Int(duration)) #EXTINF:\(String(format: "%.3f", duration)), no desc \(subtitle.uri) #EXT-X-ENDLIST """ guard let m3u8Data = m3u8Wrapper.data(using: .utf8) else { throw SourceError.invalidUri(uri: "Failed to create M3U8 wrapper").error() } if let contentRequest = loadingRequest.contentInformationRequest { contentRequest.contentType = "application/x-mpegURL" contentRequest.contentLength = Int64(m3u8Data.count) contentRequest.isByteRangeAccessSupported = true } if let dataRequest = loadingRequest.dataRequest { dataRequest.respond(with: m3u8Data) } loadingRequest.finishLoading() } catch { loadingRequest.finishLoading(with: error) } } return true } private func extractDurationFromVTT(_ vttString: String) -> Double { // Extract duration from VTT timestamps (similar to the PR approach) let timestampPattern = #"(?:(\d+):)?(\d+):([\d\.]+)"# guard let regex = try? NSRegularExpression(pattern: timestampPattern, options: []) else { return 60.0 // Default fallback } let matches = regex.matches( in: vttString, options: [], range: NSRange(location: 0, length: vttString.utf16.count) ) guard let lastMatch = matches.last, let range = Range(lastMatch.range, in: vttString) else { return 60.0 // Default fallback } let lastTimestampString = String(vttString[range]) let components = lastTimestampString.components(separatedBy: ":").reversed() .compactMap { Double($0) } .enumerated() .map { pow(60.0, Double($0.offset)) * $0.element } .reduce(0, +) return max(components, 1.0) // Ensure at least 1 second } func resourceLoader( _ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest ) { } }