feat: animate hovered chapter titles and active seek segments
This commit is contained in:
@@ -163,6 +163,10 @@ struct ResolvedPlaybackCheck {
|
|||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
|
||||||
}
|
}
|
||||||
requireYouTubeChapters(model, expected, "direct openStream")
|
requireYouTubeChapters(model, expected, "direct openStream")
|
||||||
|
require(model.sliderSegmentTitle(at: 0.5) == (url == chaptered ? "Opening" : nil) &&
|
||||||
|
model.sliderSegmentTitle(at: 1.25) == (url == chaptered ? "Ending" : nil) &&
|
||||||
|
model.sliderSegmentTitle(at: 2.5) == nil,
|
||||||
|
"hover names match the chapter under the pointer, with no invented gap names")
|
||||||
require(model.state.duration < 500, "native duration rejects chapter edges beyond measured media")
|
require(model.state.duration < 500, "native duration rejects chapter edges beyond measured media")
|
||||||
require(model.setPlayback(playing: false), "pause before saving chapter playback")
|
require(model.setPlayback(playing: false), "pause before saving chapter playback")
|
||||||
try await until("chapter playback paused", model: model) { model.state.status == PLAYER_PAUSED }
|
try await until("chapter playback paused", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
|
|||||||
@@ -193,6 +193,15 @@ struct YouTubeResolverChecks {
|
|||||||
"missing, null and malformed optional titles do not discard chapters")
|
"missing, null and malformed optional titles do not discard chapters")
|
||||||
try require(decoded.chapterBoundaries(duration: 12) == [2.25, 8.75],
|
try require(decoded.chapterBoundaries(duration: 12) == [2.25, 8.75],
|
||||||
"chapter edges are sorted, deduplicated and strictly interior")
|
"chapter edges are sorted, deduplicated and strictly interior")
|
||||||
|
try require(decoded.chapterTitle(at: 0, duration: 12) == "Opening" &&
|
||||||
|
decoded.chapterTitle(at: 2.249, duration: 12) == "Opening" &&
|
||||||
|
decoded.chapterTitle(at: 2.25, duration: 12) == nil &&
|
||||||
|
decoded.chapterTitle(at: 12, duration: 12) == nil,
|
||||||
|
"hover titles include chapter start, exclude its end and never invent missing titles")
|
||||||
|
for duration in [0, -1, 1, Double.nan, .infinity] {
|
||||||
|
try require(decoded.chapterTitle(at: 0.5, duration: duration) == nil,
|
||||||
|
"unknown durations and chapters extending beyond media have no hover title")
|
||||||
|
}
|
||||||
|
|
||||||
object["duration"] = 0
|
object["duration"] = 0
|
||||||
object["chapters"] = [["start_time": 0, "end_time": 2.25],
|
object["chapters"] = [["start_time": 0, "end_time": 2.25],
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self
|
|||||||
- **Local files:** use **⌘O**, drag files onto the player, or choose Finder's **Open With**. Multiple files form a queue in natural filename order, with automatic advance. Use **View → Show Queue** to play, reorder or remove items; **+ → file icon** or dropping files onto the sidebar appends them. The queue lasts only for the session; opening other media replaces it.
|
- **Local files:** use **⌘O**, drag files onto the player, or choose Finder's **Open With**. Multiple files form a queue in natural filename order, with automatic advance. Use **View → Show Queue** to play, reorder or remove items; **+ → file icon** or dropping files onto the sidebar appends them. The queue lasts only for the session; opening other media replaces it.
|
||||||
- **Streams:** use **Open Stream…** (**⇧⌘O**) or drop an HTTP(S) video link. To append without replacing playback, press the sidebar's **+**, then the **link icon**: the URL field opens upwards; **Return** fetches and adds the video. The link icon toggles the field; **Esc** dismisses it and cancels a pending fetch. Fetch errors leave the queue unchanged. Links are passed to the bundled yt-dlp without a site allowlist; extraction must return a single streamable video, not a playlist or channel. Miruro keeps its dedicated resolver: links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number.
|
- **Streams:** use **Open Stream…** (**⇧⌘O**) or drop an HTTP(S) video link. To append without replacing playback, press the sidebar's **+**, then the **link icon**: the URL field opens upwards; **Return** fetches and adds the video. The link icon toggles the field; **Esc** dismisses it and cancels a pending fetch. Fetch errors leave the queue unchanged. Links are passed to the bundled yt-dlp without a site allowlist; extraction must return a single streamable video, not a playlist or channel. Miruro keeps its dedicated resolver: links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number.
|
||||||
- **Stream queues:** integrations can supply an ordered yt-dlp/Miruro queue with `--new-instance --queue-file /absolute/queue.json`, or append without interrupting the running player using `--enqueue --queue-file /absolute/queue.json` (also for a single item). Enqueue starts playback if the player is empty or not running. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Unresolved titles, including the selected row, load serially at lower priority without waiting for playback loading, buffering or quality extraction. YouTube uses a title-only oEmbed request; other sites use yt-dlp metadata and Miruro uses optional episode metadata. Arrival still depends on the site/network; failed lookups retain the supplied label, and full playback metadata takes precedence. Playback sources still resolve fresh when selected or revisited; local-file batches still use natural filename order. yt-dlp items use the original page URL for resume and version-1 progress reports; recognized YouTube video URLs still canonicalize.
|
- **Stream queues:** integrations can supply an ordered yt-dlp/Miruro queue with `--new-instance --queue-file /absolute/queue.json`, or append without interrupting the running player using `--enqueue --queue-file /absolute/queue.json` (also for a single item). Enqueue starts playback if the player is empty or not running. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Unresolved titles, including the selected row, load serially at lower priority without waiting for playback loading, buffering or quality extraction. YouTube uses a title-only oEmbed request; other sites use yt-dlp metadata and Miruro uses optional episode metadata. Arrival still depends on the site/network; failed lookups retain the supplied label, and full playback metadata takes precedence. Playback sources still resolve fresh when selected or revisited; local-file batches still use natural filename order. yt-dlp items use the original page URL for resume and version-1 progress reports; recognized YouTube video URLs still canonicalize.
|
||||||
- **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints.
|
- **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints. While mouse-seeking, the segment under the thumb smoothly thickens and returns to normal on release. Hovering a named segment fades its title in above the segment's center. Reduce Motion disables these transitions.
|
||||||
- **YouTube chapters:** when yt-dlp provides usable chapters, small rounded gaps mark their boundaries in the playback track. Boundaries use the current player's duration; missing or malformed chapter data leaves ordinary playback unchanged. Chapters do not add opening/ending skip buttons. Availability varies, and description-derived timestamps may differ from YouTube's displayed chapters.
|
- **YouTube chapters:** when yt-dlp provides usable chapters, small rounded gaps mark their boundaries in the playback track. Boundaries use the current player's duration; missing or malformed chapter data leaves ordinary playback unchanged. Chapters do not add opening/ending skip buttons. Availability varies, and description-derived timestamps may differ from YouTube's displayed chapters.
|
||||||
- **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. A light background fill grows as the offer expires, with a short entrance/exit transition; Reduce Motion uses stepped countdown updates without the transition. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Nothing skips automatically.
|
- **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. A light background fill grows as the offer expires, with a short entrance/exit transition; Reduce Motion uses stepped countdown updates without the transition. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Nothing skips automatically.
|
||||||
- **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
|
- **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
|
||||||
|
|||||||
+173
-8
@@ -85,6 +85,14 @@ final class PlayerModel: ObservableObject {
|
|||||||
return Array(Set(visibleIntervals.flatMap { [$0.start, $0.end] }
|
return Array(Set(visibleIntervals.flatMap { [$0.start, $0.end] }
|
||||||
.filter { $0 > 0 && $0 < state.duration })).sorted()
|
.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)?
|
@Published private var skipOffer: (interval: MiruroInterval, deadline: Double)?
|
||||||
private var offeredSegments: Set<MiruroInterval.Kind> = []
|
private var offeredSegments: Set<MiruroInterval.Kind> = []
|
||||||
private let skipClock: () -> Double
|
private let skipClock: () -> Double
|
||||||
@@ -2346,6 +2354,11 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
var enabled = true
|
var enabled = true
|
||||||
var bufferedRange: ClosedRange<Double>?
|
var bufferedRange: ClosedRange<Double>?
|
||||||
var boundaries: [Double] = []
|
var boundaries: [Double] = []
|
||||||
|
struct Hover: Equatable {
|
||||||
|
let time: Double
|
||||||
|
let x: CGFloat
|
||||||
|
}
|
||||||
|
var onHover: (Hover?) -> Void = { _ in }
|
||||||
var onEditingChanged: (Bool) -> Void = { _ in }
|
var onEditingChanged: (Bool) -> Void = { _ in }
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||||
@@ -2366,14 +2379,22 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
slider.minValue = range.lowerBound
|
slider.minValue = range.lowerBound
|
||||||
slider.maxValue = range.upperBound
|
slider.maxValue = range.upperBound
|
||||||
slider.setModelValue(value)
|
slider.setModelValue(value)
|
||||||
|
if slider.isEnabled != enabled {
|
||||||
slider.isEnabled = enabled
|
slider.isEnabled = enabled
|
||||||
|
if !enabled { slider.resetSegments() }
|
||||||
|
}
|
||||||
slider.editingChanged = onEditingChanged
|
slider.editingChanged = onEditingChanged
|
||||||
if let cell = slider.cell as? Cell,
|
slider.hoverChanged = onHover
|
||||||
cell.bufferedRange != bufferedRange || cell.boundaries != boundaries {
|
if let cell = slider.cell as? Cell {
|
||||||
cell.bufferedRange = bufferedRange
|
if cell.boundaries != boundaries {
|
||||||
cell.boundaries = boundaries
|
cell.boundaries = boundaries
|
||||||
|
slider.resetSegments()
|
||||||
|
}
|
||||||
|
if cell.bufferedRange != bufferedRange {
|
||||||
|
cell.bufferedRange = bufferedRange
|
||||||
slider.needsDisplay = true
|
slider.needsDisplay = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
|
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2391,6 +2412,95 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
final class Slider: NSSlider {
|
final class Slider: NSSlider {
|
||||||
var editingChanged: (Bool) -> Void = { _ in }
|
var editingChanged: (Bool) -> Void = { _ in }
|
||||||
private(set) var tracking = false
|
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 } }
|
func setModelValue(_ value: Double) { if !tracking { doubleValue = value } }
|
||||||
#if PLAYER_CHECK
|
#if PLAYER_CHECK
|
||||||
static func feedbackCheck() -> Bool {
|
static func feedbackCheck() -> Bool {
|
||||||
@@ -2412,6 +2522,8 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
editingChanged(true)
|
editingChanged(true)
|
||||||
defer {
|
defer {
|
||||||
tracking = false
|
tracking = false
|
||||||
|
emphasizeSegment(nil)
|
||||||
|
updateHover(at: convert(window?.mouseLocationOutsideOfEventStream ?? .zero, from: nil))
|
||||||
editingChanged(false)
|
editingChanged(false)
|
||||||
}
|
}
|
||||||
guard let cell = cell as? Cell, !cell.volume else {
|
guard let cell = cell as? Cell, !cell.volume else {
|
||||||
@@ -2426,6 +2538,8 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
func seek(_ x: CGFloat) {
|
func seek(_ x: CGFloat) {
|
||||||
let fraction = min(1, max(0, (x - start) / width))
|
let fraction = min(1, max(0, (x - start) / width))
|
||||||
let value = minValue + fraction * (maxValue - minValue)
|
let value = minValue + fraction * (maxValue - minValue)
|
||||||
|
emphasizeSegment(cell.segment(at: value))
|
||||||
|
updateHover(at: NSPoint(x: x, y: point.y))
|
||||||
guard value != doubleValue else { return }
|
guard value != doubleValue else { return }
|
||||||
doubleValue = value
|
doubleValue = value
|
||||||
if let action { sendAction(action, to: target) }
|
if let action { sendAction(action, to: target) }
|
||||||
@@ -2443,6 +2557,10 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
var volume = false
|
var volume = false
|
||||||
var bufferedRange: ClosedRange<Double>?
|
var bufferedRange: ClosedRange<Double>?
|
||||||
var boundaries: [Double] = []
|
var boundaries: [Double] = []
|
||||||
|
var emphasis: [Double] = []
|
||||||
|
func segment(at time: Double) -> Int {
|
||||||
|
boundaries.firstIndex(where: { time < $0 }) ?? boundaries.count
|
||||||
|
}
|
||||||
#if PLAYER_CHECK
|
#if PLAYER_CHECK
|
||||||
var renderedKnob = NSRect.zero
|
var renderedKnob = NSRect.zero
|
||||||
#endif
|
#endif
|
||||||
@@ -2468,14 +2586,15 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
let inset = min(1.5, (end - start) / 4)
|
let inset = min(1.5, (end - start) / 4)
|
||||||
let left = index == 0 ? start : start + inset
|
let left = index == 0 ? start : start + inset
|
||||||
let right = index == boundaries.count ? end : end - inset
|
let right = index == boundaries.count ? end : end - inset
|
||||||
|
let height = 3 + 3 * (emphasis.indices.contains(index) ? emphasis[index] : 0)
|
||||||
track.append(NSBezierPath(roundedRect:
|
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))
|
xRadius: 1.5, yRadius: 1.5))
|
||||||
}
|
}
|
||||||
track.addClip()
|
track.addClip()
|
||||||
}
|
}
|
||||||
SunsetPalette.track.setFill()
|
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 {
|
if volume {
|
||||||
let fraction = (doubleValue - minValue) / (maxValue - minValue)
|
let fraction = (doubleValue - minValue) / (maxValue - minValue)
|
||||||
let fill = NSRect(x: bar.minX, y: bar.minY, width: bar.width * fraction, height: bar.height)
|
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 lower = min(maxValue, max(minValue, bufferedRange.lowerBound))
|
||||||
let upper = min(maxValue, max(minValue, bufferedRange.upperBound))
|
let upper = min(maxValue, max(minValue, bufferedRange.upperBound))
|
||||||
let scale = bar.width / (maxValue - minValue)
|
let scale = bar.width / (maxValue - minValue)
|
||||||
let fill = NSRect(x: bar.minX + (lower - minValue) * scale, y: bar.minY,
|
let fill = NSRect(x: bar.minX + (lower - minValue) * scale, y: bar.minY - 1.5,
|
||||||
width: (upper - lower) * scale, height: bar.height)
|
width: (upper - lower) * scale, height: 6)
|
||||||
SunsetPalette.bufferedTrack.setFill()
|
SunsetPalette.bufferedTrack.setFill()
|
||||||
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
|
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
|
let left = cell.knobRect(flipped: slider.isFlipped).midX
|
||||||
slider.doubleValue = slider.maxValue
|
slider.doubleValue = slider.maxValue
|
||||||
let right = cell.knobRect(flipped: slider.isFlipped).midX
|
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 [
|
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),
|
(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, -1, -20), (80, 1, 20), (20, -4, -20), (80, 4, 20),
|
||||||
@@ -2735,6 +2881,7 @@ private struct PlayerContent: View {
|
|||||||
@ObservedObject var model: PlayerModel
|
@ObservedObject var model: PlayerModel
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
@ViewState private var showsRemainingTime = false
|
@ViewState private var showsRemainingTime = false
|
||||||
|
@ViewState private var hoveredSliderSegment: PlaybackSlider.Hover?
|
||||||
@ViewState private var dropTargeted = false
|
@ViewState private var dropTargeted = false
|
||||||
private var showsYouTubeInput: Bool { model.showsYouTubeInput }
|
private var showsYouTubeInput: Bool { model.showsYouTubeInput }
|
||||||
@ViewState private var youTubeURL = ""
|
@ViewState private var youTubeURL = ""
|
||||||
@@ -3163,10 +3310,28 @@ private struct PlayerContent: View {
|
|||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing)
|
Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing)
|
||||||
PlaybackSlider(value: $model.seekPosition, range: 0...max(model.duration, 0.001),
|
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() }
|
if editing { model.beginSeeking() } else { model.finishSeeking() }
|
||||||
})
|
})
|
||||||
.frame(height: 14)
|
.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")
|
.accessibilityLabel("Playback Position")
|
||||||
.accessibilityValue(time(model.seekPosition) + (timelineStatus.isEmpty ? "" : ", " + timelineStatus))
|
.accessibilityValue(time(model.seekPosition) + (timelineStatus.isEmpty ? "" : ", " + timelineStatus))
|
||||||
.help(timelineStatus.isEmpty ? "Playback Position" : timelineStatus)
|
.help(timelineStatus.isEmpty ? "Playback Position" : timelineStatus)
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ struct YouTubeVideo: Sendable {
|
|||||||
}
|
}
|
||||||
return boundaries.sorted()
|
return boundaries.sorted()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func chapterTitle(at time: Double, duration: Double) -> String? {
|
||||||
|
guard duration.isFinite, duration > 0 else { return nil }
|
||||||
|
let title = chapters.first {
|
||||||
|
$0.start >= 0 && $0.end <= duration && $0.start <= time && time < $0.end
|
||||||
|
}?.title?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return title?.isEmpty == false ? title : nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct YouTubeResolutionError: LocalizedError {
|
private struct YouTubeResolutionError: LocalizedError {
|
||||||
|
|||||||
Reference in New Issue
Block a user