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))
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)
+63
View File
@@ -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
}
+77
View File
@@ -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)