diff --git a/Checks/resolved-playback.swift b/Checks/resolved-playback.swift index f1bf001..102d06c 100644 --- a/Checks/resolved-playback.swift +++ b/Checks/resolved-playback.swift @@ -163,6 +163,10 @@ struct ResolvedPlaybackCheck { !model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2 } 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.setPlayback(playing: false), "pause before saving chapter playback") try await until("chapter playback paused", model: model) { model.state.status == PLAYER_PAUSED } diff --git a/Checks/youtube-resolver.swift b/Checks/youtube-resolver.swift index 76c7a80..3bff943 100644 --- a/Checks/youtube-resolver.swift +++ b/Checks/youtube-resolver.swift @@ -193,6 +193,15 @@ struct YouTubeResolverChecks { "missing, null and malformed optional titles do not discard chapters") try require(decoded.chapterBoundaries(duration: 12) == [2.25, 8.75], "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["chapters"] = [["start_time": 0, "end_time": 2.25], diff --git a/README.md b/README.md index c0c59c6..3374ce0 100644 --- a/README.md +++ b/README.md @@ -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. - **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//?ep=` 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. -- **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. - **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. diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 0477e4f..3a64e43 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -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 = [] private let skipClock: () -> Double @@ -2346,6 +2354,11 @@ private struct PlaybackSlider: NSViewRepresentable { var enabled = true var bufferedRange: ClosedRange? 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..= 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? 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) diff --git a/Sources/App/YouTubeResolver.swift b/Sources/App/YouTubeResolver.swift index 785b824..c4578fd 100644 --- a/Sources/App/YouTubeResolver.swift +++ b/Sources/App/YouTubeResolver.swift @@ -35,6 +35,14 @@ struct YouTubeVideo: Sendable { } 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 {