121 lines
4.5 KiB
Swift
121 lines
4.5 KiB
Swift
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
|
|
}
|
|
}
|