feat: animate hovered chapter titles and active seek segments

This commit is contained in:
2026-10-25 03:09:34 +01:00
parent 119fe2e737
commit edb17f76e3
5 changed files with 198 additions and 12 deletions
+176 -11
View File
@@ -85,6 +85,14 @@ final class PlayerModel: ObservableObject {
return Array(Set(visibleIntervals.flatMap { [$0.start, $0.end] }
.filter { $0 > 0 && $0 < state.duration })).sorted()
}
func sliderSegmentTitle(at time: Double) -> String? {
guard !isBusy, state.duration.isFinite, state.duration > 0 else { return nil }
if case .youtube(let video, _) = currentMedia {
return video.chapterTitle(at: time, duration: state.duration)
}
return visibleIntervals.first { $0.start <= time && time < $0.end }
.map { $0.kind == .intro ? "Opening" : "Ending" }
}
@Published private var skipOffer: (interval: MiruroInterval, deadline: Double)?
private var offeredSegments: Set<MiruroInterval.Kind> = []
private let skipClock: () -> Double
@@ -2346,6 +2354,11 @@ private struct PlaybackSlider: NSViewRepresentable {
var enabled = true
var bufferedRange: ClosedRange<Double>?
var boundaries: [Double] = []
struct Hover: Equatable {
let time: Double
let x: CGFloat
}
var onHover: (Hover?) -> Void = { _ in }
var onEditingChanged: (Bool) -> Void = { _ in }
func makeCoordinator() -> Coordinator { Coordinator(self) }
@@ -2366,13 +2379,21 @@ private struct PlaybackSlider: NSViewRepresentable {
slider.minValue = range.lowerBound
slider.maxValue = range.upperBound
slider.setModelValue(value)
slider.isEnabled = enabled
if slider.isEnabled != enabled {
slider.isEnabled = enabled
if !enabled { slider.resetSegments() }
}
slider.editingChanged = onEditingChanged
if let cell = slider.cell as? Cell,
cell.bufferedRange != bufferedRange || cell.boundaries != boundaries {
cell.bufferedRange = bufferedRange
cell.boundaries = boundaries
slider.needsDisplay = true
slider.hoverChanged = onHover
if let cell = slider.cell as? Cell {
if cell.boundaries != boundaries {
cell.boundaries = boundaries
slider.resetSegments()
}
if cell.bufferedRange != bufferedRange {
cell.bufferedRange = bufferedRange
slider.needsDisplay = true
}
}
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
}
@@ -2391,6 +2412,95 @@ private struct PlaybackSlider: NSViewRepresentable {
final class Slider: NSSlider {
var editingChanged: (Bool) -> Void = { _ in }
private(set) var tracking = false
var hoverChanged: (Hover?) -> Void = { _ in }
private var hover: Hover?
private var hoverArea: NSTrackingArea?
private var segmentAnimation: Timer?
private var activeSegment: Int?
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let hoverArea { removeTrackingArea(hoverArea) }
let area = NSTrackingArea(rect: .zero,
options: [.mouseEnteredAndExited, .mouseMoved, .activeInKeyWindow, .inVisibleRect],
owner: self)
addTrackingArea(area)
hoverArea = area
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil { resetSegments() }
}
func resetSegments() {
segmentAnimation?.invalidate()
segmentAnimation = nil
activeSegment = nil
if let cell = cell as? Cell { cell.emphasis = Array(repeating: 0, count: cell.boundaries.count + 1) }
hover = nil
DispatchQueue.main.async { [weak self] in
guard let self, self.hover == nil else { return }
self.hoverChanged(nil)
}
needsDisplay = true
}
private func setHover(_ value: Hover?) {
guard hover != value else { return }
hover = value
hoverChanged(value)
}
private func updateHover(at point: NSPoint) {
guard isEnabled, bounds.contains(point), let cell = cell as? Cell, !cell.volume,
maxValue > minValue else { setHover(nil); return }
let bar = cell.barRect(flipped: isFlipped)
let start = bar.minX + cell.knobThickness / 2
let width = bar.width - cell.knobThickness
guard width > 0 else { setHover(nil); return }
let time = minValue + min(1, max(0, (point.x - start) / width)) * (maxValue - minValue)
let index = cell.segment(at: time)
let lower = index == 0 ? minValue : cell.boundaries[index - 1]
let upper = index == cell.boundaries.count ? maxValue : cell.boundaries[index]
let middle = (lower + upper) / 2
setHover(Hover(time: middle, x: start + (middle - minValue) / (maxValue - minValue) * width))
}
override func mouseEntered(with event: NSEvent) { updateHover(at: convert(event.locationInWindow, from: nil)) }
override func mouseMoved(with event: NSEvent) { updateHover(at: convert(event.locationInWindow, from: nil)) }
override func mouseExited(with event: NSEvent) { setHover(nil) }
private func emphasizeSegment(_ index: Int?) {
guard activeSegment != index, let cell = cell as? Cell else { return }
activeSegment = index
segmentAnimation?.invalidate()
let count = cell.boundaries.count + 1
if cell.emphasis.count != count { cell.emphasis = Array(repeating: 0, count: count) }
let initial = cell.emphasis
let update: @MainActor (Double) -> Void = { [weak self] progress in
for segment in 0..<count {
let target = segment == index ? 1.0 : 0.0
cell.emphasis[segment] = initial[segment] + (target - initial[segment]) * progress
}
self?.needsDisplay = true
}
guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { update(1); return }
let start = ContinuousClock.now
let timer = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] timer in
guard self != nil else { timer.invalidate(); return }
MainActor.assumeIsolated {
let progress = min(1, start.duration(to: .now) / .milliseconds(160))
update(progress * progress * (3 - 2 * progress))
if progress >= 1 {
self?.segmentAnimation?.invalidate()
self?.segmentAnimation = nil
}
}
}
segmentAnimation = timer
RunLoop.main.add(timer, forMode: .common)
}
func setModelValue(_ value: Double) { if !tracking { doubleValue = value } }
#if PLAYER_CHECK
static func feedbackCheck() -> Bool {
@@ -2412,6 +2522,8 @@ private struct PlaybackSlider: NSViewRepresentable {
editingChanged(true)
defer {
tracking = false
emphasizeSegment(nil)
updateHover(at: convert(window?.mouseLocationOutsideOfEventStream ?? .zero, from: nil))
editingChanged(false)
}
guard let cell = cell as? Cell, !cell.volume else {
@@ -2426,6 +2538,8 @@ private struct PlaybackSlider: NSViewRepresentable {
func seek(_ x: CGFloat) {
let fraction = min(1, max(0, (x - start) / width))
let value = minValue + fraction * (maxValue - minValue)
emphasizeSegment(cell.segment(at: value))
updateHover(at: NSPoint(x: x, y: point.y))
guard value != doubleValue else { return }
doubleValue = value
if let action { sendAction(action, to: target) }
@@ -2443,6 +2557,10 @@ private struct PlaybackSlider: NSViewRepresentable {
var volume = false
var bufferedRange: ClosedRange<Double>?
var boundaries: [Double] = []
var emphasis: [Double] = []
func segment(at time: Double) -> Int {
boundaries.firstIndex(where: { time < $0 }) ?? boundaries.count
}
#if PLAYER_CHECK
var renderedKnob = NSRect.zero
#endif
@@ -2468,14 +2586,15 @@ private struct PlaybackSlider: NSViewRepresentable {
let inset = min(1.5, (end - start) / 4)
let left = index == 0 ? start : start + inset
let right = index == boundaries.count ? end : end - inset
let height = 3 + 3 * (emphasis.indices.contains(index) ? emphasis[index] : 0)
track.append(NSBezierPath(roundedRect:
NSRect(x: left, y: bar.minY, width: right - left, height: bar.height),
NSRect(x: left, y: bar.midY - height / 2, width: right - left, height: height),
xRadius: 1.5, yRadius: 1.5))
}
track.addClip()
}
SunsetPalette.track.setFill()
NSBezierPath(roundedRect: bar, xRadius: 1.5, yRadius: 1.5).fill()
NSBezierPath(roundedRect: volume ? bar : bar.insetBy(dx: 0, dy: -1.5), xRadius: 1.5, yRadius: 1.5).fill()
if volume {
let fraction = (doubleValue - minValue) / (maxValue - minValue)
let fill = NSRect(x: bar.minX, y: bar.minY, width: bar.width * fraction, height: bar.height)
@@ -2485,8 +2604,8 @@ private struct PlaybackSlider: NSViewRepresentable {
let lower = min(maxValue, max(minValue, bufferedRange.lowerBound))
let upper = min(maxValue, max(minValue, bufferedRange.upperBound))
let scale = bar.width / (maxValue - minValue)
let fill = NSRect(x: bar.minX + (lower - minValue) * scale, y: bar.minY,
width: (upper - lower) * scale, height: bar.height)
let fill = NSRect(x: bar.minX + (lower - minValue) * scale, y: bar.minY - 1.5,
width: (upper - lower) * scale, height: 6)
SunsetPalette.bufferedTrack.setFill()
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
}
@@ -2523,6 +2642,33 @@ func playbackSliderHitCheck() -> Bool {
let left = cell.knobRect(flipped: slider.isFlipped).midX
slider.doubleValue = slider.maxValue
let right = cell.knobRect(flipped: slider.isFlipped).midX
var hovered: PlaybackSlider.Hover?
slider.hoverChanged = { hovered = $0 }
cell.boundaries = [25, 75]
cell.emphasis = [1, 0, 0]
let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 280, pixelsHigh: 14,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)!
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap)
NSGraphicsContext.current!.cgContext.clear(NSRect(x: 0, y: 0, width: 280, height: 14))
cell.drawBar(inside: NSRect(x: 0, y: 0, width: 280, height: 14), flipped: false)
NSGraphicsContext.restoreGraphicsState()
guard bitmap.colorAt(x: 35, y: 4)!.alphaComponent > 0,
bitmap.colorAt(x: 140, y: 4)!.alphaComponent == 0 else { return false }
cell.emphasis = []
for (time, middle) in [(0.0, 12.5), (25.0, 50.0), (75.0, 87.5), (100.0, 87.5)] {
let point = slider.convert(NSPoint(x: left + time / 100 * (right - left), y: 7), to: nil)
let event = NSEvent.mouseEvent(with: .mouseMoved, location: point, modifierFlags: [],
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 0, pressure: 0)!
slider.mouseMoved(with: event)
guard hovered?.time == middle,
abs(hovered!.x - (left + middle / 100 * (right - left))) < 0.5 else { return false }
}
let exit = NSEvent.enterExitEvent(with: .mouseExited, location: .zero, modifierFlags: [],
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, trackingNumber: 0, userData: nil)!
slider.mouseExited(with: exit)
guard hovered == nil else { return false }
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),
@@ -2735,6 +2881,7 @@ private struct PlayerContent: View {
@ObservedObject var model: PlayerModel
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ViewState private var showsRemainingTime = false
@ViewState private var hoveredSliderSegment: PlaybackSlider.Hover?
@ViewState private var dropTargeted = false
private var showsYouTubeInput: Bool { model.showsYouTubeInput }
@ViewState private var youTubeURL = ""
@@ -3163,10 +3310,28 @@ 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, boundaries: model.sliderBoundaries, onEditingChanged: { editing in
enabled: model.canSeek, bufferedRange: buffered, boundaries: model.sliderBoundaries,
onHover: { hoveredSliderSegment = $0 }, onEditingChanged: { editing in
if editing { model.beginSeeking() } else { model.finishSeeking() }
})
.frame(height: 14)
.overlay(alignment: .topLeading) {
if let hover = hoveredSliderSegment, let title = model.sliderSegmentTitle(at: hover.time) {
Text(title)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(SunsetPalette.text))
.lineLimit(1)
.padding(.horizontal, 8).padding(.vertical, 5)
.background { PlayerGlass(cornerRadius: 6) }
.fixedSize()
.position(x: hover.x, y: -16)
.transition(.opacity)
.id(hover.time)
.allowsHitTesting(false)
}
}
.animation(reduceMotion ? nil : .easeInOut(duration: 0.16), value: hoveredSliderSegment)
.allowsHitTesting(model.canSeek)
.accessibilityLabel("Playback Position")
.accessibilityValue(time(model.seekPosition) + (timelineStatus.isEmpty ? "" : ", " + timelineStatus))
.help(timelineStatus.isEmpty ? "Playback Position" : timelineStatus)