feat: play resolved Miruro HLS with external VTT and stable episode progress

This commit is contained in:
2026-10-12 19:17:22 +02:00
parent 1f12660936
commit 470d8feec2
23 changed files with 1528 additions and 38 deletions
+275
View File
@@ -0,0 +1,275 @@
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 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
}
// Providers sometimes disguise VTT resources as images: trust the explicit format and content,
// never the path extension or Content-Type.
guard let text = String(data: data, encoding: .utf8) else { throw SubtitleError.content }
return try parse(text)
}
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..<index]
let first = lines[beginning]
if first == "NOTE" || first.hasPrefix("NOTE ") || first.hasPrefix("NOTE\t") { continue }
if first == "STYLE" || first == "REGION" {
guard cues.isEmpty, !block.contains(where: { $0.contains("-->") }) 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)..<index].joined(separator: "\n")
guard !payload.contains("-->") 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<String>()
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[..<colon])
var value = String(line[line.index(after: colon)...])
// ResolvedSource serializes exactly one separator space; the resource value is verbatim.
if value.first == " " { value.removeFirst() }
guard !name.isEmpty, name.utf8.count <= 256,
name.utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || "!#$%&'*+-.^_`|~".utf8.contains($0) }),
names.insert(name.lowercased()).inserted, value.utf8.count <= 16384,
!value.unicodeScalars.contains(where: { ($0.value < 32 && $0.value != 9) || $0.value == 127 }) else { throw SubtitleError.request }
request.setValue(value, forHTTPHeaderField: name)
}
return request
}
static func permitsRedirect(from source: URL, to destination: URL) -> 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<Substring>) 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<Substring>()
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)..<end])
if tag.first == "/" { tag.removeFirst() }
let name = tag.prefix { $0 != " " && $0 != "\t" && $0 != "." }
guard ["b", "i", "u", "c", "v", "lang", "ruby", "rt"].contains(String(name)) || timestamp(tag) != nil else { throw SubtitleError.content }
index = payload.index(after: end)
} else if payload[index] == "&", let end = payload[index...].firstIndex(of: ";"), payload.distance(from: index, to: end) <= 16 {
let entity = String(payload[payload.index(after: index)..<end])
var decoded = entities[entity]
if entity.hasPrefix("#") {
let hexadecimal = entity.hasPrefix("#x") || entity.hasPrefix("#X")
let digits = entity.dropFirst(hexadecimal ? 2 : 1)
if let number = UInt32(digits, radix: hexadecimal ? 16 : 10), number != 0, let scalar = UnicodeScalar(number) { decoded = String(scalar) }
}
if let decoded {
result += decoded
index = payload.index(after: end)
} else {
result.append(payload[index])
index = payload.index(after: index)
}
} else {
result.append(payload[index])
index = payload.index(after: index)
}
}
return result
}
}
private final class SubtitleRedirects: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
private let request: URLRequest
private let lock = NSLock()
private var count = 0
init(request: URLRequest) { self.request = request }
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
newRequest: URLRequest, completionHandler: @escaping (URLRequest?) -> 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)
}
}
+122
View File
@@ -0,0 +1,122 @@
import Foundation
import Darwin
struct ResolvedSubtitle: Decodable, Sendable {
let url: String
let format: String
let language: String
let label: String
let isDefault: Bool
private let headerFields: [String: String]
var headers: String {
headerFields.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined()
}
fileprivate var validatedHeaders: [String: String] { headerFields }
private enum CodingKeys: String, CodingKey {
case url, format, language, label
case isDefault = "default"
case headerFields = "headers"
}
}
struct ResolvedSource: Sendable {
let url: String
let headers: String
let identity: String
let title: String
let subtitles: [ResolvedSubtitle]
private struct Document: Decodable {
struct Identity: Decodable {
let provider: String
let media_id: String
let episode: Int
}
let version: Int
let url: String
let headers: [String: String]
let identity: Identity
let title: String?
let subtitles: [ResolvedSubtitle]
}
private struct InvalidSource: LocalizedError {
var errorDescription: String? { "Invalid or unreadable resolved source file." }
}
static func load(from file: URL) throws -> ResolvedSource {
do {
guard file.isFileURL else { throw InvalidSource() }
let descriptor: Int32 = file.withUnsafeFileSystemRepresentation { path in
guard let path else { return -1 }
return Darwin.open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC)
}
guard descriptor >= 0 else { throw InvalidSource() }
var info = stat()
guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else {
Darwin.close(descriptor)
throw InvalidSource()
}
let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
defer { try? handle.close() }
let data = try handle.read(upToCount: 1_048_577) ?? Data()
guard data.count <= 1_048_576 else { throw InvalidSource() }
let document = try JSONDecoder().decode(Document.self, from: data)
let id = document.identity
guard document.version == 1, id.provider == "miruro",
!id.media_id.isEmpty, id.media_id.utf8.count <= 512,
id.media_id.utf8.allSatisfy({ isIDByte($0) }),
id.episode > 0, id.episode <= Int(Int32.max),
validURL(document.url), validHeaders(document.headers),
validText(document.title ?? "", limit: 4096),
document.subtitles.count <= 32,
document.subtitles.filter({ $0.isDefault }).count <= 1 else { throw InvalidSource() }
for subtitle in document.subtitles {
guard validURL(subtitle.url), subtitle.format == "vtt",
validText(subtitle.language, limit: 128), !subtitle.language.isEmpty,
validText(subtitle.label, limit: 4096), !subtitle.label.isEmpty,
validHeaders(subtitle.validatedHeaders) else { throw InvalidSource() }
}
let headers = document.headers.sorted { $0.key < $1.key }
.map { "\($0.key): \($0.value)\r\n" }.joined()
return ResolvedSource(url: document.url, headers: headers,
identity: "miruro:\(id.media_id):\(id.episode)", title: document.title ?? "Miruro episode \(id.episode)",
subtitles: document.subtitles)
} catch { throw InvalidSource() }
}
private static func isIDByte(_ byte: UInt8) -> Bool {
(65...90).contains(byte) || (97...122).contains(byte) ||
(48...57).contains(byte) || byte == 45 || byte == 95
}
private static func validText(_ text: String, limit: Int) -> Bool {
text.utf8.count <= limit && !text.unicodeScalars.contains { $0.value < 32 || $0.value == 127 }
}
private static func validURL(_ text: String) -> Bool {
guard !text.isEmpty, validText(text, limit: 32768),
!text.contains(where: { $0.isWhitespace }),
let components = URLComponents(string: text),
components.scheme == "https" || components.scheme == "http",
let host = components.host, !host.isEmpty,
components.user == nil, components.password == nil,
components.fragment == nil, components.url != nil else { return false }
return components.port == nil || (1...65535).contains(components.port!)
}
private static func validHeaders(_ headers: [String: String]) -> Bool {
guard headers.count <= 64 else { return false }
var names = Set<String>()
var size = 0
for (name, value) in headers {
guard !name.isEmpty, name.utf8.count <= 256,
name.utf8.allSatisfy({ isIDByte($0) || "!#$%&'*+.^`|~".utf8.contains($0) }),
names.insert(name.lowercased()).inserted,
value.utf8.count <= 16384,
!value.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 }) else { return false }
size += name.utf8.count + value.utf8.count + 4
}
return size <= 65536
}
}
+114
View File
@@ -37,6 +37,12 @@ final class PlayerModel: ObservableObject {
@Published private(set) var youtubeQuality: Int?
@Published private(set) var automaticYouTubeQuality = false
@Published private(set) var showsBufferInfo = false
@Published private(set) var subtitleTracks: [ResolvedSubtitle] = []
@Published private(set) var selectedSubtitle: Int?
@Published private(set) var subtitleText = ""
@Published private(set) var subtitleError: String?
private var subtitleDocument: WebVTTDocument?
private var subtitleTask: Task<Void, Never>?
@Published var seekPosition = 0.0
@Published var isSeeking = false
@Published private(set) var volume = 1.0
@@ -218,6 +224,45 @@ final class PlayerModel: ObservableObject {
resolveYouTube(url, height: nil)
}
func openResolvedSource(_ file: URL) {
do {
let source = try ResolvedSource.load(from: file)
cancelYouTubeResolution()
openMedia(.resolved(source))
} catch {
commandError = error.localizedDescription
revealControls()
}
}
func selectSubtitle(_ index: Int?) {
subtitleTask?.cancel()
subtitleTask = nil
subtitleDocument = nil
subtitleText = ""
subtitleError = nil
selectedSubtitle = index
guard let index, subtitleTracks.indices.contains(index) else { return }
let track = subtitleTracks[index]
subtitleTask = Task { [weak self] in
do {
let document = try await WebVTTDocument.load(url: track.url, headers: track.headers)
guard !Task.isCancelled, let self else { return }
self.subtitleDocument = document
self.updateSubtitle()
} catch {
guard !Task.isCancelled, let self else { return }
self.subtitleError = error.localizedDescription
}
}
}
private func updateSubtitle() {
subtitleText = state.seek_pending || transition.seekPending || isBusy ||
state.status == PLAYER_EMPTY || state.status == PLAYER_ERROR ? "" :
subtitleDocument?.text(at: state.position) ?? ""
}
func setShowsBufferInfo(_ enabled: Bool) {
showsBufferInfo = enabled
revealControls()
@@ -338,11 +383,24 @@ final class PlayerModel: ObservableObject {
}
}
}
case .resolved(let source):
accepted = source.identity.withCString { identity in
source.url.withCString { url in
source.headers.withCString { headers in
var input = PlayerNetworkSource(video_url: url, audio_url: nil,
video_headers: headers, audio_headers: nil, duration: 0, is_live: false,
media_url: identity, initial_position: savedPosition ?? 0)
return player_open_network(handle, &input)
}
}
}
}
guard accepted else {
switch source {
case .file:
commandError = "Could not queue the file to open. Please try again."
case .resolved:
commandError = "Could not queue the resolved stream to open."
case .youtube:
failYouTubeChange(change)
}
@@ -363,6 +421,19 @@ final class PlayerModel: ObservableObject {
youtubeHeights = video.availableHeights
youtubeHeight = video.height
youtubeQuality = height
case .resolved(let source):
fileName = source.title
fileIcon = nil
youtubeHeights = []
youtubeHeight = nil
youtubeQuality = nil
}
selectSubtitle(nil)
if case .resolved(let resolved) = source {
subtitleTracks = resolved.subtitles
selectSubtitle(subtitleTracks.firstIndex(where: \.isDefault))
} else {
subtitleTracks = []
}
needsVideoFit = !change.isQuality
commandError = transition.needsLocalRestoration || !change.playing || player_play(handle) ? nil :
@@ -429,6 +500,8 @@ final class PlayerModel: ObservableObject {
return
}
transition.closeMedia()
selectSubtitle(nil)
subtitleTracks = []
compactIntent = nil
compactWindow.restore(resumeFullScreen: false, animated: false)
fileName = nil
@@ -744,6 +817,7 @@ final class PlayerModel: ObservableObject {
guard observation.ready else { return }
defer { systemPlayback?.update() }
state = next
updateSubtitle()
if let playing = observation.playback { setPlayback(playing: playing) }
if !isBusy && (next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR || !next.has_video) {
compactWindow.restore(resumeFullScreen: false, animated: false)
@@ -792,6 +866,7 @@ final class PlayerModel: ObservableObject {
func shutdown() {
saveProgress()
cancelYouTubeResolution()
selectSubtitle(nil)
systemPlayback?.shutdown()
systemPlayback = nil
compactWindow.restore(resumeFullScreen: false, animated: false)
@@ -834,11 +909,13 @@ final class PlayerModel: ObservableObject {
private enum MediaSource {
case file(URL)
case youtube(YouTubeVideo, height: Int?)
case resolved(ResolvedSource)
var url: URL {
switch self {
case .file(let url): return url
case .youtube(let video, _): return video.webpageURL
case .resolved(let source): return URL(string: source.identity)!
}
}
var live: Bool {
@@ -1555,6 +1632,40 @@ private struct PlayerContent: View {
if model.isCompact {
CompactDragSurface(model: model).accessibilityHidden(true)
}
if !model.subtitleText.isEmpty {
Text(model.subtitleText)
.font(.system(size: max(16, min(28, geometry.size.width / 32)), weight: .semibold))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.padding(8)
.background(.black.opacity(0.75), in: RoundedRectangle(cornerRadius: 4))
.padding(.horizontal, 24)
.padding(.bottom, model.controlsVisible ? 110 : 24)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.allowsHitTesting(false)
}
if !model.subtitleTracks.isEmpty && (model.controlsVisible || model.subtitleError != nil) {
VStack(alignment: .trailing) {
Menu {
Button("Off") { model.selectSubtitle(nil) }
ForEach(model.subtitleTracks.indices, id: \.self) { index in
Button {
model.selectSubtitle(index)
} label: {
Label(model.subtitleTracks[index].label + " (" + model.subtitleTracks[index].language + ")",
systemImage: model.selectedSubtitle == index ? "checkmark" : "text.bubble")
}
}
} label: {
Image(systemName: "captions.bubble").accessibilityLabel("Subtitles")
}
if let error = model.subtitleError {
Text(error).font(.caption).foregroundStyle(.yellow)
}
}
.padding(.top, 44).padding(.trailing, 16)
.frame(maxWidth: .infinity, alignment: .trailing)
}
if let error = model.errorText {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle").font(.system(size: 32)).foregroundStyle(.yellow)
@@ -1918,6 +2029,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let url = URL(string: arguments[index + 1]) {
PlayerModel.shared.open(url)
}
if let index = arguments.firstIndex(of: "--source-file"), index + 1 < arguments.count {
PlayerModel.shared.openResolvedSource(URL(fileURLWithPath: arguments[index + 1]))
}
}
// Let SwiftUI create the initial window for a Finder/document launch.