feat: add expandable file and link controls to playlist
This commit is contained in:
+155
-20
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user