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)