import Foundation /// Immutable, reusable subtitles; times are acknowledged player positions, not wall-clock time. struct WebVTTDocument: Sendable { struct Cue: Sendable, Equatable { let start: Double let end: Double let text: String } let cues: [Cue] private let maximumEnds: [Double] static let byteLimit = 4 * 1024 * 1024 private enum SubtitleError: LocalizedError { case request, transport, content var errorDescription: String? { switch self { case .request: "Could not load subtitles. The subtitle URL or headers are invalid." case .transport: "Could not load subtitles. Check the connection or choose another subtitle track." case .content: "The subtitle resource is not valid WebVTT." } } } static func load(url: String, headers: String) async throws -> WebVTTDocument { let data = try await loadData(url: url, headers: headers) // Providers sometimes disguise VTT resources as images: trust the explicit format and content. guard let text = String(data: data, encoding: .utf8) else { throw SubtitleError.content } return try parse(text) } static func loadData(url: String, headers: String) async throws -> Data { let request = try request(url: url, headers: headers) let configuration = URLSessionConfiguration.ephemeral configuration.timeoutIntervalForRequest = 15 configuration.timeoutIntervalForResource = 20 configuration.httpCookieStorage = nil configuration.urlCredentialStorage = nil configuration.urlCache = nil let session = URLSession(configuration: configuration, delegate: SubtitleRedirects(request: request), delegateQueue: nil) defer { session.invalidateAndCancel() } let data: Data do { let (bytes, response) = try await session.bytes(for: request) guard let response = response as? HTTPURLResponse, (200...299).contains(response.statusCode), response.expectedContentLength <= Int64(byteLimit) else { throw SubtitleError.transport } var buffer = Data() if response.expectedContentLength > 0 { buffer.reserveCapacity(Int(response.expectedContentLength)) } for try await byte in bytes { guard buffer.count < byteLimit else { throw SubtitleError.transport } buffer.append(byte) } data = buffer } catch { if Task.isCancelled { throw CancellationError() } throw SubtitleError.transport } return data } static func parse(_ text: String) throws -> WebVTTDocument { guard text.utf8.count <= byteLimit, !text.contains("\0") else { throw SubtitleError.content } var normalized = text if normalized.first == "\u{FEFF}" { normalized.removeFirst() } normalized = normalized.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n") let lines = normalized.components(separatedBy: "\n") guard let header = lines.first, header == "WEBVTT" || header.hasPrefix("WEBVTT ") || header.hasPrefix("WEBVTT\t"), !header.contains("-->") else { throw SubtitleError.content } var index = 1 // Header metadata ends at the first blank line. A cue without this separator is malformed. while index < lines.count && !lines[index].isEmpty { guard !lines[index].contains("-->") else { throw SubtitleError.content } index += 1 } var cues: [Cue] = [] var maximumEnds: [Double] = [] while index < lines.count { while index < lines.count && lines[index].isEmpty { index += 1 } let beginning = index while index < lines.count && !lines[index].isEmpty { index += 1 } guard beginning < index else { break } let block = lines[beginning..") }) else { throw SubtitleError.content } continue // ponytail: plain-text cues; add layout/styling when positional subtitles required. } var timingIndex = beginning if !first.contains("-->") { timingIndex += 1 } // Optional cue identifier. guard timingIndex < index else { throw SubtitleError.content } let timing = lines[timingIndex].split(whereSeparator: { $0 == " " || $0 == "\t" }) guard timing.count >= 3, timing[1] == "-->", let start = timestamp(String(timing[0])), let end = timestamp(String(timing[2])), end >= start, start >= (cues.last?.start ?? 0) else { throw SubtitleError.content } // Equal timestamps produce an inactive cue, not a failure of the entire track. try validateSettings(timing.dropFirst(3)) let payload = lines[(timingIndex + 1)..") else { throw SubtitleError.content } let cue = Cue(start: start, end: end, text: try plainText(payload)) cues.append(cue) maximumEnds.append(max(end, maximumEnds.last ?? 0)) } return WebVTTDocument(cues: cues, maximumEnds: maximumEnds) } func text(at acknowledgedSeconds: Double) -> String { guard acknowledgedSeconds.isFinite, acknowledgedSeconds >= 0 else { return "" } // Binary-search starts, then inspect only the potentially overlapping interval. No cursor: // backward seeks and switching to an already-loaded track need no reset. var low = 0 var high = cues.count while low < high { let middle = low + (high - low) / 2 if cues[middle].start <= acknowledgedSeconds { low = middle + 1 } else { high = middle } } var active: [String] = [] var index = low while index > 0 { index -= 1 if maximumEnds[index] <= acknowledgedSeconds { break } if cues[index].end > acknowledgedSeconds && !cues[index].text.isEmpty { active.append(cues[index].text) } } return active.reversed().joined(separator: "\n") } // Shared with the focused check: these are the exact request and redirect policies used above. static func request(url text: String, headers: String) throws -> URLRequest { guard text.utf8.count <= 32768, !text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) }), let components = URLComponents(string: text), ["http", "https"].contains(components.scheme?.lowercased() ?? ""), let host = components.host, !host.isEmpty, components.user == nil, components.password == nil, components.fragment == nil, components.port == nil || (1...65535).contains(components.port!), let url = components.url, headers.utf8.count <= 65536, headers.isEmpty || headers.hasSuffix("\r\n") else { throw SubtitleError.request } var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15) request.httpShouldHandleCookies = false var names = Set() let lines = headers.isEmpty ? [] : headers.components(separatedBy: "\r\n").dropLast().map { $0 } guard lines.count <= 64 else { throw SubtitleError.request } for line in lines { guard let colon = line.firstIndex(of: ":") else { throw SubtitleError.request } let name = String(line[.. Bool { guard let a = URLComponents(url: source, resolvingAgainstBaseURL: false), let b = URLComponents(url: destination, resolvingAgainstBaseURL: false), let scheme = a.scheme?.lowercased(), ["http", "https"].contains(scheme), b.scheme?.lowercased() == scheme, a.host?.lowercased() == b.host?.lowercased(), (a.port ?? (scheme == "https" ? 443 : 80)) == (b.port ?? (scheme == "https" ? 443 : 80)), b.user == nil, b.password == nil, b.fragment == nil else { return false } // Reject rather than guess which resource-specific headers contain credentials. return true } private static func timestamp(_ text: String) -> Double? { let fields = text.split(separator: ":", omittingEmptySubsequences: false) guard fields.count == 2 || fields.count == 3 else { return nil } let seconds = fields.last!.split(separator: ".", omittingEmptySubsequences: false) guard seconds.count == 2, seconds[0].utf8.count == 2, seconds[1].utf8.count == 3, fields.dropLast().allSatisfy({ $0.utf8.count >= 2 }), (fields.dropLast().map(String.init) + seconds.map(String.init)).allSatisfy({ $0.utf8.allSatisfy { (48...57).contains($0) } }), let second = Double(seconds[0]), second < 60, let milliseconds = Double(seconds[1]), let minute = Double(fields[fields.count - 2]), minute < 60, fields[fields.count - 2].utf8.count == 2 else { return nil } let hours = fields.count == 3 ? Double(fields[0]) : 0 guard let hours else { return nil } let result = hours * 3600 + minute * 60 + second + milliseconds / 1000 return result.isFinite ? result : nil } private static func validateSettings(_ settings: ArraySlice) throws { func percent(_ text: Substring) -> Bool { guard text.last == "%" else { return false } let number = text.dropLast() guard !number.isEmpty, number.utf8.allSatisfy({ (48...57).contains($0) || $0 == 46 }), let value = Double(number) else { return false } return (0...100).contains(value) } var names = Set() for setting in settings { let pair = setting.split(separator: ":", omittingEmptySubsequences: false) guard pair.count == 2, !pair[1].isEmpty, names.insert(pair[0]).inserted else { throw SubtitleError.content } let values = pair[1].split(separator: ",", omittingEmptySubsequences: false) let valid: Bool switch pair[0] { case "vertical": valid = values.count == 1 && ["rl", "lr"].contains(pair[1]) case "align": valid = values.count == 1 && ["start", "center", "end", "left", "right"].contains(pair[1]) case "size": valid = values.count == 1 && percent(pair[1]) case "region": valid = values.count == 1 case "position": valid = (values.count == 1 || values.count == 2) && percent(values[0]) && (values.count == 1 || ["line-left", "center", "line-right", "auto"].contains(values[1])) case "line": let number = values[0].first == "-" ? values[0].dropFirst() : values[0][...] let integer = !number.isEmpty && number.utf8.allSatisfy { (48...57).contains($0) } valid = (values.count == 1 || values.count == 2) && (values[0] == "auto" || percent(values[0]) || integer) && (values.count == 1 || ["start", "center", "end"].contains(values[1])) default: valid = false } guard valid else { throw SubtitleError.content } } } private static func plainText(_ payload: String) throws -> String { var result = "" var index = payload.startIndex let entities: [String: String] = ["amp": "&", "lt": "<", "gt": ">", "nbsp": "\u{A0}", "lrm": "\u{200E}", "rlm": "\u{200F}", "quot": "\"", "apos": "'"] while index < payload.endIndex { if payload[index] == "<" { guard let end = payload[index...].firstIndex(of: ">") else { throw SubtitleError.content } var tag = String(payload[payload.index(after: index).. Void) { lock.lock() count += 1 let withinLimit = count <= 5 lock.unlock() guard withinLimit, let source = request.url, let destination = newRequest.url, WebVTTDocument.permitsRedirect(from: source, to: destination) else { completionHandler(nil) return } var redirected = newRequest redirected.httpShouldHandleCookies = false for (name, value) in request.allHTTPHeaderFields ?? [:] { redirected.setValue(value, forHTTPHeaderField: name) } completionHandler(redirected) } }