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
+4
View File
@@ -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 }
+9
View File
@@ -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],
+1 -1
View File
@@ -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
View File
@@ -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)
+8
View File
@@ -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 {