fix: keep playlist reordering active while dragging
This commit is contained in:
+20
-3
@@ -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(
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+96
-14
@@ -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.")
|
||||
|
||||
Reference in New Issue
Block a user