fix: keep playlist reordering active while dragging
This commit is contained in:
@@ -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