From ef3aae842dff40eb9ba754936ef8a243ad0dc5c1 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 18 Oct 2026 22:21:42 +0200 Subject: [PATCH] fix: keep playlist reordering active while dragging --- Checks/app-startup.py | 23 ++++++- Checks/app-startup.swift | 115 ++++++++++++++++++++++++++++++++ Checks/playback-queue.swift | 39 +++++++++++ Sources/App/PlaybackQueue.swift | 44 ++++++++++++ Sources/App/TerebiApp.swift | 110 ++++++++++++++++++++++++++---- 5 files changed, 314 insertions(+), 17 deletions(-) diff --git a/Checks/app-startup.py b/Checks/app-startup.py index e73452d..c4173e3 100644 --- a/Checks/app-startup.py +++ b/Checks/app-startup.py @@ -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( diff --git a/Checks/app-startup.swift b/Checks/app-startup.swift index aff52ee..2d0614f 100644 --- a/Checks/app-startup.swift +++ b/Checks/app-startup.swift @@ -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? diff --git a/Checks/playback-queue.swift b/Checks/playback-queue.swift index 73ddc95..cb058a6 100644 --- a/Checks/playback-queue.swift +++ b/Checks/playback-queue.swift @@ -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, diff --git a/Sources/App/PlaybackQueue.swift b/Sources/App/PlaybackQueue.swift index 9c3de26..c738bfa 100644 --- a/Sources/App/PlaybackQueue.swift +++ b/Sources/App/PlaybackQueue.swift @@ -60,3 +60,47 @@ struct PlaybackQueue { } } } + +// Geometry stays in the unshifted content coordinate space throughout a gesture. +struct PlaybackQueueDrag { + let source: Int + let frames: [CGRect] + let initialScrollOffset: CGFloat + private(set) var scrollOffset: CGFloat + private(set) var translation: CGFloat = 0 + private(set) var destination: Int + var pointerY: CGFloat = 0 + + init(source: Int, frames: [CGRect], scrollOffset: CGFloat) { + self.source = source + self.frames = frames + initialScrollOffset = scrollOffset + self.scrollOffset = scrollOffset + destination = source + } + + var offset: CGFloat { translation + scrollOffset - initialScrollOffset } + + func center(at index: Int) -> CGFloat { + if index < source { return frames[index].minY + frames[source].height / 2 } + if index > source { return frames[index].maxY - frames[source].height / 2 } + return frames[source].midY + } + + mutating func update(translation: CGFloat, scrollOffset: CGFloat) { + self.translation = translation + self.scrollOffset = scrollOffset + let centerY = frames[source].midY + offset + destination = frames.indices.min { abs(center(at: $0) - centerY) < abs(center(at: $1) - centerY) }! + } + + var insertionOffset: Int { destination > source ? destination + 1 : destination } + + func offset(at index: Int) -> CGFloat { + if index == source { return offset } + let target = destination + if source < index && index <= target { return -frames[source].height } + if target <= index && index < source { return frames[source].height } + return 0 + } +} diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 3651670..6b426be 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -2038,6 +2038,13 @@ private struct PlayerContent: View { @State private var youTubeURL = "" @FocusState private var youTubeInputFocused: Bool @State private var controlSize = CGSize(width: 440, height: 68) + @State private var queueFrames: [UUID: CGRect] = [:] + @State private var queueDrag: PlaybackQueueDrag? + @GestureState private var queueDragging = false + @State private var queueScroll = ScrollPosition(y: 0) + @State private var queueScrollOffset: CGFloat = 0 + @State private var queueViewportHeight: CGFloat = 0 + @State private var queueContentHeight: CGFloat = 0 private func compactTitleLabel(progress: String?, constrained: Bool) -> some View { HStack(spacing: 0) { @@ -2364,17 +2371,46 @@ private struct PlayerContent: View { .allowsHitTesting(false) .accessibilityHidden(true) } + private var queueReorderAnimation: Animation? { + reduceMotion ? nil : .easeOut(duration: 0.16) + } + + private func updateQueueDrag(_ value: DragGesture.Value, item: PlaybackQueue.Item) { + NSLog("[DEBUG-queue-drag] changed %@ translation %@ frames %d", item.url.lastPathComponent, String(describing: value.translation), queueFrames.count) + if queueDrag == nil { + let frames = model.queue.items.compactMap { queueFrames[$0.id] } + guard frames.count == model.queue.items.count, + let source = model.queue.items.firstIndex(where: { $0.id == item.id }) else { return } + queueDrag = PlaybackQueueDrag(source: source, frames: frames, scrollOffset: queueScrollOffset) + } + queueDrag?.pointerY = value.location.y + queueDrag?.update(translation: value.translation.height, scrollOffset: queueScrollOffset) + } + + private func queueRowDrag(_ item: PlaybackQueue.Item) -> some Gesture { + DragGesture(minimumDistance: 4, coordinateSpace: .named("queueViewport")) + .updating($queueDragging) { _, active, _ in active = true } + .onChanged { updateQueueDrag($0, item: item) } + .onEnded { value in + NSLog("[DEBUG-queue-drag] ended %@", String(describing: queueDrag)) + updateQueueDrag(value, item: item) + guard let drag = queueDrag else { return } + withAnimation(queueReorderAnimation) { + model.moveQueueItems(fromOffsets: IndexSet(integer: drag.source), toOffset: drag.insertionOffset) + queueDrag = nil + } + } + } private var queueSidebar: some View { VStack(spacing: 0) { - List { - Color.clear - .frame(height: 52) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .allowsHitTesting(false) - .accessibilityHidden(true) - ForEach(model.queue.items) { item in + ScrollView { + VStack(spacing: 0) { + Color.clear + .frame(height: 52) + .allowsHitTesting(false) + .accessibilityHidden(true) + ForEach(Array(model.queue.items.enumerated()), id: \.element.id) { index, item in let isCurrent = item.id == model.queue.currentID HStack(spacing: 8) { VStack(spacing: 4) { @@ -2433,14 +2469,60 @@ private struct PlayerContent: View { } } .accessibilityAction(named: Text("Play")) { model.selectQueueItem(item.id) } - .listRowInsets(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) - .listRowSeparator(.hidden) + .accessibilityAction(named: Text("Move Up")) { + if index > 0 { + model.moveQueueItems(fromOffsets: IndexSet(integer: index), toOffset: index - 1) + } + } + .accessibilityAction(named: Text("Move Down")) { + if index + 1 < model.queue.items.count { + model.moveQueueItems(fromOffsets: IndexSet(integer: index), toOffset: index + 2) + } + } + .padding(.vertical, 2) + .onGeometryChange(for: CGRect.self) { $0.frame(in: .named("queueContent")) } action: { + NSLog("[DEBUG-queue-drag] frame %@ %@", item.url.lastPathComponent, String(describing: $0)) + queueFrames[item.id] = $0 + } + .offset(y: queueDrag?.offset(at: index) ?? 0) + .zIndex(queueDrag?.source == index ? 1 : 0) + .animation(queueDrag?.source == index ? nil : queueReorderAnimation, value: queueDrag?.destination) + .highPriorityGesture(queueRowDrag(item)) + } } - .onMove { model.moveQueueItems(fromOffsets: $0, toOffset: $1) } + .coordinateSpace(name: "queueContent") + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { queueContentHeight = $0 } } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .contentMargins(.horizontal, 0, for: .scrollContent) + .coordinateSpace(name: "queueViewport") + .scrollPosition($queueScroll) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { queueViewportHeight = $0 } + .onScrollGeometryChange(for: CGFloat.self) { $0.contentOffset.y } action: { _, offset in + queueScrollOffset = offset + if let drag = queueDrag { + queueDrag?.update(translation: drag.translation, scrollOffset: offset) + } + } + .task(id: queueDrag?.source) { + guard queueDrag != nil else { return } + while !Task.isCancelled { + do { try await Task.sleep(for: .milliseconds(16)) } catch { return } + guard let drag = queueDrag else { return } + let step: CGFloat = drag.pointerY < 64 ? -6 : (drag.pointerY > queueViewportHeight - 32 ? 6 : 0) + if step != 0 { + let offset = min(max(0, queueScrollOffset + step), max(0, queueContentHeight - queueViewportHeight)) + if offset != queueScrollOffset { queueScroll.scrollTo(y: offset) } + } + } + } + .onChange(of: queueDragging) { _, active in + NSLog("[DEBUG-queue-drag] active %d", active) + if !active { withAnimation(queueReorderAnimation) { queueDrag = nil } } + } + .onChange(of: model.queue.items.map(\.id)) { _, ids in + queueDrag = nil + queueFrames = queueFrames.filter { ids.contains($0.key) } + } + .onDisappear { queueDrag = nil } .overlay { if model.queue.items.isEmpty { Text("Drop local files here to add them to the queue.")