fix: match progress slider dragging to visible thumb

The 3-point progress thumb was drawn inside larger native knob bounds. Override those bounds for progress only so adjacent clicks seek; retain native volume geometry and tracking. Native click regression reproduced before the fix and passes afterward.
This commit is contained in:
2026-10-22 12:12:22 +02:00
parent 80c5f36479
commit dea8880b9b
3 changed files with 43 additions and 4 deletions
+2 -1
View File
@@ -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])
+1
View File
@@ -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/<opaque-id>/<slug>?ep=<episode>` 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.
+40 -3
View File
@@ -2005,6 +2005,11 @@ private struct PlaybackSlider: NSViewRepresentable {
var volume = false
var bufferedRange: ClosedRange<Double>?
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