feat: add expandable file and link controls to playlist
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
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)
|
||||
|
||||
@@ -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/<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.
|
||||
- **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/<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. 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.
|
||||
|
||||
+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
|
||||
|
||||
@@ -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.
|
||||
- also add some horizontal margin to the list entries
|
||||
Reference in New Issue
Block a user