feat: add editable playback queue

This commit is contained in:
2026-10-18 00:15:05 +02:00
parent d4704af442
commit ce903e2367
10 changed files with 510 additions and 33 deletions
+62
View File
@@ -0,0 +1,62 @@
import Foundation
struct PlaybackQueue {
struct Item: Identifiable {
let id = UUID()
let url: URL
}
private(set) var items: [Item] = []
private(set) var currentID: UUID?
var currentURL: URL? { items.first { $0.id == currentID }?.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
}
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
}
}
}
+8
View File
@@ -178,6 +178,8 @@ struct PlayerCommands: Commands {
Button("Open File…") { model.performMenuAction(.openFile) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .openFile).first?.keyboardShortcut)
.disabled(!model.canPerform(.openFile))
Button("Add to Queue…", action: model.addQueuePanel)
.disabled(!model.canOpen)
Button("Open YouTube URL…") { model.performMenuAction(.openYoutube) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .openYoutube).first?.keyboardShortcut)
.disabled(!model.canPerform(.openYoutube))
@@ -195,6 +197,10 @@ struct PlayerCommands: Commands {
Button(model.effectivePlaying ? "Pause" : "Play") { model.performMenuAction(playback) }
.keyboardShortcut(model.keyBindings.shortcuts(for: playback).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(playback))
Button("Previous Queue Item") { model.advanceQueue(-1) }
.disabled(!hasFocusedScene || !model.canPreviousQueueItem)
Button("Next Queue Item") { model.advanceQueue(1) }
.disabled(!hasFocusedScene || !model.canNextQueueItem)
Button("Seek Back 5 Seconds") { model.performMenuAction(.seekBackward5s) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .seekBackward5s).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.seekBackward5s))
@@ -256,6 +262,8 @@ struct PlayerCommands: Commands {
.disabled(!hasFocusedScene || !model.canPerform(bufferInfo))
}
CommandGroup(replacing: .sidebar) {
Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() }
.disabled(!hasFocusedScene || model.isCompact)
Button(fullscreen == .exitFullscreen ? "Exit Full Screen" : "Enter Full Screen") {
model.performMenuAction(fullscreen)
}
+231 -25
View File
@@ -31,6 +31,13 @@ final class PlayerModel: ObservableObject {
@Published private(set) var fileName: String?
@Published private(set) var fileIcon: NSImage?
@Published private(set) var commandError: String?
@Published private(set) var queue = PlaybackQueue()
@Published var showsQueue = false
private var queuePlaybackRevision: UInt64?
private var queueCompletionHandled = false
private var queueCastDevice: CastDevice?
var canPreviousQueueItem: Bool { canOpen && queue.canPrevious }
var canNextQueueItem: Bool { canOpen && queue.canNext }
private var transition = PlaybackTransition()
let castDiscovery = CastDiscovery()
private lazy var casting = CastPlaybackTransition { [weak self] event in self?.receiveCast(event) }
@@ -193,17 +200,18 @@ final class PlayerModel: ObservableObject {
}
}
func cast(to device: CastDevice) {
func cast(to device: CastDevice, playing: Bool? = nil) {
guard canCast, let source = currentMedia, let handle else { return }
cancelYouTubeResolution()
commandError = nil
casting.start(to: device, source: source, title: fileName ?? "terebi", subtitle: selectedSubtitle,
handle: handle, snapshot: state, playing: effectivePlaying,
handle: handle, snapshot: state, playing: playing ?? effectivePlaying,
volume: volume, audibleVolume: lastAudibleVolume)
revealControls()
}
func stopCasting(resume: Bool = true) {
queueCastDevice = nil
casting.stop(resume: resume)
}
@@ -224,6 +232,7 @@ final class PlayerModel: ObservableObject {
if !isSeeking && !state.seek_pending { seekPosition = state.position }
if let handle { _ = player_report_remote_state(handle, &state) }
saveProgress()
completeQueuePlaybackIfNeeded()
case .stopped(let result):
if let checkpoint = result.checkpoint {
saveCastProgress(checkpoint, source: result.source)
@@ -253,17 +262,21 @@ final class PlayerModel: ObservableObject {
return accepted
}
func openPanel() {
func openPanel() { filePanel(append: false) }
func addQueuePanel() { filePanel(append: true) }
private func filePanel(append: Bool) {
guard canOpen else { return }
defer { window?.makeFirstResponder(nil) }
revealControls()
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.prompt = "Open"
guard panel.runModal() == .OK, let url = panel.url else { return }
open(url)
panel.allowsMultipleSelection = true
panel.prompt = append ? "Add to Queue" : "Open"
guard panel.runModal() == .OK else { return }
if append { addToQueue(panel.urls) } else { openFiles(panel.urls) }
}
func openYouTubePanel() {
@@ -294,12 +307,14 @@ final class PlayerModel: ObservableObject {
revealControls()
return
}
clearQueue()
resolveYouTube(url, height: nil)
}
func openResolvedSource(_ file: URL) {
do {
let source = try ResolvedSource.load(from: file)
clearQueue()
cancelYouTubeResolution()
openMedia(.resolved(source))
} catch {
@@ -487,6 +502,8 @@ final class PlayerModel: ObservableObject {
return
}
transition.acceptOpen(revision: revision, source: source, change: change, savedPosition: savedPosition)
queuePlaybackRevision = queue.currentID == nil ? nil : player_get_state(handle).revision
queueCompletionHandled = false
currentMedia = source
if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
switch source {
@@ -528,13 +545,79 @@ final class PlayerModel: ObservableObject {
}
func open(_ url: URL) {
if url.isFileURL { openFiles([url]) } else { openYouTube(url.absoluteString) }
}
func openFiles(_ urls: [URL]) {
guard canOpen, urls.contains(where: \.isFileURL) else { return }
queueCastDevice = nil
queue.replace(urls)
showsQueue = queue.items.count > 1
openQueueSelection(keepingReceiver: false)
}
func addToQueue(_ urls: [URL]) {
guard canOpen else { return }
guard url.isFileURL else {
openYouTube(url.absoluteString)
return
let wasEmpty = queue.items.isEmpty
queue.append(urls)
guard !queue.items.isEmpty else { return }
showsQueue = true
if wasEmpty, fileName == nil, let first = queue.items.first {
queue.select(first.id)
openQueueSelection()
}
}
func selectQueueItem(_ id: UUID) {
guard canOpen, queue.items.contains(where: { $0.id == id }) else { return }
queue.select(id)
openQueueSelection()
}
func advanceQueue(_ offset: Int) {
guard canOpen, queue.advance(offset) != nil else { return }
openQueueSelection()
}
func removeQueueItem(_ id: UUID) {
let wasCurrent = queue.currentID == id
let next = queue.remove(id)
guard wasCurrent else { return }
if next != nil { openQueueSelection() } else { closeFile(clearQueue: false) }
}
func moveQueueItems(fromOffsets offsets: IndexSet, toOffset destination: Int) {
queue.move(fromOffsets: offsets, toOffset: destination)
}
private func openQueueSelection(keepingReceiver: Bool = true) {
guard let url = queue.currentURL else { return }
let receiver = keepingReceiver ? (castDevice ?? queueCastDevice) : nil
queuePlaybackRevision = nil
queueCompletionHandled = true
cancelYouTubeResolution()
openMedia(.file(url))
openMedia(.file(url), change: .queue(playing: receiver == nil))
queueCastDevice = receiver
}
private func clearQueue() {
queue.replace([])
queuePlaybackRevision = nil
queueCompletionHandled = false
queueCastDevice = nil
}
private func completeQueuePlaybackIfNeeded() {
guard queue.currentID != nil, queuePlaybackRevision != nil, !isBusy else { return }
if state.status == PLAYER_PLAYING { queueCompletionHandled = false }
guard !queueCompletionHandled, !state.seek_pending, !transition.seekPending else { return }
if state.status == PLAYER_ERROR {
queueCompletionHandled = true
queueCastDevice = nil
} else if state.status == PLAYER_ENDED {
queueCompletionHandled = true
if queue.canNext { advanceQueue(1) }
}
}
func togglePlayback() {
@@ -582,7 +665,7 @@ final class PlayerModel: ObservableObject {
revealControls()
}
func closeFile() {
func closeFile(clearQueue: Bool = true) {
guard let handle, canClose else { return }
saveProgress()
if castDevice != nil { stopCasting(resume: false) }
@@ -593,6 +676,10 @@ final class PlayerModel: ObservableObject {
return
}
transition.closeMedia()
if clearQueue { self.clearQueue() }
queuePlaybackRevision = nil
queueCompletionHandled = true
queueCastDevice = nil
currentMedia = nil
selectSubtitle(nil)
subtitleTracks = []
@@ -922,6 +1009,8 @@ final class PlayerModel: ObservableObject {
}
func applyState(_ next: PlayerState) {
guard !casting.active else { return }
if let revision = queuePlaybackRevision, next.revision < revision { return }
let statusChanged = state.status != next.status
let observation = transition.advance(next,
forceCheckpoint: statusChanged && (next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED),
@@ -945,6 +1034,15 @@ final class PlayerModel: ObservableObject {
if let position = restored.position { seekPosition = position }
commandError = restored.error
}
if let device = queueCastDevice, !isBusy, !transition.seekPending, !state.seek_pending {
if state.status == PLAYER_ERROR {
queueCastDevice = nil
} else if canCast {
queueCastDevice = nil
cast(to: device, playing: true)
}
}
completeQueuePlaybackIfNeeded()
}
private func updateAutomaticQuality() {
@@ -1246,6 +1344,7 @@ final class PlayerModel: ObservableObject {
private enum MediaChange {
case open
case queue(playing: Bool)
case quality(position: Double?, playing: Bool, automatic: Bool)
case restore(position: Double, playing: Bool)
@@ -1256,6 +1355,7 @@ final class PlayerModel: ObservableObject {
var playing: Bool {
if case .quality(_, let playing, _) = self { return playing }
if case .restore(_, let playing) = self { return playing }
if case .queue(let playing) = self { return playing }
return true
}
var position: Double? {
@@ -1941,6 +2041,9 @@ private struct PlayerContent: View {
private var titleControls: some View {
HStack(spacing: 4) {
iconButton("list.bullet", model.showsQueue ? "Hide Queue" : "Show Queue") {
model.showsQueue.toggle()
}
iconButton(model.isOnTop ? "pin.fill" : "pin",
model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
.imageScale(.small)
@@ -1963,6 +2066,10 @@ private struct PlayerContent: View {
private var actionsMenu: some View {
Menu {
PlayerActionButton(model: model, action: .openFile, title: "Open File…")
Button("Add to Queue…", action: model.addQueuePanel).disabled(!model.canOpen)
Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() }
Button("Previous Queue Item") { model.advanceQueue(-1) }.disabled(!model.canPreviousQueueItem)
Button("Next Queue Item") { model.advanceQueue(1) }.disabled(!model.canNextQueueItem)
PlayerActionButton(model: model, action: .openYoutube, title: "Open YouTube URL…")
PlayerActionButton(model: model, action: .closeFile, title: "Close File")
Divider()
@@ -2115,7 +2222,106 @@ private struct PlayerContent: View {
)
}
private var queueSidebar: some View {
VStack(spacing: 0) {
HStack {
Text("Queue").font(.headline)
Text("\(model.queue.items.count)").foregroundStyle(.secondary)
Spacer()
Button { model.showsQueue = false } label: {
Image(systemName: "sidebar.right")
}
.help("Hide Queue")
.accessibilityLabel("Hide Queue")
}
.padding(12)
HStack {
iconButton("backward.end.fill", "Previous Queue Item") { model.advanceQueue(-1) }
.disabled(!model.canPreviousQueueItem)
iconButton("forward.end.fill", "Next Queue Item") { model.advanceQueue(1) }
.disabled(!model.canNextQueueItem)
Spacer()
Button("Add to Queue…", action: model.addQueuePanel).disabled(!model.canOpen)
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
List {
ForEach(model.queue.items) { item in
HStack(spacing: 8) {
Image(systemName: item.id == model.queue.currentID ? "play.fill" : "film")
.foregroundStyle(item.id == model.queue.currentID ? Color.accentColor : .secondary)
.accessibilityLabel(item.id == model.queue.currentID ? "Current item" : "Queued item")
Text(item.url.lastPathComponent)
.lineLimit(2)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
Button { model.removeQueueItem(item.id) } label: {
Image(systemName: "minus.circle")
}
.buttonStyle(.plain)
.help("Remove from Queue")
.accessibilityLabel("Remove \(item.url.lastPathComponent) from Queue")
}
.contentShape(Rectangle())
.onTapGesture(count: 2) { model.selectQueueItem(item.id) }
.help(item.url.path + "\nDouble-click to play; drag to reorder.")
.contextMenu {
Button("Play") { model.selectQueueItem(item.id) }
Button("Remove from Queue") { model.removeQueueItem(item.id) }
if let index = model.queue.items.firstIndex(where: { $0.id == item.id }) {
Button("Move Up") {
model.moveQueueItems(fromOffsets: IndexSet(integer: index), toOffset: index - 1)
}.disabled(index == 0)
Button("Move Down") {
model.moveQueueItems(fromOffsets: IndexSet(integer: index), toOffset: index + 2)
}.disabled(index + 1 == model.queue.items.count)
}
}
.accessibilityAction(named: Text("Play")) { model.selectQueueItem(item.id) }
}
.onMove { model.moveQueueItems(fromOffsets: $0, toOffset: $1) }
}
.listStyle(.sidebar)
.overlay {
if model.queue.items.isEmpty {
Text("Drop local files here to add them to the queue.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding()
.allowsHitTesting(false)
}
}
}
.frame(width: 260)
.padding(.top, 32)
.background(PlayerGlass(cornerRadius: 0))
.dropDestination(for: URL.self) { urls, _ in
guard model.canOpen, urls.contains(where: \.isFileURL) else { return false }
model.addToQueue(urls)
return true
}
.onHover(perform: model.setControlsHovered)
}
var body: some View {
HStack(spacing: 0) {
videoContent
if model.showsQueue && !model.isCompact && !model.compactWindow.isTransitioning {
Divider()
queueSidebar
}
}
.frame(minWidth: model.isCompact || model.compactWindow.isTransitioning ? 160 : (model.showsQueue ? 660 : 400),
minHeight: model.isCompact || model.compactWindow.isTransitioning ? model.compactContentMinimumHeight : 240)
.ignoresSafeArea()
.preferredColorScheme(.dark)
.foregroundStyle(Color(SunsetPalette.text))
.tint(Color(SunsetPalette.accent))
.background(WindowChrome(model: model))
}
private var videoContent: some View {
GeometryReader { geometry in
ZStack(alignment: .top) {
if model.fileName == nil || model.castDevice != nil {
@@ -2362,9 +2568,14 @@ private struct PlayerContent: View {
}
}
.dropDestination(for: URL.self) { urls, _ in
guard model.canOpen, let url = urls.first else { return false }
if !url.isFileURL && (try? YouTubeResolver.validateURL(url.absoluteString)) == nil { return false }
model.open(url)
guard model.canOpen, !urls.isEmpty else { return false }
if urls.allSatisfy(\.isFileURL) {
model.openFiles(urls)
} else {
guard urls.count == 1, let url = urls.first,
(try? YouTubeResolver.validateURL(url.absoluteString)) != nil else { return false }
model.open(url)
}
return true
} isTargeted: {
dropTargeted = $0
@@ -2378,13 +2589,6 @@ private struct PlayerContent: View {
}
}
}
.frame(minWidth: model.isCompact || model.compactWindow.isTransitioning ? 160 : 400,
minHeight: model.isCompact || model.compactWindow.isTransitioning ? model.compactContentMinimumHeight : 240)
.ignoresSafeArea()
.preferredColorScheme(.dark)
.foregroundStyle(Color(SunsetPalette.text))
.tint(Color(SunsetPalette.accent))
.background(WindowChrome(model: model))
}
}
@@ -2517,8 +2721,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
@objc private func openDocuments(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
guard let url = event.paramDescriptor(forKeyword: keyDirectObject)?.atIndex(1)?.fileURLValue else { return }
PlayerModel.shared.open(url)
guard let files = event.paramDescriptor(forKeyword: keyDirectObject), files.numberOfItems > 0 else { return }
let urls = (1...files.numberOfItems).compactMap { files.atIndex($0)?.fileURLValue }
PlayerModel.shared.openFiles(urls)
}
func applicationDidFinishLaunching(_ notification: Notification) {
@@ -2538,7 +2743,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Let SwiftUI create the initial window for a Finder/document launch.
func application(_ application: NSApplication, open urls: [URL]) {
if let url = urls.first { PlayerModel.shared.open(url) }
if urls.allSatisfy(\.isFileURL) { PlayerModel.shared.openFiles(urls) }
else if let url = urls.first { PlayerModel.shared.open(url) }
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }