import Foundation struct PlaybackQueue { struct Item: Identifiable { let id = UUID() let url: URL var label: String? = nil var displayName: String { label ?? url.lastPathComponent } } private(set) var items: [Item] = [] private(set) var currentID: UUID? var currentItem: Item? { items.first { $0.id == currentID } } var currentURL: URL? { currentItem?.url } private var currentIndex: Int? { items.firstIndex { $0.id == currentID } } var canPrevious: Bool { currentIndex.map { $0 > 0 } ?? false } var canNext: Bool { currentIndex.map { $0 + 1 < items.count } ?? false } mutating func replace(_ urls: [URL]) { items = sortedFiles(urls).map { Item(url: $0) } currentID = items.first?.id } // Unlike file batches, stream entries already have an intentional playback order. mutating func replaceStreams(_ entries: [(url: URL, label: String)]) { items = entries.map { Item(url: $0.url, label: $0.label) } currentID = items.first?.id } mutating func updateCurrentLabel(_ label: String) { guard let index = currentIndex else { return } items[index].label = label } mutating func append(_ urls: [URL]) { items.append(contentsOf: sortedFiles(urls).map { Item(url: $0) }) } mutating func select(_ id: UUID) { guard items.contains(where: { $0.id == id }) else { return } currentID = id } mutating func advance(_ offset: Int) -> URL? { guard let index = currentIndex else { return nil } let (target, overflow) = index.addingReportingOverflow(offset) guard !overflow, items.indices.contains(target) else { return nil } currentID = items[target].id return items[target].url } mutating func remove(_ id: UUID) -> URL? { guard let index = items.firstIndex(where: { $0.id == id }) else { return nil } let wasCurrent = currentID == id items.remove(at: index) guard wasCurrent else { return nil } currentID = items.indices.contains(index) ? items[index].id : nil return currentURL } mutating func move(fromOffsets offsets: IndexSet, toOffset destination: Int) { guard !offsets.isEmpty, offsets.allSatisfy(items.indices.contains), (0...items.count).contains(destination) else { return } let moving = offsets.map { items[$0] } for index in offsets.reversed() { items.remove(at: index) } let insertion = destination - offsets.filter { $0 < destination }.count items.insert(contentsOf: moving, at: insertion) } private func sortedFiles(_ urls: [URL]) -> [URL] { urls.filter(\.isFileURL).sorted { let order = $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) return order == .orderedSame ? $0.path < $1.path : order == .orderedAscending } } } // 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 } }