diff --git a/Checks/cast-playback.swift b/Checks/cast-playback.swift index 5cf722c..1040900 100644 --- a/Checks/cast-playback.swift +++ b/Checks/cast-playback.swift @@ -201,8 +201,9 @@ struct CastPlaybackCheck { _ = NSApplication.shared NSApp.setActivationPolicy(.prohibited) require(castVolumeSliderFeedbackCheck(), "Native slider keeps user position during tracking and applies receiver volume when idle") + require(playbackSliderHitCheck(), "Progress slider seeks beside the visible thumb and grabs only the thumb") if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--slider-regression" { - print("PASS: native volume slider tracking preserves user input and idle receiver feedback") + print("PASS: progress thumb hit bounds, adjacent seeking, and volume tracking feedback") return } let source = URL(fileURLWithPath: CommandLine.arguments[1]) diff --git a/README.md b/README.md index 782bbba..09c0f0c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,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; **+** 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 a supported link. YouTube supports individual videos, not playlists or channels. Miruro links must use `https://barelystarted.miruro.tv/watch//?ep=` with an explicit positive episode number; arbitrary streaming sites are not supported. +- **Seeking:** click the progress track to seek, including immediately beside the visible thumb. Grab the thumb itself to drag without first jumping to the click position. - **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider, without visible labels; accessibility descriptions retain the interval names and times. Missing/invalid metadata leaves playback usable. Timings may differ between cuts. No automatic skipping is performed. - **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress. - **Compact mode:** press **P** for a floating player; double-click it to restore. Drag noninteractive areas to move the window. Use the titlebar pin to keep the normal window on top. diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 45afad1..032aeb0 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -2005,6 +2005,11 @@ private struct PlaybackSlider: NSViewRepresentable { var volume = false var bufferedRange: ClosedRange? var intervals: [MiruroInterval] = [] + override func knobRect(flipped: Bool) -> NSRect { + let rect = super.knobRect(flipped: flipped) + guard !volume else { return rect } + return NSRect(x: rect.midX - 1.5, y: rect.midY - 7.5, width: 3, height: 15) + } override func drawBar(inside rect: NSRect, flipped: Bool) { let bar = NSRect(x: rect.minX + knobThickness / 2, y: rect.midY - 1.5, width: max(0, rect.width - knobThickness), height: 3) @@ -2046,9 +2051,9 @@ private struct PlaybackSlider: NSViewRepresentable { } } override func drawKnob(_ rect: NSRect) { - let width: CGFloat = volume ? 16 : 3 - let height: CGFloat = volume ? 12 : 15 - let knob = NSRect(x: rect.midX - width / 2, y: rect.midY - height / 2, width: width, height: height) + let knob = volume + ? NSRect(x: rect.midX - 8, y: rect.midY - 6, width: 16, height: 12) + : rect let color = isEnabled ? (volume ? SunsetPalette.text : SunsetPalette.secondaryText) : SunsetPalette.disabledThumb color.setFill() @@ -2060,6 +2065,38 @@ private struct PlaybackSlider: NSViewRepresentable { #if PLAYER_CHECK @MainActor func castVolumeSliderFeedbackCheck() -> Bool { PlaybackSlider.Slider.feedbackCheck() } +@MainActor +func playbackSliderHitCheck() -> Bool { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 320, height: 80), + styleMask: [.borderless], backing: .buffered, defer: false) + let slider = PlaybackSlider.Slider(frame: NSRect(x: 20, y: 20, width: 280, height: 24)) + slider.cell = PlaybackSlider.Cell() + slider.minValue = 0 + slider.maxValue = 100 + slider.isContinuous = true + window.contentView!.addSubview(slider) + window.display() + for offset: CGFloat in [-4, -2, -1, 0, 1, 2, 4] { + slider.doubleValue = 50 + let cell = slider.cell as! NSSliderCell + let knob = cell.knobRect(flipped: slider.isFlipped) + let point = slider.convert(NSPoint(x: knob.midX + offset, y: knob.midY), to: nil) + let up = NSEvent.mouseEvent(with: .leftMouseUp, location: point, modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, + eventNumber: 1, clickCount: 1, pressure: 0)! + let down = NSEvent.mouseEvent(with: .leftMouseDown, location: point, modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, + eventNumber: 0, clickCount: 1, pressure: 1)! + NSApp.postEvent(up, atStart: true) + slider.mouseDown(with: down) + if abs(offset) <= 1 { + guard slider.doubleValue == 50 else { return false } + } else { + guard (slider.doubleValue - 50) * offset > 0 else { return false } + } + } + return true +} #endif @MainActor