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)
|
||||
|
||||
Reference in New Issue
Block a user