diff --git a/Checks/cast-playback.swift b/Checks/cast-playback.swift index 1040900..33319ea 100644 --- a/Checks/cast-playback.swift +++ b/Checks/cast-playback.swift @@ -201,9 +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") + require(playbackSliderHitCheck(), "Progress slider seeks beside the thumb and preserves cursor alignment throughout dragging") if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--slider-regression" { - print("PASS: progress thumb hit bounds, adjacent seeking, and volume tracking feedback") + print("PASS: adjacent seeking, thumb and track drag alignment, and volume tracking feedback") return } let source = URL(fileURLWithPath: CommandLine.arguments[1]) diff --git a/README.md b/README.md index 09c0f0c..15b1288 100644 --- a/README.md +++ b/README.md @@ -23,7 +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. +- **Seeking:** click the progress track to seek, including immediately beside the visible thumb. Grabbing the thumb preserves the cursor's position within it throughout the drag; dragging from the track first seeks to the cursor. - **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 032aeb0..1f62eb4 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -1995,9 +1995,35 @@ private struct PlaybackSlider: NSViewRepresentable { guard isEnabled else { return } tracking = true editingChanged(true) - super.mouseDown(with: event) - tracking = false - editingChanged(false) + defer { + tracking = false + editingChanged(false) + } + guard let cell = cell as? Cell, !cell.volume else { + super.mouseDown(with: event) + return + } + let bar = cell.barRect(flipped: isFlipped) + let start = bar.minX + cell.knobThickness / 2 + let width = bar.width - cell.knobThickness + guard width > 0, maxValue > minValue else { return } + let point = convert(event.locationInWindow, from: nil) + let knob = cell.visibleKnobRect(cell.knobRect(flipped: isFlipped)) + let grabbed = NSBezierPath(roundedRect: knob, xRadius: 1, yRadius: 1).contains(point) + let offset = grabbed ? point.x - knob.midX : 0 + func seek(_ x: CGFloat) { + let fraction = min(1, max(0, (x - offset - start) / width)) + let value = minValue + fraction * (maxValue - minValue) + guard value != doubleValue else { return } + doubleValue = value + if let action { sendAction(action, to: target) } + } + if !grabbed { seek(point.x) } + while let next = window?.nextEvent(matching: [.leftMouseDragged, .leftMouseUp]) { + let nextPoint = convert(next.locationInWindow, from: nil) + if next.type == .leftMouseDragged || nextPoint.x != point.x { seek(nextPoint.x) } + if next.type == .leftMouseUp { break } + } } } @@ -2005,10 +2031,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) + func visibleKnobRect(_ rect: NSRect) -> NSRect { + let width: CGFloat = volume ? 16 : 3 + let height: CGFloat = volume ? 12 : 15 + return NSRect(x: rect.midX - width / 2, y: rect.midY - height / 2, + width: width, height: height) } override func drawBar(inside rect: NSRect, flipped: Bool) { let bar = NSRect(x: rect.minX + knobThickness / 2, y: rect.midY - 1.5, @@ -2051,9 +2078,7 @@ private struct PlaybackSlider: NSViewRepresentable { } } override func drawKnob(_ rect: NSRect) { - let knob = volume - ? NSRect(x: rect.midX - 8, y: rect.midY - 6, width: 16, height: 12) - : rect + let knob = visibleKnobRect(rect) let color = isEnabled ? (volume ? SunsetPalette.text : SunsetPalette.secondaryText) : SunsetPalette.disabledThumb color.setFill() @@ -2076,23 +2101,40 @@ func playbackSliderHitCheck() -> Bool { 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 cell = slider.cell as! NSSliderCell + slider.doubleValue = slider.minValue + let left = cell.knobRect(flipped: slider.isFlipped).midX + slider.doubleValue = slider.maxValue + let right = cell.knobRect(flipped: slider.isFlipped).midX + 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), + (20, 0, -500), (80, 0, 500) + ] { + slider.doubleValue = initial 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: [], + let end = NSPoint(x: point.x + movement, y: point.y) + let up = NSEvent.mouseEvent(with: .leftMouseUp, location: end, 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) + if movement != 0 { + let drag = NSEvent.mouseEvent(with: .leftMouseDragged, location: end, modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, + eventNumber: 1, clickCount: 1, pressure: 1)! + NSApp.postEvent(drag, 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 } + let movedKnob = cell.knobRect(flipped: slider.isFlipped) + let expectedMovement = movement + (abs(offset) <= 1 ? 0 : offset) + let expectedX = min(right, max(left, knob.midX + expectedMovement)) + guard abs(movedKnob.midX - expectedX) < 0.5 else { return false } + if abs(offset) <= 1 && movement == 0 { + guard slider.doubleValue == initial else { return false } } } return true