feat: enrich Miruro URL playback metadata and timeline
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
import Foundation
|
||||
import CoreFoundation
|
||||
|
||||
struct MiruroInterval: Sendable, Equatable {
|
||||
enum Kind: Sendable { case intro, outro }
|
||||
let kind: Kind
|
||||
let start: Double
|
||||
let end: Double
|
||||
let episode: Int
|
||||
var rawKind: String = ""
|
||||
var provider: String? = nil
|
||||
var votes: Double? = nil
|
||||
var referenceLength: Double? = nil
|
||||
}
|
||||
|
||||
struct MiruroMetadata: Sendable {
|
||||
let title: String?
|
||||
let intervals: [MiruroInterval]
|
||||
|
||||
func visibleIntervals(episode: Int, duration: Double) -> [MiruroInterval] {
|
||||
guard duration.isFinite, duration > 0 else { return [] }
|
||||
var intro: MiruroInterval?, outro: MiruroInterval?
|
||||
for row in intervals {
|
||||
guard row.episode == episode, row.start.isFinite, row.end.isFinite,
|
||||
row.start >= 0, row.end > row.start, row.end <= duration,
|
||||
!(row.provider?.range(of: "crunchyroll", options: .caseInsensitive) != nil),
|
||||
(row.votes ?? 0).isFinite, (row.votes ?? 0) >= 0 else { continue }
|
||||
if let length = row.referenceLength, length.isFinite, length > 0,
|
||||
abs(length - duration) / duration > 0.05 { continue }
|
||||
// Strictly greater preserves input order on ties, equivalent to stable descending votes.
|
||||
switch row.kind {
|
||||
case .intro:
|
||||
if intro == nil || (row.votes ?? 0) > (intro?.votes ?? 0) { intro = row }
|
||||
case .outro:
|
||||
if outro == nil || (row.votes ?? 0) > (outro?.votes ?? 0) { outro = row }
|
||||
}
|
||||
}
|
||||
var selected: [MiruroInterval] = []
|
||||
if let intro { selected.append(intro) }
|
||||
if let outro { selected.append(outro) }
|
||||
return selected
|
||||
}
|
||||
}
|
||||
|
||||
private struct MiruroMetadataError: LocalizedError {
|
||||
var errorDescription: String? { "Miruro optional metadata was unavailable or invalid." }
|
||||
}
|
||||
|
||||
enum MiruroMetadataResolver {
|
||||
static let bodyLimit = 2 * 1024 * 1024
|
||||
static let redirectLimit = 4
|
||||
|
||||
// Both HTTP redirects and SvelteKit's HTTP-200 redirect envelopes use this policy.
|
||||
struct Route {
|
||||
let mediaID: String
|
||||
let episode: Int
|
||||
private(set) var url: URL
|
||||
private var visited: Set<URL>
|
||||
private var redirects = 0
|
||||
|
||||
init(mediaID: String, episode: Int) throws {
|
||||
guard ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { throw MiruroMetadataError() }
|
||||
self.mediaID = mediaID
|
||||
self.episode = episode
|
||||
url = URL(string: "https://barelystarted.miruro.tv/watch/\(mediaID)/episode/__data.json?ep=\(episode)")!
|
||||
visited = [url]
|
||||
}
|
||||
|
||||
mutating func follow(_ location: String) throws {
|
||||
guard redirects < MiruroMetadataResolver.redirectLimit,
|
||||
!location.unicodeScalars.contains(where: {
|
||||
CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0)
|
||||
}), !location.contains("\\"),
|
||||
let destination = URL(string: location, relativeTo: url)?.absoluteURL,
|
||||
var parts = URLComponents(url: destination, resolvingAgainstBaseURL: true),
|
||||
parts.scheme?.lowercased() == "https", parts.host?.lowercased() == "barelystarted.miruro.tv",
|
||||
parts.port == nil || parts.port == 443, parts.user == nil, parts.password == nil,
|
||||
parts.fragment == nil, parts.percentEncodedPath == parts.path,
|
||||
parts.percentEncodedQuery == "ep=\(episode)" else { throw MiruroMetadataError() }
|
||||
let path = parts.path.split(separator: "/", omittingEmptySubsequences: false)
|
||||
guard (path.count == 4 || path.count == 5), path[0].isEmpty, path[1] == "watch",
|
||||
path[2] == Substring(mediaID), !path[3].isEmpty, path[3].utf8.count <= 512,
|
||||
path[3].utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) ||
|
||||
(48...57).contains($0) || $0 == 45 || $0 == 95 }),
|
||||
path.count == 4 || path[4] == "__data.json" else { throw MiruroMetadataError() }
|
||||
parts.scheme = "https"
|
||||
parts.host = "barelystarted.miruro.tv"
|
||||
parts.port = nil
|
||||
parts.path = "/watch/\(mediaID)/\(path[3])/__data.json"
|
||||
guard let next = parts.url, visited.insert(next).inserted else { throw MiruroMetadataError() }
|
||||
redirects += 1
|
||||
url = next
|
||||
}
|
||||
}
|
||||
|
||||
static func resolve(mediaID: String, episode: Int) async throws -> MiruroMetadata {
|
||||
try Task.checkCancellation()
|
||||
var route = try Route(mediaID: mediaID, episode: episode)
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 25
|
||||
configuration.timeoutIntervalForResource = 25
|
||||
configuration.httpShouldSetCookies = false
|
||||
configuration.httpCookieStorage = nil
|
||||
configuration.urlCache = nil
|
||||
let session = URLSession(configuration: configuration, delegate: MetadataRedirectPolicy(), delegateQueue: nil)
|
||||
defer { session.invalidateAndCancel() }
|
||||
do {
|
||||
while true {
|
||||
try Task.checkCancellation()
|
||||
var request = URLRequest(url: route.url)
|
||||
request.timeoutInterval = 25
|
||||
request.setValue("application/json, text/sveltekit-data", forHTTPHeaderField: "Accept")
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw MiruroMetadataError() }
|
||||
if [301, 302, 303, 307, 308].contains(http.statusCode) {
|
||||
guard let location = http.value(forHTTPHeaderField: "Location") else { throw MiruroMetadataError() }
|
||||
try route.follow(location)
|
||||
continue
|
||||
}
|
||||
try validateResponse(response)
|
||||
var body = Data()
|
||||
if response.expectedContentLength > 0 { body.reserveCapacity(Int(response.expectedContentLength)) }
|
||||
for try await byte in bytes {
|
||||
try Task.checkCancellation()
|
||||
guard body.count < bodyLimit else { throw MiruroMetadataError() }
|
||||
body.append(byte)
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
switch try decodeRoute(body, mediaID: mediaID, episode: episode) {
|
||||
case .redirect(let location): try route.follow(location)
|
||||
case .metadata(let metadata): return metadata
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled {
|
||||
throw CancellationError()
|
||||
}
|
||||
throw MiruroMetadataError()
|
||||
}
|
||||
}
|
||||
|
||||
static func validateResponse(_ response: URLResponse) throws {
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200,
|
||||
["application/json", "text/sveltekit-data"].contains(http.mimeType?.lowercased() ?? ""),
|
||||
http.expectedContentLength <= Int64(bodyLimit) else { throw MiruroMetadataError() }
|
||||
}
|
||||
|
||||
enum RouteData { case redirect(String), metadata(MiruroMetadata) }
|
||||
|
||||
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> MiruroMetadata {
|
||||
guard case .metadata(let metadata) = try decodeRoute(data, mediaID: mediaID, episode: episode) else {
|
||||
throw MiruroMetadataError()
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
static func decodeRoute(_ data: Data, mediaID: String, episode: Int) throws -> RouteData {
|
||||
guard data.count <= bodyLimit, ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else {
|
||||
throw MiruroMetadataError()
|
||||
}
|
||||
// Bound JSON nesting before Foundation allocates containers. Devalue traversal below is schema-guided,
|
||||
// so unrelated source payloads and unsupported tagged types are never recursively expanded.
|
||||
var depth = 0, quoted = false, escaped = false
|
||||
for byte in data {
|
||||
if quoted {
|
||||
if escaped { escaped = false }
|
||||
else if byte == 92 { escaped = true }
|
||||
else if byte == 34 { quoted = false }
|
||||
} else if byte == 34 { quoted = true }
|
||||
else if byte == 91 || byte == 123 {
|
||||
depth += 1
|
||||
guard depth <= 64 else { throw MiruroMetadataError() }
|
||||
} else if byte == 93 || byte == 125 {
|
||||
depth -= 1
|
||||
guard depth >= 0 else { throw MiruroMetadataError() }
|
||||
}
|
||||
}
|
||||
let lines = data.split(separator: 10).filter { !$0.allSatisfy({ $0 == 13 || $0 == 32 || $0 == 9 }) }
|
||||
guard !lines.isEmpty, lines.count <= 128 else { throw MiruroMetadataError() }
|
||||
let envelopes = try lines.map { line -> [String: Any] in
|
||||
guard let envelope = try JSONSerialization.jsonObject(with: Data(line)) as? [String: Any] else {
|
||||
throw MiruroMetadataError()
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
// Chunks may arrive before the initial envelope and each has its own independent table.
|
||||
let initials = envelopes.filter { ($0["type"] as? String) == "data" || ($0["type"] as? String) == "redirect" }
|
||||
guard initials.count == 1, let initial = initials.first else { throw MiruroMetadataError() }
|
||||
if initial["type"] as? String == "redirect" {
|
||||
guard envelopes.count == 1, let location = initial["location"] as? String else { throw MiruroMetadataError() }
|
||||
return .redirect(location)
|
||||
}
|
||||
guard let nodes = initial["nodes"] as? [Any], nodes.count > 1,
|
||||
let node = nodes[1] as? [String: Any], node["type"] as? String == "data",
|
||||
let table = node["data"] as? [Any], !table.isEmpty else { throw MiruroMetadataError() }
|
||||
let reader = MetadataReader(envelopes: envelopes)
|
||||
let root = MetadataReader.Cursor(table: table, index: 0, depth: 0)
|
||||
let media = try? reader.field(root, "media")
|
||||
if let id = media.flatMap({ try? reader.string(reader.field($0, "id")) }), id != mediaID { throw MiruroMetadataError() }
|
||||
if let number = try? reader.integer(reader.field(root, "episodeNumber")), number != episode { throw MiruroMetadataError() }
|
||||
var series: String?
|
||||
if let media, let title = try? reader.field(media, "title") {
|
||||
series = cleanTitle(try? reader.string(title))
|
||||
if series == nil {
|
||||
for key in ["english", "romaji", "native"] {
|
||||
if let value = cleanTitle(try? reader.string(reader.field(title, key))) { series = value; break }
|
||||
}
|
||||
}
|
||||
}
|
||||
let episodes = try? reader.field(root, "episodesData")
|
||||
var episodeTitle: String?
|
||||
if let episodes {
|
||||
if let current = try? reader.field(episodes, "currentEpisode"),
|
||||
(try? reader.integer(reader.field(current, "number"))) == episode {
|
||||
episodeTitle = cleanTitle(try? reader.string(reader.field(current, "title")))
|
||||
}
|
||||
if episodeTitle == nil, let rows = try? reader.array(reader.field(episodes, "episodes")) {
|
||||
for row in rows where (try? reader.integer(reader.field(row, "number"))) == episode {
|
||||
episodeTitle = cleanTitle(try? reader.string(reader.field(row, "title")))
|
||||
if episodeTitle != nil { break }
|
||||
}
|
||||
}
|
||||
}
|
||||
var intervals: [MiruroInterval] = []
|
||||
if let episodes, let rows = try? reader.array(reader.field(episodes, "aniskip")) {
|
||||
for row in rows {
|
||||
guard let rawKind = try? reader.string(reader.field(row, "type")),
|
||||
let number = try? reader.integer(reader.field(row, "episode")),
|
||||
let start = try? reader.number(reader.field(row, "start")),
|
||||
let end = try? reader.number(reader.field(row, "end")) else { continue }
|
||||
let kind: MiruroInterval.Kind
|
||||
switch rawKind {
|
||||
case "op", "mixed-op": kind = .intro
|
||||
case "ed", "mixed-ed": kind = .outro
|
||||
default: continue
|
||||
}
|
||||
intervals.append(MiruroInterval(kind: kind, start: start, end: end, episode: number,
|
||||
rawKind: rawKind, provider: try? reader.string(reader.field(row, "provider")),
|
||||
votes: try? reader.number(reader.field(row, "votes")),
|
||||
referenceLength: try? reader.number(reader.field(row, "episode_length"))))
|
||||
}
|
||||
}
|
||||
let title: String?
|
||||
if let series { title = series + " — Episode \(episode)" + (episodeTitle.map { ": " + $0 } ?? "") }
|
||||
else { title = episodeTitle.map { "Episode \(episode): " + $0 } }
|
||||
return .metadata(MiruroMetadata(title: title, intervals: intervals))
|
||||
}
|
||||
|
||||
private static func cleanTitle(_ value: String?) -> String? {
|
||||
guard let value, value.utf8.count <= 2048,
|
||||
!value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { return nil }
|
||||
let title = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return title.isEmpty ? nil : title
|
||||
}
|
||||
}
|
||||
|
||||
// Only traverse fields needed for metadata. The budget counts repeated references as well as strings,
|
||||
// bounding amplification without copying/expanding whole devalue objects or sourcesData.
|
||||
private final class MetadataReader {
|
||||
struct Cursor { let table: [Any]; let index: Int; let depth: Int }
|
||||
private let envelopes: [[String: Any]]
|
||||
private var budget = MiruroMetadataResolver.bodyLimit
|
||||
|
||||
init(envelopes: [[String: Any]]) { self.envelopes = envelopes }
|
||||
|
||||
private func charge(_ cost: Int = 32) throws {
|
||||
guard cost <= budget else { throw MiruroMetadataError() }
|
||||
budget -= cost
|
||||
}
|
||||
|
||||
private func value(_ cursor: Cursor) throws -> Any {
|
||||
let cursor = try resolved(cursor)
|
||||
try charge()
|
||||
let value = cursor.table[cursor.index]
|
||||
if let tag = value as? [Any], tag.first is String { throw MiruroMetadataError() }
|
||||
return value
|
||||
}
|
||||
|
||||
private func reference(_ value: Any, from cursor: Cursor) throws -> Cursor {
|
||||
guard let index = Self.integer(value), index >= 0 else { throw MiruroMetadataError() }
|
||||
return Cursor(table: cursor.table, index: index, depth: cursor.depth + 1)
|
||||
}
|
||||
|
||||
func field(_ cursor: Cursor, _ name: String) throws -> Cursor {
|
||||
// A promise's resolved object must retain its own table for subsequent references.
|
||||
let cursor = try resolved(cursor)
|
||||
guard let object = try value(cursor) as? [String: Any], let reference = object[name] else { throw MiruroMetadataError() }
|
||||
return try self.reference(reference, from: cursor)
|
||||
}
|
||||
|
||||
private func resolved(_ cursor: Cursor) throws -> Cursor {
|
||||
try charge()
|
||||
guard cursor.depth < 32, cursor.table.indices.contains(cursor.index) else { throw MiruroMetadataError() }
|
||||
guard let tag = cursor.table[cursor.index] as? [Any], tag.first as? String == "Promise" else { return cursor }
|
||||
guard tag.count == 2 else { throw MiruroMetadataError() }
|
||||
let id = try integer(reference(tag[1], from: cursor))
|
||||
let matches = envelopes.filter { ($0["type"] as? String) == "chunk" && Self.integer($0["id"]) == id }
|
||||
guard matches.count == 1, let chunk = matches.first, chunk["error"] == nil,
|
||||
let table = chunk["data"] as? [Any] else { throw MiruroMetadataError() }
|
||||
return try resolved(Cursor(table: table, index: 0, depth: cursor.depth + 1))
|
||||
}
|
||||
|
||||
func array(_ cursor: Cursor) throws -> [Cursor] {
|
||||
let cursor = try resolved(cursor)
|
||||
guard let rows = try value(cursor) as? [Any] else { throw MiruroMetadataError() }
|
||||
try charge(rows.count * 32)
|
||||
// Bad individual references do not invalidate their siblings.
|
||||
return rows.compactMap { try? reference($0, from: cursor) }
|
||||
}
|
||||
|
||||
func string(_ cursor: Cursor) throws -> String {
|
||||
guard let string = try value(cursor) as? String else { throw MiruroMetadataError() }
|
||||
try charge(string.utf8.count)
|
||||
return string
|
||||
}
|
||||
|
||||
func number(_ cursor: Cursor) throws -> Double {
|
||||
guard let number = try value(cursor) as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { throw MiruroMetadataError() }
|
||||
return number.doubleValue
|
||||
}
|
||||
|
||||
func integer(_ cursor: Cursor) throws -> Int {
|
||||
guard let integer = Self.integer(try value(cursor)) else { throw MiruroMetadataError() }
|
||||
return integer
|
||||
}
|
||||
|
||||
private static func integer(_ value: Any?) -> Int? {
|
||||
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
|
||||
let value = number.doubleValue
|
||||
guard value.isFinite, value.rounded(.towardZero) == value, value >= 0, value < Double(Int.max) else { return nil }
|
||||
return Int(value)
|
||||
}
|
||||
}
|
||||
|
||||
private final class MetadataRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
|
||||
completionHandler: @escaping (URLRequest?) -> Void) {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
+144
-7
@@ -57,6 +57,13 @@ final class PlayerModel: ObservableObject {
|
||||
var castConnecting: Bool { casting.connecting }
|
||||
private var currentMedia: MediaSource?
|
||||
private var shouldRecordRecentMedia = false
|
||||
private var metadataLifecycle = MetadataLifecycle()
|
||||
private var metadataTask: Task<Void, Never>?
|
||||
var visibleIntervals: [MiruroInterval] {
|
||||
guard !isBusy, state.status != PLAYER_EMPTY, state.status != PLAYER_ERROR,
|
||||
currentMedia?.url.absoluteString == metadataLifecycle.identity else { return [] }
|
||||
return metadataLifecycle.visibleIntervals(duration: state.duration)
|
||||
}
|
||||
var pendingRevision: UInt64? { transition.pendingRevision }
|
||||
var resolvingStream: Bool { transition.isResolving }
|
||||
var isLiveStream: Bool { transition.isLive }
|
||||
@@ -478,6 +485,31 @@ final class PlayerModel: ObservableObject {
|
||||
revealControls()
|
||||
}
|
||||
|
||||
private func replaceMetadata(for source: MediaSource?) {
|
||||
metadataTask?.cancel()
|
||||
metadataTask = nil
|
||||
let parts = source?.url.absoluteString.split(separator: ":") ?? []
|
||||
let episode = parts.count == 3 && parts[0] == "miruro" ? Int(parts[2]) : nil
|
||||
let generation = metadataLifecycle.replace(identity: source?.url.absoluteString, episode: episode)
|
||||
guard let source, let episode else { return }
|
||||
let identity = source.url.absoluteString
|
||||
let mediaID = String(parts[1])
|
||||
metadataTask = Task { [weak self] in
|
||||
guard let metadata = try? await MiruroMetadataResolver.resolve(mediaID: mediaID, episode: episode),
|
||||
!Task.isCancelled, let self,
|
||||
self.currentMedia?.url.absoluteString == identity,
|
||||
self.metadataLifecycle.accept(metadata, identity: identity, generation: generation) else { return }
|
||||
self.objectWillChange.send()
|
||||
if let title = metadata.title {
|
||||
self.fileName = title
|
||||
self.window?.title = title
|
||||
self.casting.updateTitle(title)
|
||||
self.systemPlayback?.update()
|
||||
}
|
||||
self.metadataTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func openMedia(_ source: MediaSource, change: MediaChange = .open) {
|
||||
guard let handle else { return }
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
@@ -578,6 +610,7 @@ final class PlayerModel: ObservableObject {
|
||||
window?.representedURL = source.url
|
||||
window?.title = fileName ?? "Terebi"
|
||||
revealControls()
|
||||
replaceMetadata(for: source)
|
||||
systemPlayback?.update()
|
||||
}
|
||||
|
||||
@@ -718,6 +751,7 @@ final class PlayerModel: ObservableObject {
|
||||
queueCompletionHandled = true
|
||||
queueCastDevice = nil
|
||||
currentMedia = nil
|
||||
replaceMetadata(for: nil)
|
||||
shouldRecordRecentMedia = false
|
||||
selectSubtitle(nil)
|
||||
subtitleTracks = []
|
||||
@@ -1223,6 +1257,7 @@ final class PlayerModel: ObservableObject {
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
castDiscovery.stop()
|
||||
cancelStreamResolution()
|
||||
replaceMetadata(for: nil)
|
||||
selectSubtitle(nil)
|
||||
systemPlayback?.shutdown()
|
||||
systemPlayback = nil
|
||||
@@ -1281,7 +1316,7 @@ final class PlayerModel: ObservableObject {
|
||||
private final class Operation {
|
||||
let device: CastDevice
|
||||
let source: MediaSource
|
||||
let title: String
|
||||
var title: String
|
||||
let subtitle: Int?
|
||||
let handle: OpaquePointer
|
||||
let snapshot: PlayerState
|
||||
@@ -1324,6 +1359,11 @@ final class PlayerModel: ObservableObject {
|
||||
return (operation.source, projection.state)
|
||||
}
|
||||
|
||||
func updateTitle(_ title: String) {
|
||||
// The existing Cast interface accepts titles at LOAD; do not reload active playback.
|
||||
operation?.title = title
|
||||
}
|
||||
|
||||
func start(to device: CastDevice, source: MediaSource, title: String, subtitle: Int?,
|
||||
handle: OpaquePointer, snapshot: PlayerState, playing: Bool,
|
||||
volume: Double, audibleVolume: Double) {
|
||||
@@ -1454,7 +1494,7 @@ final class PlayerModel: ObservableObject {
|
||||
guard var projection = operation.projection else { return }
|
||||
projection.state.status = status.ended ? PLAYER_ENDED : (status.playing ? PLAYER_PLAYING : PLAYER_PAUSED)
|
||||
projection.state.position = status.position
|
||||
if status.duration > 0 { projection.state.duration = status.duration }
|
||||
projection.state.duration = status.duration.isFinite && status.duration > 0 ? status.duration : 0
|
||||
projection.state.buffering = status.buffering
|
||||
projection.state.can_seek = status.seekable && !operation.source.live
|
||||
projection.state.seek_pending = status.seekPending
|
||||
@@ -1535,6 +1575,67 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private struct MetadataLifecycle {
|
||||
private(set) var identity: String?
|
||||
private(set) var metadata: MiruroMetadata?
|
||||
private var episode: Int?
|
||||
private var generation: UInt64 = 0
|
||||
|
||||
mutating func replace(identity: String?, episode: Int?) -> UInt64 {
|
||||
generation &+= 1
|
||||
self.identity = identity
|
||||
self.episode = episode
|
||||
metadata = nil
|
||||
return generation
|
||||
}
|
||||
|
||||
mutating func accept(_ metadata: MiruroMetadata, identity: String, generation: UInt64) -> Bool {
|
||||
guard self.identity == identity, self.generation == generation else { return false }
|
||||
self.metadata = metadata
|
||||
return true
|
||||
}
|
||||
|
||||
func visibleIntervals(duration: Double) -> [MiruroInterval] {
|
||||
guard let episode, let metadata else { return [] }
|
||||
return metadata.visibleIntervals(episode: episode, duration: duration)
|
||||
}
|
||||
}
|
||||
|
||||
#if PLAYER_CHECK
|
||||
static func metadataLifecycleCheck() {
|
||||
var lifecycle = MetadataLifecycle()
|
||||
let a = "miruro:A:1"
|
||||
let b = "miruro:B:2"
|
||||
let metadata = MiruroMetadata(title: "Series — Episode 1", intervals: [
|
||||
MiruroInterval(kind: .intro, start: 2.5, end: 10, episode: 1),
|
||||
MiruroInterval(kind: .outro, start: 90, end: 100, episode: 1),
|
||||
])
|
||||
let first = lifecycle.replace(identity: a, episode: 1)
|
||||
precondition(lifecycle.accept(metadata, identity: a, generation: first), "Current metadata is accepted")
|
||||
precondition(lifecycle.visibleIntervals(duration: 0).isEmpty, "Unknown duration hides bands")
|
||||
precondition(lifecycle.visibleIntervals(duration: 100).count == 2, "Actual duration permits exact-end bands")
|
||||
precondition(lifecycle.visibleIntervals(duration: 95).count == 1, "Duration shrink revalidates raw bands")
|
||||
precondition(lifecycle.visibleIntervals(duration: 100).count == 2, "Duration growth restores raw bands")
|
||||
_ = lifecycle.replace(identity: b, episode: 2)
|
||||
precondition(lifecycle.metadata == nil, "Replacement clears title and intervals")
|
||||
precondition(!lifecycle.accept(metadata, identity: a, generation: first), "Different-identity result is stale")
|
||||
let second = lifecycle.replace(identity: a, episode: 1)
|
||||
precondition(!lifecycle.accept(metadata, identity: a, generation: first), "A-B-A result is stale")
|
||||
precondition(lifecycle.accept(metadata, identity: a, generation: second), "Fresh same-identity result is accepted")
|
||||
precondition(lifecycle.accept(MiruroMetadata(title: nil, intervals: []), identity: a, generation: second),
|
||||
"Empty optional metadata is accepted")
|
||||
precondition(lifecycle.metadata?.title == nil && lifecycle.visibleIntervals(duration: 100).isEmpty,
|
||||
"Absent title and intervals do not retain enrichment")
|
||||
_ = lifecycle.replace(identity: "file:///replacement.mp4", episode: nil)
|
||||
precondition(lifecycle.metadata == nil && lifecycle.visibleIntervals(duration: 100).isEmpty,
|
||||
"Local replacement has no title enrichment or bands")
|
||||
_ = lifecycle.replace(identity: "https://www.youtube.com/watch?v=fixture", episode: nil)
|
||||
precondition(!lifecycle.accept(metadata, identity: a, generation: second), "YouTube rejects earlier metadata")
|
||||
_ = lifecycle.replace(identity: nil, episode: nil)
|
||||
precondition(lifecycle.metadata == nil, "Close clears metadata")
|
||||
}
|
||||
#endif
|
||||
|
||||
private struct PlaybackTransition {
|
||||
enum PlaybackSubmission {
|
||||
case unchanged
|
||||
@@ -1829,6 +1930,7 @@ private struct PlaybackSlider: NSViewRepresentable {
|
||||
var volume = false
|
||||
var enabled = true
|
||||
var bufferedRange: ClosedRange<Double>?
|
||||
var intervals: [MiruroInterval] = []
|
||||
var onEditingChanged: (Bool) -> Void = { _ in }
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||
@@ -1851,8 +1953,10 @@ private struct PlaybackSlider: NSViewRepresentable {
|
||||
slider.setModelValue(value)
|
||||
slider.isEnabled = enabled
|
||||
slider.editingChanged = onEditingChanged
|
||||
if let cell = slider.cell as? Cell, cell.bufferedRange != bufferedRange {
|
||||
if let cell = slider.cell as? Cell,
|
||||
cell.bufferedRange != bufferedRange || cell.intervals != intervals {
|
||||
cell.bufferedRange = bufferedRange
|
||||
cell.intervals = intervals
|
||||
slider.needsDisplay = true
|
||||
}
|
||||
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
|
||||
@@ -1900,9 +2004,36 @@ private struct PlaybackSlider: NSViewRepresentable {
|
||||
final class Cell: NSSliderCell {
|
||||
var volume = false
|
||||
var bufferedRange: ClosedRange<Double>?
|
||||
var intervals: [MiruroInterval] = []
|
||||
private static let labelAttributes: [NSAttributedString.Key: Any] = [
|
||||
.font: NSFont.systemFont(ofSize: 9, weight: .semibold),
|
||||
.foregroundColor: SunsetPalette.text,
|
||||
]
|
||||
override func drawBar(inside rect: NSRect, flipped: Bool) {
|
||||
let bar = NSRect(x: rect.minX + knobThickness / 2, y: rect.midY - 1.5,
|
||||
width: max(0, rect.width - knobThickness), height: 3)
|
||||
if !volume, maxValue > minValue {
|
||||
let scale = bar.width / (maxValue - minValue)
|
||||
for interval in intervals {
|
||||
let band = NSRect(x: bar.minX + (interval.start - minValue) * scale,
|
||||
y: bar.minY - 2, width: (interval.end - interval.start) * scale, height: 7)
|
||||
let color = interval.kind == .intro ? SunsetPalette.accent : SunsetPalette.ember
|
||||
color.withAlphaComponent(0.3).setFill()
|
||||
NSBezierPath(rect: band).fill()
|
||||
SunsetPalette.secondaryText.withAlphaComponent(0.8).setStroke()
|
||||
let outline = NSBezierPath(rect: band)
|
||||
outline.lineWidth = 0.5
|
||||
outline.stroke()
|
||||
let full = (interval.kind == .intro ? "Opening" : "Ending") as NSString
|
||||
let label = full.size(withAttributes: Self.labelAttributes).width <= band.width ? full :
|
||||
(interval.kind == .intro ? "OP" : "ED") as NSString
|
||||
let size = label.size(withAttributes: Self.labelAttributes)
|
||||
let x = min(max(bar.minX, band.midX - size.width / 2), bar.maxX - size.width)
|
||||
let above = interval.kind == .intro
|
||||
let y = above != flipped ? bar.maxY + 3 : bar.minY - size.height - 3
|
||||
label.draw(at: NSPoint(x: x, y: y), withAttributes: Self.labelAttributes)
|
||||
}
|
||||
}
|
||||
SunsetPalette.track.setFill()
|
||||
NSBezierPath(roundedRect: bar, xRadius: 1.5, yRadius: 1.5).fill()
|
||||
if volume {
|
||||
@@ -2475,6 +2606,12 @@ private struct PlayerContent: View {
|
||||
|
||||
private var playbackControls: some View {
|
||||
let buffered = model.bufferedRange
|
||||
let intervals = model.visibleIntervals
|
||||
let intervalStatus = intervals.map {
|
||||
"\($0.kind == .intro ? "Opening (OP)" : "Ending (ED)") \(time($0.start))–\(time($0.end))"
|
||||
}.joined(separator: ", ")
|
||||
let timelineStatus = [bufferedStatus, intervalStatus.isEmpty ? nil : intervalStatus].compactMap { $0 }
|
||||
.joined(separator: ", ")
|
||||
return VStack(spacing: 8) {
|
||||
HStack(spacing: 0) {
|
||||
HStack(spacing: 4) {
|
||||
@@ -2522,13 +2659,13 @@ private struct PlayerContent: View {
|
||||
HStack(spacing: 10) {
|
||||
Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing)
|
||||
PlaybackSlider(value: $model.seekPosition, range: 0...max(model.duration, 0.001),
|
||||
enabled: model.canSeek, bufferedRange: buffered, onEditingChanged: { editing in
|
||||
enabled: model.canSeek, bufferedRange: buffered, intervals: intervals, onEditingChanged: { editing in
|
||||
if editing { model.beginSeeking() } else { model.finishSeeking() }
|
||||
})
|
||||
.frame(height: 14)
|
||||
.frame(height: intervals.isEmpty ? 14 : 32)
|
||||
.accessibilityLabel("Playback Position")
|
||||
.accessibilityValue(time(model.seekPosition) + (bufferedStatus.map { ", " + $0 } ?? ""))
|
||||
.help(bufferedStatus ?? "Playback Position")
|
||||
.accessibilityValue(time(model.seekPosition) + (timelineStatus.isEmpty ? "" : ", " + timelineStatus))
|
||||
.help(timelineStatus.isEmpty ? "Playback Position" : timelineStatus)
|
||||
Button {
|
||||
showsRemainingTime.toggle()
|
||||
model.revealControls()
|
||||
|
||||
Reference in New Issue
Block a user