feat: add editable playback queue
This commit is contained in:
+231
-25
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user