fix: keep playlist reordering active while dragging

This commit is contained in:
2026-10-18 22:21:42 +02:00
parent 2e5255e194
commit ef3aae842d
5 changed files with 314 additions and 17 deletions
+20 -3
View File
@@ -36,6 +36,21 @@ class StartupStreamingServer(StreamingServer):
super()._get(request)
def check_queue_drag(root, app, fixtures):
result_path = root / "queue-drag.json"
diagnostics = root / "queue-drag.log"
result = subprocess.run(
["open", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app),
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-queue-drag", str(result_path)],
capture_output=True, text=True, timeout=30,
)
report = json.loads(result_path.read_text()) if result_path.exists() else {}
if result.returncode != 0 or not report.get("passed"):
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
raise RuntimeError(f"native queue drag failed: {report}; {result.stdout}{result.stderr}{log}")
print("PASS native queue drag: slide down/up, hold without pointer events, release to nearest slot, current item preserved")
def check_window_layout(root, app, fixtures):
result_path = root / "window-layout.json"
diagnostics = root / "window-layout.log"
@@ -656,9 +671,11 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier:
raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}")
print("PASS packaged bootstrap: registered app PID/identity, in-process player load, actual playback and Now Playing")
check_window_layout(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_LAYOUT_ONLY") != "1":
check_cli_instances(root, app, fixtures)
check_queue_drag(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_QUEUE_ONLY") != "1":
check_window_layout(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_LAYOUT_ONLY") != "1":
check_cli_instances(root, app, fixtures)
finally:
os.close(descriptor)
subprocess.run(
+115
View File
@@ -5,6 +5,10 @@ import MediaPlayer
@_cdecl("terebi_app_main")
@MainActor
public func checkAppStartup() {
if let index = CommandLine.arguments.firstIndex(of: "--check-queue-drag") {
QueueDragCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
return
}
if let index = CommandLine.arguments.firstIndex(of: "--check-window-layout") {
WindowLayoutCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
return
@@ -16,6 +20,117 @@ public func checkAppStartup() {
AppStartupCheck.run()
}
@MainActor
private enum QueueDragCheck {
static var timer: Timer?
static var step = 0
static var resultURL: URL!
static var original: [UUID] = []
static var current: UUID?
static var start = NSPoint.zero
static var end = NSPoint.zero
static var previousPoint = NSPoint.zero
// Deadline only; ordering assertions do not depend on the wall clock.
static let deadline = ContinuousClock.now.advanced(by: .seconds(20))
static func run(result: URL) {
resultURL = result
_ = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp]) {
NSLog("[DEBUG-queue-drag] received %@", String(describing: $0))
return $0
}
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
MainActor.assumeIsolated { advance() }
}
terebiAppMain()
}
static func finish(_ error: String? = nil) {
timer?.invalidate()
try! JSONSerialization.data(withJSONObject: ["passed": error == nil, "error": error ?? ""])
.write(to: resultURL, options: .atomic)
PlayerModel.shared.shutdown()
NSApp.terminate(nil)
}
static func mouse(_ type: NSEvent.EventType, at point: NSPoint, in window: NSWindow) {
let event = NSEvent.mouseEvent(with: type, location: point, modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil,
eventNumber: step, clickCount: 1, pressure: type == .leftMouseUp ? 0 : 1)!
let native = event.cgEvent!
if type == .leftMouseDragged {
native.setDoubleValueField(.mouseEventDeltaX, value: point.x - previousPoint.x)
native.setDoubleValueField(.mouseEventDeltaY, value: previousPoint.y - point.y)
}
previousPoint = point
NSApp.postEvent(NSEvent(cgEvent: native)!, atStart: false)
}
static func advance() {
let model = PlayerModel.shared
guard ContinuousClock.now < deadline else { finish("Queue drag did not complete at step \(step)"); return }
guard model.canTogglePlayback,
let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }),
let content = window.contentView else { return }
if step == 0 {
model.setVolume(0)
model.addToQueue(["B", "C"].map { URL(fileURLWithPath: "/queue-drag-check/\($0).mp4") })
current = model.queue.currentID
original = model.queue.items.map(\.id)
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
} else if step == 1 {
guard !model.isQueueTransitioning else { return }
guard NSApp.isActive && window.isKeyWindow else {
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
return
}
window.layoutIfNeeded()
if let bitmap = content.bitmapImageRepForCachingDisplay(in: content.bounds) {
content.cacheDisplay(in: content.bounds, to: bitmap)
try! bitmap.representation(using: .png, properties: [:])!.write(to: URL(fileURLWithPath: "/tmp/terebi-queue-debug.png"))
}
let top = content.isFlipped ? content.bounds.minY : content.bounds.maxY
let sign: CGFloat = content.isFlipped ? 1 : -1
start = content.convert(NSPoint(x: content.bounds.maxX - 100, y: top + sign * 82), to: nil)
end = NSPoint(x: start.x, y: start.y - 120)
NSLog("[DEBUG-queue-drag] start %@ end %@ hit %@", String(describing: start), String(describing: end),
String(describing: content.hitTest(content.convert(start, from: nil))))
mouse(.leftMouseDown, at: start, in: window)
} else if step == 2 {
mouse(.leftMouseDragged, at: NSPoint(x: start.x, y: start.y - 10), in: window)
} else if step == 3 {
mouse(.leftMouseDragged, at: end, in: window)
} else if step == 12 {
guard model.queue.items.map(\.id) == original else {
finish("Queue committed before release"); return
}
// Several event-loop turns without a mouseDragged event, then release in place.
mouse(.leftMouseUp, at: end, in: window)
} else if step == 14 {
guard model.queue.items.map(\.id) == [original[1], original[2], original[0]],
model.queue.currentID == current else {
finish("Stationary release order: \(model.queue.items.map(\.url.lastPathComponent))"); return
}
mouse(.leftMouseDown, at: end, in: window)
} else if step == 15 {
mouse(.leftMouseDragged, at: NSPoint(x: end.x, y: end.y + 10), in: window)
} else if step == 16 {
mouse(.leftMouseDragged, at: start, in: window)
} else if step == 25 {
mouse(.leftMouseUp, at: start, in: window)
} else if step == 27 {
guard model.queue.items.map(\.id) == original, model.queue.currentID == current else {
finish("Stationary upward release did not restore the original order"); return
}
finish()
return
}
step += 1
}
}
@MainActor
private enum WindowLayoutCheck {
static var timer: Timer?
+39
View File
@@ -1,3 +1,4 @@
import CoreGraphics
import Foundation
@main
@@ -10,7 +11,45 @@ struct PlaybackQueueChecks {
URL(fileURLWithPath: "/queue-check/" + name + ".mp4")
}
static func dragReordering() {
// Unequal heights catch snapping to a row's old center instead of the vacated slot.
let frames = [
CGRect(x: 0, y: 52, width: 260, height: 60),
CGRect(x: 0, y: 112, width: 260, height: 80),
CGRect(x: 0, y: 192, width: 260, height: 60),
]
var queue = PlaybackQueue()
queue.replace([file("A"), file("B"), file("C")])
let current = queue.currentID
var drag = PlaybackQueueDrag(source: 0, frames: frames, scrollOffset: 0)
drag.update(translation: 39, scrollOffset: 0)
require(drag.destination == 0, "release before the nearest-slot midpoint keeps the original position")
drag.update(translation: 41, scrollOffset: 0)
require(drag.destination == 1 && drag.offset(at: 1) == -60 && drag.offset(at: 2) == 0,
"crossing the midpoint slides only the displaced neighbor")
drag.update(translation: 140, scrollOffset: 0)
require(drag.destination == 2 && drag.offset(at: 0) == 140,
"dragged row follows the pointer continuously to the final slot")
// No further pointer update: release consumes the retained gesture state.
queue.move(fromOffsets: IndexSet(integer: drag.source), toOffset: drag.insertionOffset)
require(queue.items.map(\.url) == [file("B"), file("C"), file("A")] && queue.currentID == current,
"stationary release commits the nearest slot without changing playback identity")
drag = PlaybackQueueDrag(source: 2, frames: frames, scrollOffset: 100)
drag.update(translation: -20, scrollOffset: 0)
require(drag.destination == 0 && drag.offset(at: 0) == 60 && drag.offset(at: 1) == 60,
"scrolling under a stationary pointer moves the row and shifts earlier neighbors down")
drag.update(translation: 0, scrollOffset: 100)
require(drag.destination == 2 && drag.offset(at: 1) == 0,
"reversing a drag restores the original gap")
drag.update(translation: -1_000, scrollOffset: 100)
require(drag.destination == 0 && drag.insertionOffset == 0, "release above the list selects the first slot")
drag.update(translation: 1_000, scrollOffset: 100)
require(drag.destination == 2 && drag.insertionOffset == 2, "release below the list selects the last slot")
}
static func main() {
dragReordering()
let first = file("Episode1"), second = file("Episode2"), tenth = file("Episode10")
var queue = PlaybackQueue()
require(queue.items.isEmpty && queue.currentURL == nil && !queue.canPrevious && !queue.canNext,