feat: add expandable file and link controls to playlist

This commit is contained in:
2026-10-22 17:47:32 +02:00
parent 3ff498720b
commit f1339050d9
6 changed files with 320 additions and 25 deletions
+155 -20
View File
@@ -374,6 +374,39 @@ final class PlayerModel: ObservableObject {
resolveStream(stream, change: .open)
}
func addStreamToQueue(_ text: String) async throws {
guard canOpen else { throw CancellationError() }
try Task.checkCancellation()
let stream = try StreamURL.parse(text)
let url: URL
let title: String
let source: MediaSource
switch stream {
case .youtube(let canonical):
let video = try await youtubeResolver(canonical, nil)
guard video.webpageURL == canonical else { throw CocoaError(.fileReadCorruptFile) }
url = canonical
title = video.title
source = .youtube(video, height: nil)
case .miruro(let mediaID, let episode):
let resolved = try await miruroResolver(mediaID, episode)
guard resolved.identity == "miruro:\(mediaID):\(episode)" else { throw CocoaError(.fileReadCorruptFile) }
url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines))!
title = resolved.title
source = .resolved(resolved)
}
try Task.checkCancellation()
guard canOpen else { throw CancellationError() }
let needsStart = queue.currentID == nil && currentMedia == nil && !resolvingStream
queue.appendStreams([(url: url, label: title)])
commandError = nil
showsQueue = true
if needsStart, let added = queue.items.last {
queue.select(added.id)
openMedia(source, change: .queue(playing: true))
}
}
func openStreamQueue(_ streamQueue: StreamQueue) {
guard canOpen, progressInitializationError == nil else { return }
queueCastDevice = nil
@@ -2500,6 +2533,12 @@ private struct PlayerContent: View {
private var showsYouTubeInput: Bool { model.showsYouTubeInput }
@ViewState private var youTubeURL = ""
@FocusState private var youTubeInputFocused: Bool
@ViewState private var showsQueueAddOptions = false
@ViewState private var showsQueueURLInput = false
@ViewState private var queueURL = ""
@ViewState private var queueURLSubmission: String?
@ViewState private var queueURLError: String?
@FocusState private var queueURLInputFocused: Bool
@ViewState private var controlSize = CGSize(width: 440, height: 68)
@ViewState private var queueFrames: [UUID: CGRect] = [:]
@ViewState private var queueDrag: PlaybackQueueDrag?
@@ -2643,22 +2682,29 @@ private struct PlayerContent: View {
}
private var startURLInput: some View {
streamURLInput(text: $youTubeURL, focused: $youTubeInputFocused, action: "Open Stream") {
model.openStream(youTubeURL)
}
}
private func streamURLInput(text: Binding<String>, focused: FocusState<Bool>.Binding,
action: String, submit: @escaping () -> Void) -> some View {
HStack(spacing: 8) {
TextField("Stream URL", text: $youTubeURL,
TextField("Stream URL", text: text,
prompt: Text("Stream URL")
.foregroundStyle(Color(SunsetPalette.secondaryText).opacity(0.6)))
.textFieldStyle(.plain)
.focused($youTubeInputFocused)
.focused(focused)
.accessibilityLabel("Stream URL")
.onAppear { youTubeInputFocused = true }
.onSubmit { model.openStream(youTubeURL) }
Button { model.openStream(youTubeURL) } label: {
.onAppear { focused.wrappedValue = true }
.onSubmit(submit)
Button(action: submit) {
Image(systemName: "arrow.right").frame(width: 24, height: 24).contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("Open Stream")
.help("Open Stream")
.disabled(youTubeURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.accessibilityLabel(action)
.help(action)
.disabled(text.wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.font(.system(size: 12))
.padding(.horizontal, 12)
@@ -3003,6 +3049,98 @@ private struct PlayerContent: View {
}
}
private var queueAddAnimation: Animation? {
reduceMotion ? nil : .easeInOut(duration: 0.2)
}
private func dismissQueueAdd() {
showsQueueURLInput = false
showsQueueAddOptions = false
queueURLSubmission = nil
queueURLError = nil
}
private var queueAddButton: some View {
HStack(spacing: 0) {
if showsQueueAddOptions {
Button {
dismissQueueAdd()
model.addQueuePanel()
} label: {
Image(systemName: "doc").frame(width: 20, height: 20)
}
.help("Add Local Files…")
.accessibilityLabel("Add Local Files…")
Button {
showsQueueURLInput.toggle()
queueURLSubmission = nil
queueURLError = nil
} label: {
Image(systemName: "link").frame(width: 20, height: 20)
}
.help("Add Stream URL…")
.accessibilityLabel("Add Stream URL…")
} else {
Button { showsQueueAddOptions = true } label: {
Image(systemName: "plus").frame(width: 20, height: 20)
}
.help("Add to Queue…")
.accessibilityLabel("Add to Queue…")
}
}
.font(.system(size: 12))
.buttonStyle(.plain)
.foregroundStyle(Color(SunsetPalette.accent))
.frame(width: showsQueueAddOptions ? 40 : 20, height: 20)
.background(Color(SunsetPalette.accent).opacity(0.1), in: RoundedRectangle(cornerRadius: 6))
.overlay {
if showsQueueAddOptions {
Rectangle().fill(Color(SunsetPalette.outline)).frame(width: 0.5, height: 12)
.allowsHitTesting(false)
}
}
.clipped()
.animation(queueAddAnimation, value: showsQueueAddOptions)
.disabled(!model.canOpen)
}
private var queueURLInput: some View {
VStack(alignment: .leading, spacing: 6) {
streamURLInput(text: $queueURL, focused: $queueURLInputFocused, action: "Add to Queue") {
guard queueURLSubmission == nil,
!queueURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
queueURLError = nil
queueURLSubmission = queueURL
}
.disabled(queueURLSubmission != nil || !model.canOpen)
if queueURLSubmission != nil {
ProgressView("Fetching video…").controlSize(.small)
}
if let queueURLError {
Text(queueURLError).font(.system(size: 11)).foregroundStyle(Color(SunsetPalette.secondaryText))
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.horizontal, 12)
.padding(.top, 8)
.onExitCommand(perform: dismissQueueAdd)
.task(id: queueURLSubmission) {
guard let text = queueURLSubmission else { return }
do {
try await model.addStreamToQueue(text)
queueURL = ""
dismissQueueAdd()
} catch is CancellationError {
return
} catch {
guard !Task.isCancelled else { return }
queueURLError = error.localizedDescription
queueURLSubmission = nil
queueURLInputFocused = true
}
}
}
private var queueSidebar: some View {
VStack(spacing: 0) {
ScrollView {
@@ -3136,6 +3274,11 @@ private struct PlayerContent: View {
.allowsHitTesting(false)
}
}
if showsQueueURLInput {
queueURLInput
.transition(.opacity.combined(with: .move(edge: .bottom))
.combined(with: .scale(scale: 0.96, anchor: .bottom)))
}
HStack(spacing: 4) {
HStack(spacing: 4) {
Text("Queue")
@@ -3143,18 +3286,7 @@ private struct PlayerContent: View {
}
.font(.system(size: 13, weight: .medium))
.accessibilityElement(children: .combine)
Button(action: model.addQueuePanel) {
Image(systemName: "plus")
.font(.system(size: 12))
.frame(width: 20, height: 20)
.background(Color(SunsetPalette.accent).opacity(0.1), in: RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
.foregroundStyle(Color(SunsetPalette.accent))
.padding(.leading, 4)
.help("Add to Queue…")
.accessibilityLabel("Add to Queue…")
.disabled(!model.canOpen)
queueAddButton.padding(.leading, 4)
Spacer()
iconButton("backward.end.fill", "Previous Queue Item", size: 12) { model.advanceQueue(-1) }
.disabled(!model.canPreviousQueueItem)
@@ -3166,6 +3298,8 @@ private struct PlayerContent: View {
.background(windowDragBackground)
.background { queueChromeBackground(top: false) }
}
.animation(queueAddAnimation, value: showsQueueURLInput)
.onDisappear(perform: dismissQueueAdd)
.frame(width: 260)
.background(queueBackground)
.overlay(alignment: .top) {
@@ -3217,6 +3351,7 @@ private struct PlayerContent: View {
.background(WindowChrome(model: model))
.onChange(of: windowDragging) { _, active in model.setControlsDragging(active) }
.onChange(of: showsYouTubeInput) { _, shown in youTubeInputFocused = shown }
.onChange(of: showsQueueURLInput) { _, shown in queueURLInputFocused = shown }
.overlay(alignment: .top) {
if model.fileName != nil && showsYouTubeInput {
startURLInput