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
+19
View File
@@ -67,6 +67,22 @@ def check_window_drag(root, app, fixtures):
print("PASS native window dragging: " + str(report)) print("PASS native window dragging: " + str(report))
def check_queue_add(root, app, fixtures):
result_path = root / "queue-add.json"
diagnostics = root / "queue-add.log"
result = subprocess.run(
["open", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app),
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-window-drag", str(result_path),
"--queue-add-only", str(fixtures)],
capture_output=True, text=True, timeout=30,
)
report = json.loads(result_path.read_text()) if result_path.exists() else {}
if result.returncode != 0 or not report.get("passed"):
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
raise RuntimeError(f"native queue add failed: {report}; {result.stdout}{result.stderr}{log}")
print("PASS native queue add: + expands, link focuses input, toggle retains draft, Escape dismisses without moving window")
def check_queue_drag(root, app, fixtures): def check_queue_drag(root, app, fixtures):
result_path = root / "queue-drag.json" result_path = root / "queue-drag.json"
diagnostics = root / "queue-drag.log" diagnostics = root / "queue-drag.log"
@@ -765,6 +781,9 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK) descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK)
launch = None launch = None
try: try:
if os.environ.get("TEREBI_CHECK_QUEUE_ADD_ONLY") == "1":
check_queue_add(root, app, fixtures)
sys.exit(0)
if os.environ.get("TEREBI_CHECK_STREAM_QUEUE_ONLY") == "1": if os.environ.get("TEREBI_CHECK_STREAM_QUEUE_ONLY") == "1":
check_cli_instances(root, app, fixtures) check_cli_instances(root, app, fixtures)
sys.exit(0) sys.exit(0)
+63
View File
@@ -185,10 +185,12 @@ private enum WindowDragCheck {
static func run(result: URL) { static func run(result: URL) {
resultURL = result resultURL = result
_ = NSApplication.shared _ = NSApplication.shared
if CommandLine.arguments.contains("--queue-add-only") { probe = 12 }
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
MainActor.assumeIsolated { advance() } MainActor.assumeIsolated { advance() }
} }
RunLoop.main.add(timer!, forMode: .common) RunLoop.main.add(timer!, forMode: .common)
RunLoop.main.add(timer!, forMode: .modalPanel)
terebiAppMain() terebiAppMain()
} }
@@ -230,6 +232,18 @@ private enum WindowDragCheck {
} }
} }
static func clickQueueAdd(in window: NSWindow, link: Bool) {
for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] {
let event = NSEvent.mouseEvent(with: type,
location: NSPoint(x: window.contentView!.bounds.width - (link ? 163 : 183), y: 18),
modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber, context: nil,
eventNumber: 1000 + phase * 2 + (type == .leftMouseDown ? 0 : 1),
clickCount: 1, pressure: type == .leftMouseDown ? 1 : 0)!
NSApp.postEvent(NSEvent(cgEvent: event.cgEvent!)!, atStart: false)
}
}
static func point(in window: NSWindow) -> NSPoint? { static func point(in window: NSWindow) -> NSPoint? {
let content = window.contentView! let content = window.contentView!
let w = content.bounds.width let w = content.bounds.width
@@ -303,6 +317,7 @@ private enum WindowDragCheck {
interceptDrags(in: window) interceptDrags(in: window)
model.setVolume(0) model.setVolume(0)
model.setPlayback(playing: false) model.setPlayback(playing: false)
if CommandLine.arguments.contains("--queue-add-only") { model.showsQueue = true }
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
window.makeMain() window.makeMain()
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
@@ -365,6 +380,12 @@ private enum WindowDragCheck {
phase = 7 phase = 7
return return
} }
if probe == 12 {
urlWindowFrame = window.frame
clickQueueAdd(in: window, link: false)
phase = 7
return
}
probe += 1 probe += 1
if probe == names.count { finish(); return } if probe == names.count { finish(); return }
phase = 0 phase = 0
@@ -387,6 +408,48 @@ private enum WindowDragCheck {
probe += 1 probe += 1
phase = 0 phase = 0
return return
} else if probe == 12 && phase == 7 {
guard NSApp.modalWindow == nil else { finish("Queue + opened the file picker instead of expanding"); return }
clickQueueAdd(in: window, link: true)
} else if probe == 12 && phase == 8 {
guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else { return }
editor.insertText(urlDraft, replacementRange: NSRange(location: NSNotFound, length: 0))
clickQueueAdd(in: window, link: true)
} else if probe == 12 && phase == 9 {
guard nativeView(in: window.contentView!, matching: { ($0 as? NSTextField)?.isEditable == true }) == nil else { return }
guard (window.firstResponder as? NSTextView)?.isFieldEditor != true else {
finish("Collapsing the queue URL field retained keyboard focus"); return
}
clickQueueAdd(in: window, link: true)
} else if probe == 12 && phase == 10 {
guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else { return }
guard editor.string == urlDraft else { finish("Reopening queue URL input lost its draft"); return }
let escape = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber,
context: nil, characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53)!
NSApp.postEvent(escape, atStart: false)
} else if probe == 12 && phase == 11 {
guard nativeView(in: window.contentView!, matching: { ($0 as? NSTextField)?.isEditable == true }) == nil else { return }
guard (window.firstResponder as? NSTextView)?.isFieldEditor != true,
window.frame == urlWindowFrame, drags == previousDrags else {
finish("Queue URL transitions retained focus, resized or dragged the window"); return
}
clickQueueAdd(in: window, link: false)
} else if probe == 12 && phase == 12 {
clickQueueAdd(in: window, link: false)
} else if probe == 12 && phase == 13 {
guard let panel = NSApp.modalWindow as? NSOpenPanel else { return }
guard panel.allowsMultipleSelection && panel.prompt == "Add to Queue" else {
panel.cancel(nil)
finish("Queue file icon did not open the multi-file queue picker"); return
}
panel.cancel(nil)
} else if probe == 12 && phase == 14 {
guard NSApp.modalWindow == nil else { return }
probe += 1
if CommandLine.arguments.contains("--queue-add-only") { finish(); return }
phase = 0
return
} }
phase += 1 phase += 1
} }
+77
View File
@@ -102,6 +102,7 @@ struct ResolvedPlaybackCheck {
model.shutdown() model.shutdown()
try await streamQueueLifecycle(source: source) try await streamQueueLifecycle(source: source)
try await enqueueLifecycle(source: source) try await enqueueLifecycle(source: source)
try await sidebarQueueLifecycle(source: source)
} }
} }
@@ -363,6 +364,82 @@ struct ResolvedPlaybackCheck {
print("PASS: repeated enqueue while paused/playing, standalone navigation, atomic conflicts, independent reports and empty-player start") print("PASS: repeated enqueue while paused/playing, standalone navigation, atomic conflicts, independent reports and empty-player start")
} }
static func sidebarQueueLifecycle(source: ResolvedSource) async throws {
let suite = "terebi.sidebar-queue-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let first = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
let second = URL(string: "https://www.youtube.com/watch?v=BBBBBBBBBBB")!
let episode = URL(string: "https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1")!
let failedURL = URL(string: "https://example.invalid/failure")!
let cancelledURL = URL(string: "https://example.invalid/cancelled")!
var requests = 0
var pending: CheckedContinuation<YouTubeVideo, Never>?
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, _ in
requests += 1
if url == failedURL { throw CocoaError(.fileReadCorruptFile) }
let video = YouTubeVideo(title: "Sidebar \(url == first ? "first" : "second")",
video: .init(url: source.url, headers: source.headers), audio: nil,
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
if url == cancelledURL { return await withCheckedContinuation { pending = $0 } }
return video
}, miruroResolver: { _, _ in source })
defer { model.shutdown() }
try await model.addStreamToQueue(first.absoluteString)
try await until("empty sidebar addition starts fetched video", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
}
require(requests == 1 && model.queue.currentURL == first, "empty addition uses fetched source without re-extracting")
require(model.setPlayback(playing: false), "pause before sidebar addition")
try await until("sidebar pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
let selected = model.queue.currentID
let revision = model.state.revision
let position = model.state.position
try await model.addStreamToQueue(" https://youtu.be/BBBBBBBBBBB \n")
try await model.addStreamToQueue(episode.absoluteString)
require(model.queue.items.map(\.url) == [first, second, episode] &&
model.queue.items[1].displayName == "Sidebar second" &&
model.queue.items[2].displayName == source.title &&
model.queue.currentID == selected && model.state.revision == revision &&
model.state.position == position && model.state.status == PLAYER_PAUSED,
"fetched YouTube and Miruro additions preserve paused playback, order and titles")
for text in ["not a URL", "file:///tmp/video.mp4"] {
do {
try await model.addStreamToQueue(text)
fatalError("FAIL: invalid sidebar URL accepted")
} catch {}
}
do {
try await model.addStreamToQueue(failedURL.absoluteString)
fatalError("FAIL: failed extraction accepted")
} catch {}
require(model.queue.items.map(\.url) == [first, second, episode] && model.queue.currentID == selected,
"invalid URLs and failed extraction leave queue untouched")
require(model.setPlayback(playing: true), "resume before sidebar addition")
try await until("sidebar resume acknowledged", model: model) { model.state.status == PLAYER_PLAYING }
let playingRevision = model.state.revision
try await model.addStreamToQueue(second.absoluteString)
require(model.state.revision == playingRevision && model.queue.currentID == selected &&
model.state.status == PLAYER_PLAYING, "addition does not restart playing media")
let cancelled = Task { try await model.addStreamToQueue(cancelledURL.absoluteString) }
while pending == nil { await Task.yield() }
cancelled.cancel()
pending!.resume(returning: YouTubeVideo(title: "Cancelled", video: .init(url: source.url, headers: source.headers),
audio: nil, duration: 0, isLive: false, webpageURL: cancelledURL, height: nil, availableHeights: []))
do {
try await cancelled.value
fatalError("FAIL: cancelled sidebar addition accepted")
} catch is CancellationError {}
require(model.queue.items.count == 4 && model.queue.currentID == selected,
"dismissed in-flight addition cannot append a late result")
model.selectQueueItem(model.queue.items[1].id)
try await until("sidebar-added item plays through native queue selection", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.queue.currentURL == second &&
model.fileName == "Sidebar second"
}
print("PASS: sidebar URL fetch/add, empty-player decode, paused/playing preservation, validation, failure, cancellation and selection")
}
static func segmentSkips(_ model: PlayerModel, clock: Clock, file: URL) async throws { static func segmentSkips(_ model: PlayerModel, clock: Clock, file: URL) async throws {
let opening = MiruroInterval(kind: .intro, start: 0.5, end: 1.5, episode: 1) let opening = MiruroInterval(kind: .intro, start: 0.5, end: 1.5, episode: 1)
let ending = MiruroInterval(kind: .outro, start: 2, end: model.duration, episode: 1) let ending = MiruroInterval(kind: .outro, start: 2, end: model.duration, episode: 1)
+3 -3
View File
@@ -21,9 +21,9 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self
## Usage ## Usage
- **Local files:** use **⌘O**, drag files onto the player, or choose Finder's **Open With**. Multiple files form a queue in natural filename order, with automatic advance. Use **View → Show Queue** to play, reorder or remove items; **+** or dropping files onto the sidebar appends them. The queue lasts only for the session; opening other media replaces it. - **Local files:** use **⌘O**, drag files onto the player, or choose Finder's **Open With**. Multiple files form a queue in natural filename order, with automatic advance. Use **View → Show Queue** to play, reorder or remove items; **+ → file icon** or dropping files onto the sidebar appends them. The queue lasts only for the session; opening other media replaces it.
- **Streams:** use **Open Stream…** (**⇧⌘O**) or drop an HTTP(S) video link. Links are passed to the bundled yt-dlp without a site allowlist; extraction must return a single streamable video, not a playlist or channel. Miruro keeps its dedicated resolver: links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number. - **Streams:** use **Open Stream…** (**⇧⌘O**) or drop an HTTP(S) video link. To append without replacing playback, press the sidebar's **+**, then the **link icon**: the URL field opens upwards; **Return** fetches and adds the video. The link icon toggles the field; **Esc** dismisses it and cancels a pending fetch. Fetch errors leave the queue unchanged. Links are passed to the bundled yt-dlp without a site allowlist; extraction must return a single streamable video, not a playlist or channel. Miruro keeps its dedicated resolver: links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number.
- **Stream queues:** integrations can supply an ordered yt-dlp/Miruro queue with `--new-instance --queue-file /absolute/queue.json`, or append without interrupting the running player using `--enqueue --queue-file /absolute/queue.json` (also for a single item). Enqueue starts playback if the player is empty or not running. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Only the selected item resolves, including fresh resolution on revisits; local-file batches still use natural filename order. yt-dlp items use the original page URL for resume and version-1 progress reports; recognized YouTube video URLs still canonicalize. - **Stream queues:** integrations can supply an ordered yt-dlp/Miruro queue with `--new-instance --queue-file /absolute/queue.json`, or append without interrupting the running player using `--enqueue --queue-file /absolute/queue.json` (also for a single item). Enqueue starts playback if the player is empty or not running. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Manifest entries resolve only when selected, including fresh resolution on revisits; local-file batches still use natural filename order. yt-dlp items use the original page URL for resume and version-1 progress reports; recognized YouTube video URLs still canonicalize.
- **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints. - **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints.
- **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. A light background fill grows as the offer expires, with a short entrance/exit transition; Reduce Motion uses stepped countdown updates without the transition. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Nothing skips automatically. - **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. A light background fill grows as the offer expires, with a short entrance/exit transition; Reduce Motion uses stepped countdown updates without the transition. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Nothing skips automatically.
- **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress. - **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
+155 -20
View File
@@ -374,6 +374,39 @@ final class PlayerModel: ObservableObject {
resolveStream(stream, change: .open) 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) { func openStreamQueue(_ streamQueue: StreamQueue) {
guard canOpen, progressInitializationError == nil else { return } guard canOpen, progressInitializationError == nil else { return }
queueCastDevice = nil queueCastDevice = nil
@@ -2500,6 +2533,12 @@ private struct PlayerContent: View {
private var showsYouTubeInput: Bool { model.showsYouTubeInput } private var showsYouTubeInput: Bool { model.showsYouTubeInput }
@ViewState private var youTubeURL = "" @ViewState private var youTubeURL = ""
@FocusState private var youTubeInputFocused: Bool @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 controlSize = CGSize(width: 440, height: 68)
@ViewState private var queueFrames: [UUID: CGRect] = [:] @ViewState private var queueFrames: [UUID: CGRect] = [:]
@ViewState private var queueDrag: PlaybackQueueDrag? @ViewState private var queueDrag: PlaybackQueueDrag?
@@ -2643,22 +2682,29 @@ private struct PlayerContent: View {
} }
private var startURLInput: some 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) { HStack(spacing: 8) {
TextField("Stream URL", text: $youTubeURL, TextField("Stream URL", text: text,
prompt: Text("Stream URL") prompt: Text("Stream URL")
.foregroundStyle(Color(SunsetPalette.secondaryText).opacity(0.6))) .foregroundStyle(Color(SunsetPalette.secondaryText).opacity(0.6)))
.textFieldStyle(.plain) .textFieldStyle(.plain)
.focused($youTubeInputFocused) .focused(focused)
.accessibilityLabel("Stream URL") .accessibilityLabel("Stream URL")
.onAppear { youTubeInputFocused = true } .onAppear { focused.wrappedValue = true }
.onSubmit { model.openStream(youTubeURL) } .onSubmit(submit)
Button { model.openStream(youTubeURL) } label: { Button(action: submit) {
Image(systemName: "arrow.right").frame(width: 24, height: 24).contentShape(Rectangle()) Image(systemName: "arrow.right").frame(width: 24, height: 24).contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Open Stream") .accessibilityLabel(action)
.help("Open Stream") .help(action)
.disabled(youTubeURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .disabled(text.wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} }
.font(.system(size: 12)) .font(.system(size: 12))
.padding(.horizontal, 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 { private var queueSidebar: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
ScrollView { ScrollView {
@@ -3136,6 +3274,11 @@ private struct PlayerContent: View {
.allowsHitTesting(false) .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) {
HStack(spacing: 4) { HStack(spacing: 4) {
Text("Queue") Text("Queue")
@@ -3143,18 +3286,7 @@ private struct PlayerContent: View {
} }
.font(.system(size: 13, weight: .medium)) .font(.system(size: 13, weight: .medium))
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
Button(action: model.addQueuePanel) { queueAddButton.padding(.leading, 4)
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)
Spacer() Spacer()
iconButton("backward.end.fill", "Previous Queue Item", size: 12) { model.advanceQueue(-1) } iconButton("backward.end.fill", "Previous Queue Item", size: 12) { model.advanceQueue(-1) }
.disabled(!model.canPreviousQueueItem) .disabled(!model.canPreviousQueueItem)
@@ -3166,6 +3298,8 @@ private struct PlayerContent: View {
.background(windowDragBackground) .background(windowDragBackground)
.background { queueChromeBackground(top: false) } .background { queueChromeBackground(top: false) }
} }
.animation(queueAddAnimation, value: showsQueueURLInput)
.onDisappear(perform: dismissQueueAdd)
.frame(width: 260) .frame(width: 260)
.background(queueBackground) .background(queueBackground)
.overlay(alignment: .top) { .overlay(alignment: .top) {
@@ -3217,6 +3351,7 @@ private struct PlayerContent: View {
.background(WindowChrome(model: model)) .background(WindowChrome(model: model))
.onChange(of: windowDragging) { _, active in model.setControlsDragging(active) } .onChange(of: windowDragging) { _, active in model.setControlsDragging(active) }
.onChange(of: showsYouTubeInput) { _, shown in youTubeInputFocused = shown } .onChange(of: showsYouTubeInput) { _, shown in youTubeInputFocused = shown }
.onChange(of: showsQueueURLInput) { _, shown in queueURLInputFocused = shown }
.overlay(alignment: .top) { .overlay(alignment: .top) {
if model.fileName != nil && showsYouTubeInput { if model.fileName != nil && showsYouTubeInput {
startURLInput startURLInput
+3 -2
View File
@@ -1,6 +1,7 @@
- [ ] in the playlist sidebar, pressing the `+` button by the queue indicator should it expand it to double it's size (with a nice transition) split down the middle with the left side being a file icon and the right side being a link icon. - [x] in the playlist sidebar, pressing the `+` button by the queue indicator should it expand it to double it's size (with a nice transition) split down the middle with the left side being a file icon and the right side being a link icon.
- pressing the file icon should open the file picker for local file selection (current behavior) - pressing the file icon should open the file picker for local file selection (current behavior)
- pressing the link icon should present an input field that opens upwards with a nice transition where the user can paste a link; pressing return will fetch and add the video. - pressing the link icon should present an input field that opens upwards with a nice transition where the user can paste a link; pressing return will fetch and add the video.
- we already have this ui effect in the start screen when pressing the "open url" button, which we should be able to reuse much of. - we already have this ui effect in the start screen when pressing the "open url" button, which we should be able to reuse much of.
- [ ] in compact mode, "side"bar should be expandable downwards (but hidden by default). - [ ] in compact mode, "side"bar should be expandable downwards (but hidden by default).
- let's also add a keybinding that toggles sidebar visibility in both normal and compact mode. - let's also add a keybinding that toggles sidebar visibility in both normal and compact mode.
- also add some horizontal margin to the list entries