fix: preserve native thumb geometry while seeking beside it
Remove the knobRect override: native slider geometry also affects drag positioning. Progress pointer tracking now shares the drawn thumb bounds, preserves the initial grab offset, and clamps to the track endpoints. Expanded native event regression covers adjacent clicks, thumb drags, track drags, and endpoint clamping; application build passes.
This commit is contained in:
@@ -201,9 +201,9 @@ struct CastPlaybackCheck {
|
|||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
NSApp.setActivationPolicy(.prohibited)
|
NSApp.setActivationPolicy(.prohibited)
|
||||||
require(castVolumeSliderFeedbackCheck(), "Native slider keeps user position during tracking and applies receiver volume when idle")
|
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" {
|
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
|
return
|
||||||
}
|
}
|
||||||
let source = URL(fileURLWithPath: CommandLine.arguments[1])
|
let source = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
+57
-15
@@ -1995,20 +1995,47 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
guard isEnabled else { return }
|
guard isEnabled else { return }
|
||||||
tracking = true
|
tracking = true
|
||||||
editingChanged(true)
|
editingChanged(true)
|
||||||
super.mouseDown(with: event)
|
defer {
|
||||||
tracking = false
|
tracking = false
|
||||||
editingChanged(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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class Cell: NSSliderCell {
|
final class Cell: NSSliderCell {
|
||||||
var volume = false
|
var volume = false
|
||||||
var bufferedRange: ClosedRange<Double>?
|
var bufferedRange: ClosedRange<Double>?
|
||||||
var intervals: [MiruroInterval] = []
|
var intervals: [MiruroInterval] = []
|
||||||
override func knobRect(flipped: Bool) -> NSRect {
|
func visibleKnobRect(_ rect: NSRect) -> NSRect {
|
||||||
let rect = super.knobRect(flipped: flipped)
|
let width: CGFloat = volume ? 16 : 3
|
||||||
guard !volume else { return rect }
|
let height: CGFloat = volume ? 12 : 15
|
||||||
return NSRect(x: rect.midX - 1.5, y: rect.midY - 7.5, width: 3, height: 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) {
|
override func drawBar(inside rect: NSRect, flipped: Bool) {
|
||||||
let bar = NSRect(x: rect.minX + knobThickness / 2, y: rect.midY - 1.5,
|
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) {
|
override func drawKnob(_ rect: NSRect) {
|
||||||
let knob = volume
|
let knob = visibleKnobRect(rect)
|
||||||
? NSRect(x: rect.midX - 8, y: rect.midY - 6, width: 16, height: 12)
|
|
||||||
: rect
|
|
||||||
let color = isEnabled ? (volume ? SunsetPalette.text : SunsetPalette.secondaryText)
|
let color = isEnabled ? (volume ? SunsetPalette.text : SunsetPalette.secondaryText)
|
||||||
: SunsetPalette.disabledThumb
|
: SunsetPalette.disabledThumb
|
||||||
color.setFill()
|
color.setFill()
|
||||||
@@ -2076,23 +2101,40 @@ func playbackSliderHitCheck() -> Bool {
|
|||||||
slider.isContinuous = true
|
slider.isContinuous = true
|
||||||
window.contentView!.addSubview(slider)
|
window.contentView!.addSubview(slider)
|
||||||
window.display()
|
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 knob = cell.knobRect(flipped: slider.isFlipped)
|
||||||
let point = slider.convert(NSPoint(x: knob.midX + offset, y: knob.midY), to: nil)
|
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,
|
timestamp: 0, windowNumber: window.windowNumber, context: nil,
|
||||||
eventNumber: 1, clickCount: 1, pressure: 0)!
|
eventNumber: 1, clickCount: 1, pressure: 0)!
|
||||||
let down = NSEvent.mouseEvent(with: .leftMouseDown, location: point, modifierFlags: [],
|
let down = NSEvent.mouseEvent(with: .leftMouseDown, location: point, modifierFlags: [],
|
||||||
timestamp: 0, windowNumber: window.windowNumber, context: nil,
|
timestamp: 0, windowNumber: window.windowNumber, context: nil,
|
||||||
eventNumber: 0, clickCount: 1, pressure: 1)!
|
eventNumber: 0, clickCount: 1, pressure: 1)!
|
||||||
NSApp.postEvent(up, atStart: true)
|
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)
|
slider.mouseDown(with: down)
|
||||||
if abs(offset) <= 1 {
|
let movedKnob = cell.knobRect(flipped: slider.isFlipped)
|
||||||
guard slider.doubleValue == 50 else { return false }
|
let expectedMovement = movement + (abs(offset) <= 1 ? 0 : offset)
|
||||||
} else {
|
let expectedX = min(right, max(left, knob.midX + expectedMovement))
|
||||||
guard (slider.doubleValue - 50) * offset > 0 else { return false }
|
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
|
return true
|
||||||
|
|||||||
Reference in New Issue
Block a user