From f1339050d9dc4c9f6aaad768085ae0e252224d2b Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Thu, 22 Oct 2026 17:47:32 +0200 Subject: [PATCH] feat: add expandable file and link controls to playlist --- Checks/app-startup.py | 19 ++++ Checks/app-startup.swift | 63 ++++++++++++ Checks/resolved-playback.swift | 77 +++++++++++++++ README.md | 6 +- Sources/App/TerebiApp.swift | 175 +++++++++++++++++++++++++++++---- TODO.md | 5 +- 6 files changed, 320 insertions(+), 25 deletions(-) diff --git a/Checks/app-startup.py b/Checks/app-startup.py index 4ec23db..69bf6ea 100644 --- a/Checks/app-startup.py +++ b/Checks/app-startup.py @@ -67,6 +67,22 @@ def check_window_drag(root, app, fixtures): 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): result_path = root / "queue-drag.json" 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) launch = None 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": check_cli_instances(root, app, fixtures) sys.exit(0) diff --git a/Checks/app-startup.swift b/Checks/app-startup.swift index 977536b..e60a65e 100644 --- a/Checks/app-startup.swift +++ b/Checks/app-startup.swift @@ -185,10 +185,12 @@ private enum WindowDragCheck { static func run(result: URL) { resultURL = result _ = NSApplication.shared + if CommandLine.arguments.contains("--queue-add-only") { probe = 12 } timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in MainActor.assumeIsolated { advance() } } RunLoop.main.add(timer!, forMode: .common) + RunLoop.main.add(timer!, forMode: .modalPanel) 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? { let content = window.contentView! let w = content.bounds.width @@ -303,6 +317,7 @@ private enum WindowDragCheck { interceptDrags(in: window) model.setVolume(0) model.setPlayback(playing: false) + if CommandLine.arguments.contains("--queue-add-only") { model.showsQueue = true } window.makeKeyAndOrderFront(nil) window.makeMain() NSApp.activate(ignoringOtherApps: true) @@ -365,6 +380,12 @@ private enum WindowDragCheck { phase = 7 return } + if probe == 12 { + urlWindowFrame = window.frame + clickQueueAdd(in: window, link: false) + phase = 7 + return + } probe += 1 if probe == names.count { finish(); return } phase = 0 @@ -387,6 +408,48 @@ private enum WindowDragCheck { probe += 1 phase = 0 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 } diff --git a/Checks/resolved-playback.swift b/Checks/resolved-playback.swift index 06bc1ba..e3e5b8c 100644 --- a/Checks/resolved-playback.swift +++ b/Checks/resolved-playback.swift @@ -102,6 +102,7 @@ struct ResolvedPlaybackCheck { model.shutdown() try await streamQueueLifecycle(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") } + 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? + 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 { 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) diff --git a/README.md b/README.md index fb9d07f..3ee13b5 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,9 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self ## 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. -- **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//?ep=` 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. +- **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. 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//?ep=` 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. 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. - **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. diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 8d43f93..9712e4f 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -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, focused: FocusState.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 diff --git a/TODO.md b/TODO.md index 3736cdf..909ba60 100644 --- a/TODO.md +++ b/TODO.md @@ -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 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. - [ ] 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. \ No newline at end of file + - 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 \ No newline at end of file