feat: segment playback slider with resolved YouTube chapters

This commit is contained in:
2026-10-25 02:22:37 +01:00
parent 2391dadc75
commit 119fe2e737
5 changed files with 272 additions and 20 deletions
+22 -13
View File
@@ -76,6 +76,15 @@ final class PlayerModel: ObservableObject {
currentMedia?.url.absoluteString == metadataLifecycle.identity else { return [] }
return metadataLifecycle.visibleIntervals(duration: state.duration)
}
var sliderBoundaries: [Double] {
guard !isBusy, state.status != PLAYER_EMPTY, state.status != PLAYER_ERROR,
state.duration.isFinite, state.duration > 0 else { return [] }
if case .youtube(let video, _) = currentMedia {
return video.chapterBoundaries(duration: state.duration)
}
return Array(Set(visibleIntervals.flatMap { [$0.start, $0.end] }
.filter { $0 > 0 && $0 < state.duration })).sorted()
}
@Published private var skipOffer: (interval: MiruroInterval, deadline: Double)?
private var offeredSegments: Set<MiruroInterval.Kind> = []
private let skipClock: () -> Double
@@ -2336,7 +2345,7 @@ private struct PlaybackSlider: NSViewRepresentable {
var volume = false
var enabled = true
var bufferedRange: ClosedRange<Double>?
var intervals: [MiruroInterval] = []
var boundaries: [Double] = []
var onEditingChanged: (Bool) -> Void = { _ in }
func makeCoordinator() -> Coordinator { Coordinator(self) }
@@ -2360,9 +2369,9 @@ private struct PlaybackSlider: NSViewRepresentable {
slider.isEnabled = enabled
slider.editingChanged = onEditingChanged
if let cell = slider.cell as? Cell,
cell.bufferedRange != bufferedRange || cell.intervals != intervals {
cell.bufferedRange != bufferedRange || cell.boundaries != boundaries {
cell.bufferedRange = bufferedRange
cell.intervals = intervals
cell.boundaries = boundaries
slider.needsDisplay = true
}
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
@@ -2433,7 +2442,7 @@ private struct PlaybackSlider: NSViewRepresentable {
final class Cell: NSSliderCell {
var volume = false
var bufferedRange: ClosedRange<Double>?
var intervals: [MiruroInterval] = []
var boundaries: [Double] = []
#if PLAYER_CHECK
var renderedKnob = NSRect.zero
#endif
@@ -2450,16 +2459,15 @@ private struct PlaybackSlider: NSViewRepresentable {
defer { NSGraphicsContext.restoreGraphicsState() }
if !volume, maxValue > minValue, bar.width > 0 {
let scale = bar.width / (maxValue - minValue)
let boundaries = Array(Set([minValue, maxValue] + intervals.flatMap {
[$0.start, $0.end]
}.filter { $0 > minValue && $0 < maxValue })).sorted()
let track = NSBezierPath()
for index in 0..<(boundaries.count - 1) {
let start = bar.minX + (boundaries[index] - minValue) * scale
let end = bar.minX + (boundaries[index + 1] - minValue) * scale
for index in 0...boundaries.count {
let startTime = index == 0 ? minValue : boundaries[index - 1]
let endTime = index == boundaries.count ? maxValue : boundaries[index]
let start = bar.minX + (startTime - minValue) * scale
let end = bar.minX + (endTime - minValue) * scale
let inset = min(1.5, (end - start) / 4)
let left = index == 0 ? start : start + inset
let right = index == boundaries.count - 2 ? end : end - inset
let right = index == boundaries.count ? end : end - inset
track.append(NSBezierPath(roundedRect:
NSRect(x: left, y: bar.minY, width: right - left, height: bar.height),
xRadius: 1.5, yRadius: 1.5))
@@ -2518,8 +2526,9 @@ func playbackSliderHitCheck() -> Bool {
for (initial, offset, movement): (Double, CGFloat, CGFloat) in [
(50, -4, 0), (50, -2, 0), (50, -1, 0), (50, 0, 0), (50, 1, 0), (50, 2, 0), (50, 4, 0),
(20, -1, -20), (80, 1, 20), (20, -4, -20), (80, 4, 20),
(20, 0, -500), (80, 0, 500)
(20, 0, -500), (80, 0, 500), (25, 0, 0), (25, 0, 50), (75, 0, -50)
] {
cell.boundaries = initial == 25 || initial == 75 ? [25, 75] : []
slider.doubleValue = initial
let knob = cell.knobRect(flipped: slider.isFlipped)
let point = slider.convert(NSPoint(x: knob.midX + offset, y: knob.midY), to: nil)
@@ -3154,7 +3163,7 @@ 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, intervals: intervals, onEditingChanged: { editing in
enabled: model.canSeek, bufferedRange: buffered, boundaries: model.sliderBoundaries, onEditingChanged: { editing in
if editing { model.beginSeeking() } else { model.finishSeeking() }
})
.frame(height: 14)
+47 -1
View File
@@ -7,6 +7,12 @@ struct YouTubeStream: Sendable {
let headers: String
}
struct YouTubeChapter: Sendable {
let start: Double
let end: Double
let title: String?
}
struct YouTubeVideo: Sendable {
let title: String
let video: YouTubeStream
@@ -16,6 +22,19 @@ struct YouTubeVideo: Sendable {
let webpageURL: URL
let height: Int?
let availableHeights: [Int]
let chapters: [YouTubeChapter]
func chapterBoundaries(duration: Double) -> [Double] {
guard duration.isFinite, duration > 0 else { return [] }
var boundaries = Set<Double>()
for chapter in chapters {
guard chapter.start.isFinite, chapter.end.isFinite,
chapter.start >= 0, chapter.end > chapter.start, chapter.end <= duration else { continue }
if chapter.start > 0 { boundaries.insert(chapter.start) }
if chapter.end < duration { boundaries.insert(chapter.end) }
}
return boundaries.sorted()
}
}
private struct YouTubeResolutionError: LocalizedError {
@@ -135,7 +154,8 @@ enum YouTubeResolver {
video: video, audio: audio, duration: duration,
isLive: metadata.is_live == true || metadata.live_status == "is_live",
webpageURL: try validateURL(webpageURL.absoluteString),
height: videoFormat.height, availableHeights: availableHeights
height: videoFormat.height, availableHeights: availableHeights,
chapters: metadata.chapters?.values ?? []
)
}
@@ -386,12 +406,38 @@ private struct YouTubeMetadata: Decodable {
let formats: [YouTubeMetadata]?
let http_headers: [String: String]?
let requested_formats: [YouTubeMetadata]?
let chapters: YouTubeChapters?
// Generic extractors may omit codec hints; FFmpeg probes the actual streams.
var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? true }
var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? true }
}
private struct YouTubeChapters: Decodable {
let values: [YouTubeChapter]
private enum CodingKeys: String, CodingKey {
case start_time, end_time, title
}
init(from decoder: Decoder) {
guard var rows = try? decoder.unkeyedContainer() else {
values = []
return
}
var chapters: [YouTubeChapter] = []
while !rows.isAtEnd {
guard let rowDecoder = try? rows.superDecoder() else { break }
guard let row = try? rowDecoder.container(keyedBy: CodingKeys.self),
let start = try? row.decode(Double.self, forKey: .start_time),
let end = try? row.decode(Double.self, forKey: .end_time) else { continue }
chapters.append(YouTubeChapter(start: start, end: end,
title: try? row.decode(String.self, forKey: .title)))
}
values = chapters
}
}
private struct YouTubeExtractionOutput: Sendable {
let stdout: Data
let stderr: Data