feat: enrich Miruro URL playback metadata and timeline

This commit is contained in:
2026-10-22 11:39:56 +02:00
parent b9c7d7f73a
commit a1c8314391
7 changed files with 798 additions and 37 deletions
+144 -7
View File
@@ -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()