feat: animate hovered chapter titles and active seek segments
This commit is contained in:
+176
-11
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user