Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98e6dcb3c5 | |||
| 6ed7db8167 | |||
| f1339050d9 |
+29
-5
@@ -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"
|
||||
@@ -85,22 +101,24 @@ def check_queue_drag(root, app, fixtures):
|
||||
def check_window_layout(root, app, fixtures):
|
||||
result_path = root / "window-layout.json"
|
||||
diagnostics = root / "window-layout.log"
|
||||
compact_queue_only = os.environ.get("TEREBI_CHECK_COMPACT_QUEUE_ONLY") == "1"
|
||||
extra = ["--compact-queue-only"] if compact_queue_only else []
|
||||
result = subprocess.run(
|
||||
["open", "-g", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app),
|
||||
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-window-layout", str(result_path)],
|
||||
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-window-layout", str(result_path), *extra],
|
||||
capture_output=True, text=True, timeout=40,
|
||||
)
|
||||
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
|
||||
report = json.loads(result_path.read_text()) if result_path.exists() else {}
|
||||
expected_animations = 0 if report.get("reduceMotion") else 16
|
||||
expected_queue_animations = 0 if report.get("reduceMotion") else 9
|
||||
if (result.returncode != 0 or not report.get("passed") or report.get("steps") != 36
|
||||
expected_animations = 0 if report.get("reduceMotion") else (4 if compact_queue_only else 19)
|
||||
expected_queue_animations = 0 if report.get("reduceMotion") else (4 if compact_queue_only else 13)
|
||||
if (result.returncode != 0 or not report.get("passed") or report.get("steps") != 45
|
||||
or report.get("animatedTransitions") != expected_animations
|
||||
or report.get("animatedQueueTransitions") != expected_queue_animations):
|
||||
raise RuntimeError(f"native window layout failed: {report}; {result.stdout}{result.stderr}{log}")
|
||||
print(f"PASS native SwiftUI window layout: {report['animatedTransitions']} compact and "
|
||||
f"{report['animatedQueueTransitions']} queue animations, stable video size, responsive reversals, "
|
||||
"screen-edge fitting, fullscreen fixed bounds, minimum sizes and exact normal geometry restored")
|
||||
"downward compact queue with native shortcut and painted content, minimum sizes and exact normal geometry restored")
|
||||
|
||||
def check_startup_errors(root, app):
|
||||
config = root / "invalid-startup.toml"
|
||||
@@ -765,6 +783,12 @@ 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_COMPACT_QUEUE_ONLY") == "1":
|
||||
check_window_layout(root, app, fixtures)
|
||||
sys.exit(0)
|
||||
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)
|
||||
|
||||
+182
-4
@@ -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
|
||||
}
|
||||
@@ -527,6 +590,7 @@ private enum WindowLayoutCheck {
|
||||
static var step = 0
|
||||
static var windowIdentity: ObjectIdentifier?
|
||||
static var normalFrame = NSRect.zero
|
||||
static var compactFrame = NSRect.zero
|
||||
static var animationStart: NSRect?
|
||||
static var animationTarget = NSRect.zero
|
||||
static var sawIntermediateFrame = false
|
||||
@@ -535,6 +599,7 @@ private enum WindowLayoutCheck {
|
||||
static var queueVideoSize: NSSize?
|
||||
static var observingQueue = false
|
||||
static var animationQueueWidth: CGFloat = 0
|
||||
static var compactQueueOnly: Bool { CommandLine.arguments.contains("--compact-queue-only") }
|
||||
static var expectsAnimation: Bool { !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }
|
||||
// Infrastructure timeout only; assertions depend on native transition completion.
|
||||
static let deadline = ContinuousClock.now.advanced(by: .seconds(30))
|
||||
@@ -614,7 +679,7 @@ private enum WindowLayoutCheck {
|
||||
let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
|
||||
if model.compactWindow.isTransitioning || model.isQueueTransitioning {
|
||||
window.layoutIfNeeded()
|
||||
if model.isQueueTransitioning && !sidebarHeaderIsVisible(in: window) {
|
||||
if model.isQueueTransitioning && !model.isCompact && !sidebarHeaderIsVisible(in: window) {
|
||||
finish("Top-right controls left the window corner during queue animation at step \(step)"); return
|
||||
}
|
||||
if let size = queueVideoSize, let video = model.videoView {
|
||||
@@ -630,8 +695,8 @@ private enum WindowLayoutCheck {
|
||||
// These run-loop-delivered requests must remain responsive mid-animation.
|
||||
if model.isQueueTransitioning {
|
||||
let frame = window.frame
|
||||
model.showsQueue.toggle()
|
||||
model.showsQueue.toggle()
|
||||
model.perform(model.queueAction)
|
||||
model.perform(model.queueAction)
|
||||
guard sameFrame(window.frame, frame) else { finish("Queue reversal jumped the window"); return }
|
||||
} else {
|
||||
model.toggleCompact()
|
||||
@@ -657,9 +722,20 @@ private enum WindowLayoutCheck {
|
||||
guard ObjectIdentifier(window) == windowIdentity else { finish("Transition replaced the playback window"); return }
|
||||
if model.fullScreenIntent != nil { return }
|
||||
window.layoutIfNeeded()
|
||||
if compactQueueOnly && step == 0 {
|
||||
if !model.showsQueue {
|
||||
guard toggleQueue(model, in: window) else { finish("Normal queue shortcut was not handled"); return }
|
||||
return
|
||||
}
|
||||
step = 36
|
||||
}
|
||||
if model.showsQueue && !model.isCompact && !sidebarHeaderIsVisible(in: window) {
|
||||
finish("Sidebar pin, Cast or settings glyph is not visible at step \(step)"); return
|
||||
}
|
||||
if step >= 36 {
|
||||
advanceCompactQueue(model, window: window)
|
||||
return
|
||||
}
|
||||
if step >= 33 {
|
||||
switch step {
|
||||
case 33, 34:
|
||||
@@ -708,7 +784,9 @@ private enum WindowLayoutCheck {
|
||||
observeAnimation(from: window, to: target)
|
||||
observingQueue = true
|
||||
queueVideoSize = model.videoView?.bounds.size
|
||||
model.showsQueue = showsQueue
|
||||
guard toggleQueue(model, in: window), model.showsQueue == showsQueue else {
|
||||
finish("Native queue shortcut failed in normal mode"); return
|
||||
}
|
||||
}
|
||||
case 1, 5:
|
||||
window.setFrame(NSRect(x: 100, y: 200, width: 763, height: 368), display: true)
|
||||
@@ -737,6 +815,106 @@ private enum WindowLayoutCheck {
|
||||
}
|
||||
step += 1
|
||||
}
|
||||
static func toggleQueue(_ model: PlayerModel, in window: NSWindow, repeatKey: Bool = false) -> Bool {
|
||||
let event = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [.command, .shift],
|
||||
timestamp: 0, windowNumber: window.windowNumber, context: nil,
|
||||
characters: "L", charactersIgnoringModifiers: "l", isARepeat: repeatKey, keyCode: 37)!
|
||||
return model.handleKeyEvent(event, in: window)
|
||||
}
|
||||
|
||||
static func observeCompactQueue(_ model: PlayerModel, window: NSWindow) {
|
||||
let target = model.compactWindow.queueLayout(visible: !model.queueVisible)!
|
||||
observeAnimation(from: window, to: target.frame)
|
||||
observingQueue = true
|
||||
queueVideoSize = model.videoView?.bounds.size
|
||||
if !toggleQueue(model, in: window) { finish("Native queue shortcut was not handled") }
|
||||
}
|
||||
|
||||
static func compactQueueHasPaintedContent(_ model: PlayerModel, window: NSWindow) -> Bool {
|
||||
guard let content = window.contentView else { return false }
|
||||
let height = model.compactWindow.queueHeight
|
||||
let region = NSRect(x: 0, y: content.isFlipped ? content.bounds.maxY - height : 0,
|
||||
width: content.bounds.width, height: height)
|
||||
guard let bitmap = content.bitmapImageRepForCachingDisplay(in: region) else { return false }
|
||||
content.cacheDisplay(in: region, to: bitmap)
|
||||
var textPixels = 0
|
||||
for x in 0..<bitmap.pixelsWide {
|
||||
for y in 0..<bitmap.pixelsHigh {
|
||||
if let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB),
|
||||
color.alphaComponent > 0.5,
|
||||
min(color.redComponent, color.greenComponent, color.blueComponent) > 0.5 {
|
||||
textPixels += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return textPixels > 30
|
||||
}
|
||||
|
||||
static func advanceCompactQueue(_ model: PlayerModel, window: NSWindow) {
|
||||
switch step {
|
||||
case 36, 42:
|
||||
guard !model.isFullScreen, !model.isCompact else { finish("Expanded compact/fullscreen restoration failed"); return }
|
||||
if step == 42, !sameFrame(window.frame, normalFrame) {
|
||||
finish("Expanded queue corrupted the normal restoration frame"); return
|
||||
}
|
||||
normalFrame = window.frame
|
||||
observeAnimation(from: window, to: CompactWindowController.initialFrame(
|
||||
aspect: model.state.video_aspect_ratio,
|
||||
visibleFrame: (window.screen ?? NSScreen.main!).visibleFrame, width: 320))
|
||||
model.toggleCompact()
|
||||
case 37, 43:
|
||||
guard model.isCompact, !model.queueVisible, model.compactWindow.queueHeight == 0 else {
|
||||
finish("Compact entry did not default to a hidden queue"); return
|
||||
}
|
||||
compactFrame = window.frame
|
||||
observeCompactQueue(model, window: window)
|
||||
case 38, 40, 44:
|
||||
let height = model.compactWindow.queueHeight
|
||||
guard model.queueVisible, height > 0, model.showsQueue,
|
||||
abs(window.frame.maxY - compactFrame.maxY) < 1,
|
||||
abs(window.frame.width / (window.frame.height - height) - model.state.video_aspect_ratio) < 0.01,
|
||||
let video = model.videoView, abs(video.bounds.height - compactFrame.height) < 2 else {
|
||||
finish("Compact queue did not expand below the unchanged video at step \(step)"); return
|
||||
}
|
||||
guard compactQueueHasPaintedContent(model, window: window) else {
|
||||
finish("Expanded compact queue did not paint its native rows and footer"); return
|
||||
}
|
||||
guard toggleQueue(model, in: window, repeatKey: true), model.queueVisible else {
|
||||
finish("Held queue shortcut toggled repeatedly"); return
|
||||
}
|
||||
if step == 38 {
|
||||
observeCompactQueue(model, window: window)
|
||||
} else if step == 40 {
|
||||
if compactQueueOnly {
|
||||
observeAnimation(from: window, to: normalFrame)
|
||||
model.toggleCompact()
|
||||
step = 41
|
||||
} else {
|
||||
// The explicit fullscreen action must discard dropdown geometry before entering the native Space.
|
||||
model.toggleFullScreen()
|
||||
}
|
||||
} else {
|
||||
observeAnimation(from: window, to: normalFrame)
|
||||
model.toggleCompact()
|
||||
}
|
||||
case 39:
|
||||
guard !model.queueVisible, sameFrame(window.frame, compactFrame) else {
|
||||
finish("Compact queue did not collapse to its original video frame"); return
|
||||
}
|
||||
observeCompactQueue(model, window: window)
|
||||
case 41:
|
||||
guard model.isFullScreen, !model.isCompact, model.compactWindow.queueHeight == 0 else {
|
||||
finish("Fullscreen retained compact dropdown geometry"); return
|
||||
}
|
||||
model.toggleFullScreen()
|
||||
default:
|
||||
guard !model.isCompact, model.queueVisible, sameFrame(window.frame, normalFrame) else {
|
||||
finish("Normal queue visibility or restoration frame was lost"); return
|
||||
}
|
||||
finish(); return
|
||||
}
|
||||
step += 1
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -96,11 +96,11 @@ struct CompactWindowChecks {
|
||||
|
||||
// Run the real AppKit completion callbacks. The monotonic deadline is only a hang watchdog.
|
||||
static func settled(_ controller: CompactWindowController, compact: Bool,
|
||||
checking: () throws -> Void = {}) throws {
|
||||
line: UInt = #line, checking: () throws -> Void = {}) throws {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
while controller.isTransitioning {
|
||||
try checking()
|
||||
try require(ContinuousClock.now < deadline, "native compact transition completes")
|
||||
try require(ContinuousClock.now < deadline, "native compact transition completes (call at line \(line), compact \(controller.isCompact), target \(compact))")
|
||||
CFRunLoopRunInMode(.defaultMode, 0.01, false)
|
||||
}
|
||||
try checking()
|
||||
@@ -504,12 +504,73 @@ struct CompactWindowChecks {
|
||||
}
|
||||
}
|
||||
|
||||
static func compactQueue() throws {
|
||||
let fixture = try WindowFixture()
|
||||
let controller = CompactWindowController(reduceMotion: { true })
|
||||
let window = fixture.window
|
||||
defer {
|
||||
controller.restore(resumeFullScreen: false, animated: false)
|
||||
window.close()
|
||||
}
|
||||
controller.attach(window)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.queueHeight == 0, "compact entry hides the queue")
|
||||
let videoFrame = window.frame
|
||||
let expanded = controller.queueLayout(visible: true)!
|
||||
controller.updateQueue(height: expanded.height, frame: expanded.frame)
|
||||
try require(close(window.frame.maxY, videoFrame.maxY) && close(window.frame.width, videoFrame.width)
|
||||
&& close(window.frame.height - controller.queueHeight, videoFrame.height),
|
||||
"queue expands downwards without moving or resizing the compact video")
|
||||
try require(window.aspectRatio == .zero && close(window.minSize.height, 90 + controller.queueHeight),
|
||||
"expanded queue constrains video, not the combined window aspect")
|
||||
for horizontal in [true, false] {
|
||||
let start = window.frame
|
||||
window.delegate?.windowWillStartLiveResize?(Notification(name: NSWindow.willStartLiveResizeNotification, object: window))
|
||||
for offset in [CGFloat(20), 21, 22, 21, 0, -10] {
|
||||
let proposed = NSSize(width: start.width + (horizontal ? offset : 0),
|
||||
height: start.height + (horizontal ? 0 : offset))
|
||||
let size = window.delegate!.windowWillResize!(window, to: proposed)
|
||||
try require(close(size.width / (size.height - controller.queueHeight), 16.0 / 9.0),
|
||||
"both resize axes preserve the compact video's aspect with queue open")
|
||||
try require(close(horizontal ? size.width : size.height, horizontal ? proposed.width : proposed.height),
|
||||
"queue resize follows the chosen edge continuously")
|
||||
window.setFrame(NSRect(origin: start.origin, size: size), display: false)
|
||||
}
|
||||
window.delegate?.windowDidEndLiveResize?(Notification(name: NSWindow.didEndLiveResizeNotification, object: window))
|
||||
}
|
||||
controller.updateAspect(4.0 / 3.0)
|
||||
try require(abs(window.frame.height - controller.queueHeight - window.frame.width / (4.0 / 3.0)) <= 1,
|
||||
"metadata changes aspect only in the video above the queue: \(window.frame), queue \(controller.queueHeight)")
|
||||
let hidden = controller.queueLayout(visible: false)!
|
||||
let top = window.frame.maxY
|
||||
controller.updateQueue(height: hidden.height, frame: hidden.frame)
|
||||
try require(controller.queueHeight == 0 && close(window.frame.maxY, top)
|
||||
&& abs(window.frame.height - window.frame.width / (4.0 / 3.0)) <= 1,
|
||||
"hiding the queue contracts upwards and restores native aspect locking")
|
||||
var atBottom = window.frame
|
||||
atBottom.origin.y = (window.screen ?? NSScreen.main!).visibleFrame.minY + 16
|
||||
window.setFrame(atBottom, display: false)
|
||||
let bounded = controller.queueLayout(visible: true)!
|
||||
controller.updateQueue(height: bounded.height, frame: bounded.frame)
|
||||
try require(window.frame.minY >= (window.screen ?? NSScreen.main!).visibleFrame.minY + 16
|
||||
&& abs(window.frame.height - controller.queueHeight - window.frame.width / (4.0 / 3.0)) <= 1,
|
||||
"queue remains onscreen at the lower edge without distorting the video")
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try fixture.restored()
|
||||
controller.toggle(aspect: 4.0 / 3.0)
|
||||
try require(controller.queueHeight == 0, "each compact entry resets queue visibility")
|
||||
try fixture.compact(aspect: 4.0 / 3.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try fixture.restored()
|
||||
}
|
||||
|
||||
static func main() throws {
|
||||
try geometry()
|
||||
try windowBehavior()
|
||||
try reducedMotion()
|
||||
try rememberedSize()
|
||||
try continuousQueueResize()
|
||||
try compactQueue()
|
||||
print("Compact window checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +43,16 @@ struct ConfigurationChecks {
|
||||
try require(PlayerAction(name: "open_youtube") == nil, "obsolete YouTube action has no alias")
|
||||
try require(bindings.shortcuts(for: .openStream) == [try KeyChord("cmd+shift+o")],
|
||||
"stream action preserves its shortcut")
|
||||
try require(PlayerAction(name: "show_queue") == .showQueue && PlayerAction.hideQueue.name == "hide_queue",
|
||||
"queue visibility actions round-trip through configuration")
|
||||
try require(Set(bindings.actions(for: try KeyChord("cmd+shift+l"))) == [.showQueue, .hideQueue]
|
||||
&& !PlayerAction.showQueue.allowsRepeat && !PlayerAction.hideQueue.allowsRepeat,
|
||||
"queue shortcut toggles without auto-repeat")
|
||||
try requireError(["open_youtube"]) { _ = try KeyBindings(overrides: ["open_youtube": []]) }
|
||||
let inversePairs: [(PlayerAction, PlayerAction)] = [
|
||||
(.play, .pause), (.mute, .unmute), (.enterFullscreen, .exitFullscreen),
|
||||
(.enterCompact, .exitCompact), (.enableAlwaysOnTop, .disableAlwaysOnTop),
|
||||
(.showQueue, .hideQueue),
|
||||
(.showBufferInfo, .hideBufferInfo), (.enableCompactSnapping, .disableCompactSnapping),
|
||||
(.enableAutomaticQuality, .disableAutomaticQuality)
|
||||
]
|
||||
|
||||
@@ -111,7 +111,7 @@ struct PlaybackQueueChecks {
|
||||
require(queue.items.map(\.url) == [miruro, youtube], "stream order is preserved without file filtering or sorting")
|
||||
require(queue.currentItem?.displayName == "Episode 2", "stream rows have descriptive labels")
|
||||
let streamID = queue.currentID!
|
||||
queue.updateCurrentLabel("Resolved episode title")
|
||||
queue.updateLabel("Resolved episode title", for: streamID)
|
||||
require(queue.currentID == streamID && queue.currentItem?.displayName == "Resolved episode title",
|
||||
"metadata updates labels without changing queue identity")
|
||||
require(queue.advance(1) == youtube && queue.advance(-1) == miruro, "stream navigation uses supplied order")
|
||||
|
||||
@@ -50,6 +50,12 @@ struct ResolvedPlaybackCheck {
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let clock = Clock()
|
||||
let model = PlayerModel(defaults: defaults, progressFile: CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : nil,
|
||||
miruroMetadataResolver: { id, episode in
|
||||
if input.hasPrefix("https://") {
|
||||
return try await MiruroMetadataResolver.resolve(mediaID: id, episode: episode)
|
||||
}
|
||||
return MiruroMetadata(title: nil, intervals: [])
|
||||
},
|
||||
skipClock: { clock.now })
|
||||
defer { model.shutdown() }
|
||||
if input.hasPrefix("https://") { model.openStream(input) }
|
||||
@@ -100,11 +106,118 @@ struct ResolvedPlaybackCheck {
|
||||
if !input.hasPrefix("https://") { try await segmentSkips(model, clock: clock, file: file) }
|
||||
if !input.hasPrefix("https://") {
|
||||
model.shutdown()
|
||||
try await queueMetadataLifecycle(source: source)
|
||||
try await streamQueueLifecycle(source: source)
|
||||
try await enqueueLifecycle(source: source)
|
||||
try await sidebarQueueLifecycle(source: source)
|
||||
}
|
||||
}
|
||||
|
||||
static func queueMetadataLifecycle(source: ResolvedSource) async throws {
|
||||
let suite = "terebi.queue-metadata-check." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let urls = ["AAAAAAAAAAA", "BBBBBBBBBBB", "CCCCCCCCCCC", "DDDDDDDDDDD"].map {
|
||||
URL(string: "https://www.youtube.com/watch?v=\($0)")!
|
||||
}
|
||||
let episode = URL(string: "https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1")!
|
||||
var foreground: CheckedContinuation<YouTubeVideo, Never>?
|
||||
var background: CheckedContinuation<YouTubeVideo, Never>?
|
||||
var holdFirst = true
|
||||
var episodeRequests = 0
|
||||
func video(_ url: URL, title: String) -> YouTubeVideo {
|
||||
YouTubeVideo(title: title, video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
||||
}
|
||||
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, _ in
|
||||
if url == urls[0], holdFirst {
|
||||
holdFirst = false
|
||||
return await withCheckedContinuation { foreground = $0 }
|
||||
}
|
||||
if url == urls[1], Task.currentPriority <= .utility {
|
||||
return await withCheckedContinuation { background = $0 }
|
||||
}
|
||||
if url == urls[2] { throw CocoaError(.fileReadCorruptFile) }
|
||||
return video(url, title: url == urls[0] ? "Playing title" : "Fresh playback title")
|
||||
}, miruroResolver: { _, _ in
|
||||
fatalError("FAIL: sidebar enrichment must not fetch Miruro playback sources")
|
||||
}, miruroMetadataResolver: { _, _ in
|
||||
episodeRequests += 1
|
||||
return MiruroMetadata(title: "Queued episode", intervals: [])
|
||||
})
|
||||
defer { model.shutdown() }
|
||||
func queue(_ entries: [URL]) -> StreamQueue {
|
||||
StreamQueue(entries: entries.map { url in
|
||||
.init(url: url, identity: url == episode ? source.identity : url.absoluteString,
|
||||
progressFile: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString),
|
||||
label: url.absoluteString)
|
||||
})
|
||||
}
|
||||
model.openStreamQueue(queue([urls[0], urls[1], episode, urls[2], urls[3]]))
|
||||
try await until("foreground extraction starts first", model: model) { foreground != nil }
|
||||
require(background == nil && episodeRequests == 0, "sidebar lookups yield to selected extraction")
|
||||
foreground!.resume(returning: video(urls[0], title: "Playing title"))
|
||||
foreground = nil
|
||||
try await until("native playback and background extraction start", model: model) {
|
||||
!model.isBusy && model.state.status == PLAYER_PLAYING && background != nil
|
||||
}
|
||||
require(episodeRequests == 0, "sidebar lookups are serial")
|
||||
require(model.setPlayback(playing: false), "pause for background title resolution")
|
||||
try await until("metadata smoke pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||
let selected = model.queue.currentID
|
||||
let position = model.state.position
|
||||
let pendingID = model.queue.items[1].id
|
||||
model.moveQueueItems(fromOffsets: IndexSet(integer: 1), toOffset: 5)
|
||||
background!.resume(returning: video(urls[1], title: "Queued title"))
|
||||
background = nil
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(2))
|
||||
while model.queue.items.last?.displayName != "Queued title", ContinuousClock.now < deadline {
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
require(model.queue.items.last?.id == pendingID && model.queue.items.last?.displayName == "Queued title",
|
||||
"unplayed queue entry resolves by identity after reorder")
|
||||
try await until("mixed queue metadata continues after failure", model: model) {
|
||||
model.queue.items.first { $0.url == episode }?.displayName == "Queued episode" &&
|
||||
model.queue.items.first { $0.url == urls[3] }?.displayName == "Fresh playback title"
|
||||
}
|
||||
require(model.queue.items.first { $0.url == urls[2] }?.displayName == urls[2].absoluteString &&
|
||||
model.commandError == nil, "failed enrichment preserves fallback and does not block later titles")
|
||||
require(model.queue.currentID == selected &&
|
||||
model.state.position == position && model.state.status == PLAYER_PAUSED &&
|
||||
model.fileName == "Playing title", "background metadata preserves selection, position, paused state and current title")
|
||||
|
||||
model.openStreamQueue(queue([urls[0], urls[1]]))
|
||||
try await until("next background lookup starts", model: model) { background != nil }
|
||||
let cancelled = background!
|
||||
background = nil
|
||||
model.selectQueueItem(model.queue.items[1].id)
|
||||
try await until("selection preempts pending metadata with fresh playback extraction", model: model) {
|
||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == "Fresh playback title"
|
||||
}
|
||||
cancelled.resume(returning: video(urls[1], title: "Stale title"))
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
require(model.queue.currentItem?.displayName == "Fresh playback title" &&
|
||||
model.fileName == "Fresh playback title", "cancelled metadata cannot overwrite selected title")
|
||||
|
||||
model.openStreamQueue(queue([urls[0], urls[1]]))
|
||||
try await until("removable background lookup starts", model: model) { background != nil }
|
||||
let removed = background!
|
||||
background = nil
|
||||
model.removeQueueItem(model.queue.items[1].id)
|
||||
model.openStreamQueue(queue([urls[0], urls[1]]))
|
||||
try await until("replacement background lookup starts", model: model) { background != nil }
|
||||
removed.resume(returning: video(urls[1], title: "Removed title"))
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
require(model.queue.items[1].displayName == urls[1].absoluteString,
|
||||
"removed/replaced item cannot rename a new row for the same URL")
|
||||
background!.resume(returning: video(urls[1], title: "Replacement title"))
|
||||
background = nil
|
||||
try await until("replacement result updates its own row", model: model) {
|
||||
model.queue.items[1].displayName == "Replacement title"
|
||||
}
|
||||
print("PASS: background mixed-stream titles, utility priority, serial lookup, reorder, failure, playback preemption and stale removal/replacement")
|
||||
}
|
||||
|
||||
static func streamQueueLifecycle(source: ResolvedSource) async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("terebi.queue-check." + UUID().uuidString).resolvingSymlinksInPath()
|
||||
@@ -129,15 +242,17 @@ struct ResolvedPlaybackCheck {
|
||||
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||
duration: 0, isLive: false, webpageURL: youtubeURL, height: nil, availableHeights: [])
|
||||
let model = PlayerModel(defaults: defaults, streamQueue: queue, youtubeResolver: { _, _ in
|
||||
youtubeRequests += 1
|
||||
if youtubeRequests == 1 {
|
||||
return await withCheckedContinuation { stale = $0 }
|
||||
if Task.currentPriority > .utility {
|
||||
youtubeRequests += 1
|
||||
if youtubeRequests == 1 {
|
||||
return await withCheckedContinuation { stale = $0 }
|
||||
}
|
||||
}
|
||||
return video
|
||||
}, miruroResolver: { _, _ in
|
||||
episodeRequests += 1
|
||||
return source
|
||||
})
|
||||
}, miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||
defer { model.shutdown() }
|
||||
require(model.progressInitializationError == nil, "queue report registration")
|
||||
model.openStreamQueue(queue)
|
||||
@@ -234,7 +349,8 @@ struct ResolvedPlaybackCheck {
|
||||
return YouTubeVideo(title: url.absoluteString,
|
||||
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
||||
}, miruroResolver: { _, _ in episodeRequests += 1; return source })
|
||||
}, miruroResolver: { _, _ in episodeRequests += 1; return source },
|
||||
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||
defer { model.shutdown() }
|
||||
func deliver(_ items: [(URL, URL)]) throws {
|
||||
let document: [String: Any] = ["version": 1, "items": items.map {
|
||||
@@ -264,7 +380,7 @@ struct ResolvedPlaybackCheck {
|
||||
try deliver([(fourth, fourthReport)])
|
||||
require(model.queue.items.map(\.url) == [first, second, third, fourth] &&
|
||||
model.queue.currentID == selected && model.state.revision == playingRevision &&
|
||||
model.state.status == PLAYER_PLAYING && requests == 1 && episodeRequests == 0,
|
||||
model.state.status == PLAYER_PLAYING,
|
||||
"repeated native delivery appends while playing without reopening current media")
|
||||
try deliver([(third, directory.appendingPathComponent("conflict.jsonl"))])
|
||||
require(model.commandError != nil && model.queue.items.count == 4 && model.queue.currentID == selected,
|
||||
@@ -363,6 +479,83 @@ 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 },
|
||||
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||
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,13 +21,13 @@ 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. Queued titles load serially in the background at lower priority, yielding during playback loading, buffering and quality changes. Failed lookups retain the supplied label. Playback sources still resolve fresh when selected or revisited; 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.
|
||||
- **Compact mode:** press **P** for a floating player; double-click it to restore. Drag noninteractive areas to move the window. Use the titlebar pin to keep the normal window on top.
|
||||
- **Compact mode:** press **P** for a floating player; double-click it to restore. Drag noninteractive areas to move the window. The queue starts hidden each time you enter compact mode; **⇧⌘L** or **gear → Show Queue** expands it below the video. The same shortcut toggles the normal sidebar, whose visibility is preserved when leaving compact mode. Use the titlebar pin to keep the normal window on top.
|
||||
- **Quality:** yt-dlp defaults to SDR up to 1080p when resolution is reported. Missing resolution/codec hints are left to native probing. Use **gear → Quality** for other reported resolutions or automatic quality.
|
||||
|
||||
### Keyboard shortcuts
|
||||
@@ -42,6 +42,7 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self
|
||||
| Mute / unmute | M |
|
||||
| Fullscreen | F or ⌃⌘F; Esc to leave |
|
||||
| Compact mode | P or ⌃⌘P |
|
||||
| Show/hide queue (normal or compact) | ⇧⌘L |
|
||||
| Always on top | ⇧P |
|
||||
| Close media | ⇧⌘W |
|
||||
| Quit | Q or ⌘Q |
|
||||
@@ -78,6 +79,8 @@ terebi --default-config
|
||||
|
||||
Copy the settings you want to change into the config file; omitted settings keep their defaults. Settings load at startup, so restart the player after editing. Changes made through menus are session-only.
|
||||
|
||||
The queue shortcut can be rebound with `show_queue` and `hide_queue` under `[keys]`; give both actions the same chord to keep a single toggle.
|
||||
|
||||
## Limitations
|
||||
|
||||
- SDR output only; HDR is not supported.
|
||||
@@ -89,3 +92,5 @@ Copy the settings you want to change into the config file; omitted settings keep
|
||||
## Development
|
||||
|
||||
Install FFmpeg for test fixtures (`brew install ffmpeg`), then run `make check`. The suite requires a graphical macOS session and a working audio device; it launches players and plays short test audio. See the [Makefile](Makefile) for individual check targets.
|
||||
|
||||
For the focused native compact-queue smoke (transparent, noninteractive app window), run `TEREBI_CHECK_COMPACT_QUEUE_ONLY=1 make check-startup`. `make check-window check-config` covers dropdown geometry, resizing, metadata aspect changes, restoration and configurable queue shortcuts without displaying windows.
|
||||
|
||||
@@ -7,6 +7,7 @@ final class CompactWindowController: NSObject {
|
||||
var normalVideoLayout: (() -> (aspect: Double, sidebarWidth: CGFloat)?)?
|
||||
private(set) var isCompact = false
|
||||
private(set) var isTransitioning = false
|
||||
private(set) var queueHeight: CGFloat = 0
|
||||
static let transitionDuration: TimeInterval = 0.25
|
||||
|
||||
var snappingEnabled: Bool {
|
||||
@@ -59,6 +60,7 @@ final class CompactWindowController: NSObject {
|
||||
private var originatedFullScreen = false
|
||||
private var videoAspect = 16.0 / 9.0
|
||||
private var fittedAspect: Double?
|
||||
private var fittedVideoAspect = 16.0 / 9.0
|
||||
private let reduceMotion: () -> Bool
|
||||
private let defaults: UserDefaults
|
||||
private static let widthKey = "compactWindowWidth"
|
||||
@@ -135,6 +137,34 @@ final class CompactWindowController: NSObject {
|
||||
fitCompactWindow(window, initial: false)
|
||||
}
|
||||
|
||||
// The reveal and native frame share the model's animation clock.
|
||||
func queueLayout(visible: Bool) -> (height: CGFloat, frame: NSRect)? {
|
||||
guard isCompact, !isTransitioning, let window else { return nil }
|
||||
let usable = Self.usableFrame(visibleFrame(for: window))
|
||||
let height = visible ? min(240, max(0, usable.height - 90)) : 0
|
||||
let video = Self.compactSize(aspect: videoAspect, width: window.frame.width,
|
||||
available: NSSize(width: usable.width, height: usable.height - height))
|
||||
let size = NSSize(width: video.width, height: video.height + height)
|
||||
let frame = NSRect(x: min(max(window.frame.minX, usable.minX), usable.maxX - size.width),
|
||||
y: max(usable.minY, min(window.frame.maxY, usable.maxY) - size.height),
|
||||
width: size.width, height: size.height)
|
||||
return (height, frame)
|
||||
}
|
||||
|
||||
func updateQueue(height: CGFloat, frame: NSRect) {
|
||||
guard isCompact, let window else { return }
|
||||
window.resizeIncrements = NSSize(width: 1, height: 1)
|
||||
queueHeight = height
|
||||
fittedVideoAspect = frame.width / (frame.height - height)
|
||||
window.minSize = NSSize(width: 160, height: 90 + height)
|
||||
window.contentMinSize = window.minSize
|
||||
window.setFrame(frame, display: true)
|
||||
if height == 0, Self.canFitAspect(videoAspect, available: Self.usableFrame(visibleFrame(for: window)).size) {
|
||||
window.aspectRatio = NSSize(width: videoAspect, height: 1)
|
||||
}
|
||||
onChange?()
|
||||
}
|
||||
|
||||
func snapAfterDrag() {
|
||||
guard isCompact, !isTransitioning, snappingEnabled, let window else { return }
|
||||
let frame = Self.snappedFrame(window.frame, visibleFrame: visibleFrame(for: window))
|
||||
@@ -229,12 +259,23 @@ final class CompactWindowController: NSObject {
|
||||
|
||||
func windowWillResize(_ sender: NSWindow, to proposedSize: NSSize) -> NSSize {
|
||||
let proposed = original?.windowWillResize?(sender, to: proposedSize) ?? proposedSize
|
||||
guard let owner, !owner.isCompact, !owner.isTransitioning,
|
||||
!sender.styleMask.contains(.fullScreen),
|
||||
let layout = owner.normalVideoLayout?(),
|
||||
layout.aspect.isFinite, layout.aspect > 0 else { return proposed }
|
||||
guard let owner, !owner.isTransitioning,
|
||||
!sender.styleMask.contains(.fullScreen) else { return proposed }
|
||||
let layout: (aspect: Double, sidebarWidth: CGFloat)
|
||||
let extraHeight: CGFloat
|
||||
if owner.isCompact {
|
||||
guard owner.queueHeight > 0 else { return proposed }
|
||||
layout = (owner.fittedVideoAspect, 0)
|
||||
extraHeight = owner.queueHeight
|
||||
} else {
|
||||
guard let normal = owner.normalVideoLayout?(),
|
||||
normal.aspect.isFinite, normal.aspect > 0 else { return proposed }
|
||||
layout = normal
|
||||
extraHeight = 0
|
||||
}
|
||||
// The sidebar stays fixed-width; only the video area is aspect-locked.
|
||||
let chrome = sender.frame.height - sender.contentRect(forFrameRect: sender.frame).height
|
||||
let chrome = owner.isCompact ? extraHeight
|
||||
: sender.frame.height - sender.contentRect(forFrameRect: sender.frame).height
|
||||
let start = resizeStartSize ?? sender.frame.size
|
||||
let widthChange = abs(proposed.width - start.width)
|
||||
let heightChange = abs(proposed.height - start.height) * layout.aspect
|
||||
@@ -291,6 +332,7 @@ final class CompactWindowController: NSObject {
|
||||
return
|
||||
}
|
||||
if let snapshot {
|
||||
queueHeight = 0
|
||||
setState(compact: false, transitioning: animated)
|
||||
window.aspectRatio = .zero
|
||||
window.minSize = .zero
|
||||
@@ -318,6 +360,7 @@ final class CompactWindowController: NSObject {
|
||||
private func enterCompact(_ window: NSWindow, animated: Bool) {
|
||||
// After didExitFullScreen, this is the normal frame, not the fullscreen frame.
|
||||
snapshot = Snapshot(window)
|
||||
queueHeight = 0
|
||||
setState(compact: true, transitioning: animated)
|
||||
window.aspectRatio = .zero
|
||||
window.minSize = .zero
|
||||
@@ -422,18 +465,22 @@ final class CompactWindowController: NSObject {
|
||||
let aspect = videoAspect
|
||||
let savedWidth = initial ? defaults.double(forKey: Self.widthKey) : Double(window.frame.width)
|
||||
let width = savedWidth.isFinite && savedWidth > 0 ? CGFloat(savedWidth) : 320
|
||||
var frame = Self.initialFrame(aspect: videoAspect, visibleFrame: visibleFrame, width: width)
|
||||
let video = Self.compactSize(aspect: videoAspect, width: width,
|
||||
available: NSSize(width: usable.width, height: max(0, usable.height - queueHeight)))
|
||||
var frame = NSRect(x: usable.maxX - video.width, y: usable.maxY - video.height - queueHeight,
|
||||
width: video.width, height: video.height + queueHeight)
|
||||
if !initial {
|
||||
frame.origin.x = min(max(window.frame.minX, usable.minX), usable.maxX - frame.width)
|
||||
frame.origin.y = min(max(window.frame.minY, usable.minY), usable.maxY - frame.height)
|
||||
frame.origin.y = max(usable.minY, min(window.frame.maxY, usable.maxY) - frame.height)
|
||||
}
|
||||
window.resizeIncrements = NSSize(width: 1, height: 1)
|
||||
animateFrame(frame, of: window, animated: animated) { [weak self, weak window] in
|
||||
guard let self, let window else { return }
|
||||
if Self.canFitAspect(aspect, available: usable.size) {
|
||||
if self.queueHeight == 0, Self.canFitAspect(aspect, available: usable.size) {
|
||||
window.aspectRatio = NSSize(width: aspect, height: 1)
|
||||
}
|
||||
self.fittedAspect = aspect
|
||||
self.fittedVideoAspect = video.width / video.height
|
||||
if initial { self.reconcile() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ struct PlaybackQueue {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
var label: String? = nil
|
||||
var metadataAttempted = false
|
||||
var displayName: String { label ?? url.lastPathComponent }
|
||||
}
|
||||
|
||||
@@ -31,9 +32,10 @@ struct PlaybackQueue {
|
||||
items.append(contentsOf: entries.map { Item(url: $0.url, label: $0.label) })
|
||||
}
|
||||
|
||||
mutating func updateCurrentLabel(_ label: String) {
|
||||
guard let index = currentIndex else { return }
|
||||
items[index].label = label
|
||||
mutating func updateLabel(_ label: String?, for id: UUID) {
|
||||
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
|
||||
if let label { items[index].label = label }
|
||||
items[index].metadataAttempted = true
|
||||
}
|
||||
|
||||
mutating func append(_ urls: [URL]) {
|
||||
|
||||
@@ -282,8 +282,9 @@ struct PlayerCommands: Commands {
|
||||
.disabled(!hasFocusedScene || !model.canPerform(bufferInfo))
|
||||
}
|
||||
CommandGroup(replacing: .sidebar) {
|
||||
Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() }
|
||||
.disabled(!hasFocusedScene || model.isCompact)
|
||||
Button(model.queueVisible ? "Hide Queue" : "Show Queue") { model.performMenuAction(model.queueAction) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: model.queueAction).first?.keyboardShortcut)
|
||||
.disabled(!hasFocusedScene || !model.canPerform(model.queueAction))
|
||||
Button(fullscreen == .exitFullscreen ? "Exit Full Screen" : "Enter Full Screen") {
|
||||
model.performMenuAction(fullscreen)
|
||||
}
|
||||
|
||||
+285
-35
@@ -34,14 +34,21 @@ final class PlayerModel: ObservableObject {
|
||||
@Published var showsYouTubeInput = false
|
||||
@Published private(set) var fileIcon: NSImage?
|
||||
@Published private(set) var commandError: String?
|
||||
@Published private(set) var queue = PlaybackQueue()
|
||||
@Published private(set) var queue = PlaybackQueue() {
|
||||
didSet { queueMetadataPending = true }
|
||||
}
|
||||
@Published private(set) var recentMedia: [PlaybackProgress.RecentMedia] = []
|
||||
@Published private(set) var startContentHeight: CGFloat = 400
|
||||
@Published var showsQueue = false {
|
||||
didSet {
|
||||
if showsQueue != oldValue { transitionQueue() }
|
||||
guard showsQueue != oldValue else { return }
|
||||
if isCompact { queueWidth = showsQueue ? 260 : 0 }
|
||||
else { transitionQueue() }
|
||||
}
|
||||
}
|
||||
@Published private(set) var showsCompactQueue = false
|
||||
var queueVisible: Bool { isCompact ? showsCompactQueue : showsQueue }
|
||||
var queueAction: PlayerAction { queueVisible ? .hideQueue : .showQueue }
|
||||
@Published private(set) var queueWidth: CGFloat = 0
|
||||
private var queueAnimation: Timer?
|
||||
private var queueAnimationCompletion: (() -> Void)?
|
||||
@@ -51,6 +58,8 @@ final class PlayerModel: ObservableObject {
|
||||
private var queueCompletionHandled = false
|
||||
private var queueCastDevice: CastDevice?
|
||||
private var resolvedQueueSource: (id: UUID, source: ResolvedSource)?
|
||||
private var queueMetadataPending = false
|
||||
private var queueMetadataTask: (id: UUID, task: Task<Void, Never>)?
|
||||
var canPreviousQueueItem: Bool { canOpen && queue.canPrevious }
|
||||
var canNextQueueItem: Bool { canOpen && queue.canNext }
|
||||
private var transition = PlaybackTransition()
|
||||
@@ -152,7 +161,7 @@ final class PlayerModel: ObservableObject {
|
||||
var compactContentMinimumHeight: CGFloat {
|
||||
guard let window else { return 90 }
|
||||
// Hosting-view minimums include the native titlebar safe area, even with full-size content.
|
||||
return max(0, 90 - (window.frame.height - window.contentLayoutRect.height))
|
||||
return max(0, 90 + compactWindow.queueHeight - (window.frame.height - window.contentLayoutRect.height))
|
||||
}
|
||||
|
||||
let videoView: NSView?
|
||||
@@ -179,6 +188,7 @@ final class PlayerModel: ObservableObject {
|
||||
private var reportingErrorBuffer = [CChar]()
|
||||
private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo
|
||||
private let miruroResolver: @MainActor (String, Int) async throws -> ResolvedSource
|
||||
private let miruroMetadataResolver: @MainActor (String, Int) async throws -> MiruroMetadata
|
||||
private var reportingErrorMessage: String {
|
||||
reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
|
||||
}
|
||||
@@ -187,12 +197,14 @@ final class PlayerModel: ObservableObject {
|
||||
progressFile: String? = nil, streamQueue: StreamQueue? = nil,
|
||||
youtubeResolver: @escaping @MainActor (URL, Int?) async throws -> YouTubeVideo = { try await YouTubeResolver.resolve($0, height: $1) },
|
||||
miruroResolver: @escaping @MainActor (String, Int) async throws -> ResolvedSource = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) },
|
||||
miruroMetadataResolver: @escaping @MainActor (String, Int) async throws -> MiruroMetadata = { try await MiruroMetadataResolver.resolve(mediaID: $0, episode: $1) },
|
||||
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
|
||||
skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
||||
self.qualityClock = qualityClock
|
||||
self.skipClock = skipClock
|
||||
self.youtubeResolver = youtubeResolver
|
||||
self.miruroResolver = miruroResolver
|
||||
self.miruroMetadataResolver = miruroMetadataResolver
|
||||
keyBindings = configuration.keys
|
||||
compactTitleProgress = configuration.compactTitleProgress
|
||||
automaticYouTubeQuality = configuration.automaticQuality
|
||||
@@ -374,6 +386,40 @@ 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)])
|
||||
if case .youtube = stream, let added = queue.items.last { queue.updateLabel(title, for: added.id) }
|
||||
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
|
||||
@@ -610,6 +656,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func cancelStreamResolution() {
|
||||
cancelQueueMetadata()
|
||||
guard transition.isResolving else { return }
|
||||
objectWillChange.send()
|
||||
transition.cancelResolution()
|
||||
@@ -628,6 +675,58 @@ final class PlayerModel: ObservableObject {
|
||||
revealControls()
|
||||
}
|
||||
|
||||
private func cancelQueueMetadata() {
|
||||
queueMetadataTask?.task.cancel()
|
||||
queueMetadataTask = nil
|
||||
}
|
||||
|
||||
private func resolveQueueMetadataIfNeeded() {
|
||||
// Playback and quality changes own the network before sidebar enrichment.
|
||||
guard !isBusy, !resolvingStream, !state.buffering else {
|
||||
cancelQueueMetadata()
|
||||
return
|
||||
}
|
||||
if let request = queueMetadataTask,
|
||||
!queue.items.contains(where: { $0.id == request.id }) {
|
||||
cancelQueueMetadata()
|
||||
}
|
||||
guard queueMetadataPending, queueMetadataTask == nil else { return }
|
||||
guard let item = queue.items.first(where: {
|
||||
!$0.url.isFileURL && $0.id != queue.currentID && !$0.metadataAttempted
|
||||
}) else {
|
||||
queueMetadataPending = false
|
||||
return
|
||||
}
|
||||
guard let stream = try? StreamURL.parse(item.url.absoluteString) else {
|
||||
queue.updateLabel(nil, for: item.id)
|
||||
return
|
||||
}
|
||||
let youtube = youtubeResolver
|
||||
let miruro = miruroMetadataResolver
|
||||
let task = Task(priority: .utility) { [weak self] in
|
||||
let title: String?
|
||||
do {
|
||||
switch stream {
|
||||
case .youtube(let url):
|
||||
let video = try await youtube(url, nil)
|
||||
guard video.webpageURL == url else { throw CocoaError(.fileReadCorruptFile) }
|
||||
title = video.title
|
||||
case .miruro(let id, let episode):
|
||||
title = try await miruro(id, episode).title
|
||||
}
|
||||
} catch {
|
||||
// Optional enrichment must not surface as a playback error or retry every poll.
|
||||
title = nil
|
||||
}
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
if self.queue.currentID != item.id {
|
||||
self.queue.updateLabel(title, for: item.id)
|
||||
}
|
||||
self.queueMetadataTask = nil
|
||||
}
|
||||
queueMetadataTask = (item.id, task)
|
||||
}
|
||||
|
||||
private func replaceMetadata(for source: MediaSource?) {
|
||||
metadataTask?.cancel()
|
||||
metadataTask = nil
|
||||
@@ -641,8 +740,9 @@ final class PlayerModel: ObservableObject {
|
||||
guard let source, let episode else { return }
|
||||
let identity = source.url.absoluteString
|
||||
let mediaID = String(parts[1])
|
||||
let resolver = miruroMetadataResolver
|
||||
metadataTask = Task { [weak self] in
|
||||
guard let metadata = try? await MiruroMetadataResolver.resolve(mediaID: mediaID, episode: episode),
|
||||
guard let metadata = try? await resolver(mediaID, episode),
|
||||
!Task.isCancelled, let self,
|
||||
self.currentMedia?.url.absoluteString == identity,
|
||||
self.metadataLifecycle.accept(metadata, identity: identity, generation: generation) else { return }
|
||||
@@ -652,7 +752,7 @@ final class PlayerModel: ObservableObject {
|
||||
self.fileName = title
|
||||
self.window?.title = title
|
||||
self.casting.updateTitle(title)
|
||||
if self.queue.currentID != nil { self.queue.updateCurrentLabel(title) }
|
||||
if let id = self.queue.currentID { self.queue.updateLabel(title, for: id) }
|
||||
self.systemPlayback?.update()
|
||||
}
|
||||
self.metadataTask = nil
|
||||
@@ -744,7 +844,9 @@ final class PlayerModel: ObservableObject {
|
||||
youtubeHeight = nil
|
||||
youtubeQuality = nil
|
||||
}
|
||||
if let fileName, !source.url.isFileURL { queue.updateCurrentLabel(fileName) }
|
||||
if let fileName, !source.url.isFileURL, let id = queue.currentID {
|
||||
queue.updateLabel(fileName, for: id)
|
||||
}
|
||||
selectSubtitle(nil)
|
||||
if case .resolved(let resolved) = source {
|
||||
subtitleTracks = resolved.subtitles
|
||||
@@ -829,6 +931,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func clearQueue() {
|
||||
cancelQueueMetadata()
|
||||
queue.replace([])
|
||||
queuePlaybackRevision = nil
|
||||
resolvedQueueSource = nil
|
||||
@@ -915,6 +1018,7 @@ final class PlayerModel: ObservableObject {
|
||||
selectSubtitle(nil)
|
||||
subtitleTracks = []
|
||||
compactIntent = nil
|
||||
finishQueueTransition()
|
||||
compactWindow.restore(resumeFullScreen: false, animated: false)
|
||||
fileName = nil
|
||||
fileIcon = nil
|
||||
@@ -979,6 +1083,21 @@ final class PlayerModel: ObservableObject {
|
||||
queueAnimation?.invalidate()
|
||||
queueAnimation = nil
|
||||
queueAnimationCompletion = nil
|
||||
if isCompact {
|
||||
guard let window, let target = compactWindow.queueLayout(visible: showsCompactQueue) else { return }
|
||||
let startFrame = window.frame
|
||||
let startHeight = compactWindow.queueHeight
|
||||
let update: @MainActor (Double) -> Void = { [weak self] progress in
|
||||
let frame = NSRect(x: startFrame.minX + (target.frame.minX - startFrame.minX) * progress,
|
||||
y: startFrame.minY + (target.frame.minY - startFrame.minY) * progress,
|
||||
width: startFrame.width + (target.frame.width - startFrame.width) * progress,
|
||||
height: startFrame.height + (target.frame.height - startFrame.height) * progress)
|
||||
self?.compactWindow.updateQueue(height: startHeight + (target.height - startHeight) * progress, frame: frame)
|
||||
}
|
||||
queueAnimationCompletion = { update(1) }
|
||||
animateQueue(update)
|
||||
return
|
||||
}
|
||||
let targetWidth: CGFloat = showsQueue ? 260 : 0
|
||||
guard let window, fileName != nil, !isCompact, !compactWindow.isTransitioning else {
|
||||
queueWidth = targetWidth
|
||||
@@ -1016,6 +1135,10 @@ final class PlayerModel: ObservableObject {
|
||||
window.aspectRatio = NSSize(width: self.state.video_aspect_ratio, height: 1)
|
||||
}
|
||||
}
|
||||
animateQueue(update)
|
||||
}
|
||||
|
||||
private func animateQueue(_ update: @escaping @MainActor (Double) -> Void) {
|
||||
guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else {
|
||||
finishQueueTransition()
|
||||
return
|
||||
@@ -1125,6 +1248,9 @@ final class PlayerModel: ObservableObject {
|
||||
case .quit: return true
|
||||
case .showBufferInfo: return !showsBufferInfo
|
||||
case .hideBufferInfo: return showsBufferInfo
|
||||
case .showQueue, .hideQueue:
|
||||
return window != nil && !compactWindow.isTransitioning &&
|
||||
(action == .showQueue ? !queueVisible : queueVisible)
|
||||
case .enableCompactSnapping: return !compactWindow.snappingEnabled
|
||||
case .disableCompactSnapping: return compactWindow.snappingEnabled
|
||||
case .enableAutomaticQuality: return !automaticYouTubeQuality
|
||||
@@ -1164,12 +1290,14 @@ final class PlayerModel: ObservableObject {
|
||||
revealControls()
|
||||
case .enterCompact:
|
||||
finishQueueTransition()
|
||||
showsCompactQueue = false
|
||||
compactIntent = true
|
||||
compactWindow.toggle(aspect: state.video_aspect_ratio)
|
||||
controlsHovered = false
|
||||
titleHovered = false
|
||||
revealControls()
|
||||
case .exitCompact:
|
||||
finishQueueTransition()
|
||||
compactIntent = false
|
||||
compactWindow.restore()
|
||||
revealControls()
|
||||
@@ -1183,6 +1311,14 @@ final class PlayerModel: ObservableObject {
|
||||
case .quit: NSApp.terminate(nil)
|
||||
case .showBufferInfo: setShowsBufferInfo(true)
|
||||
case .hideBufferInfo: setShowsBufferInfo(false)
|
||||
case .showQueue, .hideQueue:
|
||||
if isCompact {
|
||||
showsCompactQueue = action == .showQueue
|
||||
transitionQueue()
|
||||
} else {
|
||||
showsQueue = action == .showQueue
|
||||
}
|
||||
revealControls()
|
||||
case .enableCompactSnapping: compactWindow.snappingEnabled = true
|
||||
case .disableCompactSnapping: compactWindow.snappingEnabled = false
|
||||
case .enableAutomaticQuality: setAutomaticYouTubeQuality(true)
|
||||
@@ -1335,6 +1471,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
if !casting.active { applyState(player_get_state(handle)) }
|
||||
else { updateSegmentSkip() }
|
||||
resolveQueueMetadataIfNeeded()
|
||||
}
|
||||
|
||||
func applyState(_ next: PlayerState) {
|
||||
@@ -2500,6 +2637,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 +2786,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)
|
||||
@@ -2784,7 +2934,8 @@ private struct PlayerContent: View {
|
||||
Menu {
|
||||
PlayerActionButton(model: model, action: .openFile, title: "Open File…")
|
||||
Button("Add to Queue…", action: model.addQueuePanel).disabled(!model.canOpen)
|
||||
Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() }
|
||||
PlayerActionButton(model: model, action: model.queueAction,
|
||||
title: model.queueVisible ? "Hide Queue" : "Show Queue")
|
||||
Button("Previous Queue Item") { model.advanceQueue(-1) }.disabled(!model.canPreviousQueueItem)
|
||||
Button("Next Queue Item") { model.advanceQueue(1) }.disabled(!model.canNextQueueItem)
|
||||
PlayerActionButton(model: model, action: .openStream, title: "Open Stream…")
|
||||
@@ -3003,12 +3154,104 @@ 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 {
|
||||
VStack(spacing: 0) {
|
||||
Color.clear
|
||||
.frame(height: 52)
|
||||
.frame(height: model.isCompact ? 12 : 52)
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityHidden(true)
|
||||
ForEach(Array(model.queue.items.enumerated()), id: \.element.id) { index, item in
|
||||
@@ -3054,6 +3297,7 @@ private struct PlayerContent: View {
|
||||
.fill(Color(SunsetPalette.actionFill))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { model.selectQueueItem(item.id) }
|
||||
.help((item.url.isFileURL ? item.url.path : item.displayName) + "\nClick to play; drag to reorder.")
|
||||
@@ -3136,6 +3380,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 +3392,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,7 +3404,8 @@ private struct PlayerContent: View {
|
||||
.background(windowDragBackground)
|
||||
.background { queueChromeBackground(top: false) }
|
||||
}
|
||||
.frame(width: 260)
|
||||
.animation(queueAddAnimation, value: showsQueueURLInput)
|
||||
.onDisappear(perform: dismissQueueAdd)
|
||||
.background(queueBackground)
|
||||
.overlay(alignment: .top) {
|
||||
queueChromeBackground(top: true)
|
||||
@@ -3183,14 +3422,24 @@ private struct PlayerContent: View {
|
||||
var body: some View {
|
||||
let titleControlsVisible = model.showsQueue || model.isQueueTransitioning || model.controlsVisible
|
||||
let startScreen = model.fileName == nil && model.queue.items.isEmpty && !model.isCompact && !model.compactWindow.isTransitioning
|
||||
HStack(spacing: 0) {
|
||||
videoContent
|
||||
if !model.isCompact && !model.compactWindow.isTransitioning && model.queueWidth > 0 {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
videoContent
|
||||
if !model.isCompact && !model.compactWindow.isTransitioning && model.queueWidth > 0 {
|
||||
queueSidebar
|
||||
.frame(width: 260)
|
||||
.frame(width: model.queueWidth, alignment: .leading)
|
||||
.clipped()
|
||||
.allowsHitTesting(model.showsQueue)
|
||||
.accessibilityHidden(!model.showsQueue)
|
||||
}
|
||||
}
|
||||
if model.isCompact && model.compactWindow.queueHeight > 0 {
|
||||
queueSidebar
|
||||
.frame(width: model.queueWidth, alignment: .leading)
|
||||
.frame(height: model.compactWindow.queueHeight, alignment: .top)
|
||||
.clipped()
|
||||
.allowsHitTesting(model.showsQueue)
|
||||
.accessibilityHidden(!model.showsQueue)
|
||||
.allowsHitTesting(model.showsCompactQueue)
|
||||
.accessibilityHidden(!model.showsCompactQueue)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
@@ -3217,6 +3466,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
|
||||
|
||||
@@ -312,7 +312,7 @@ private struct YouTubeExtractionOutput: Sendable {
|
||||
|
||||
// All Process, pipe, and continuation state belongs to this request's serial queue.
|
||||
private final class YouTubeExtraction: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "terebi.youtube-extraction", qos: .userInitiated)
|
||||
private let queue: DispatchQueue
|
||||
private let process = Process()
|
||||
private let stdoutPipe = Pipe()
|
||||
private let stderrPipe = Pipe()
|
||||
@@ -331,6 +331,9 @@ private final class YouTubeExtraction: @unchecked Sendable {
|
||||
private var failure: (any Error)?
|
||||
|
||||
init(executable: URL, arguments: [String], environment: [String: String]) {
|
||||
let background = Task.currentPriority <= .utility
|
||||
queue = DispatchQueue(label: "terebi.youtube-extraction", qos: background ? .utility : .userInitiated)
|
||||
process.qualityOfService = background ? .utility : .userInitiated
|
||||
process.executableURL = executable
|
||||
process.arguments = arguments
|
||||
process.environment = environment
|
||||
|
||||
@@ -83,6 +83,7 @@ public struct KeyBindings: Equatable, Sendable {
|
||||
.enterFullscreen: ["f", "ctrl+cmd+f"], .exitFullscreen: ["f", "ctrl+cmd+f", "escape"],
|
||||
.enterCompact: ["p", "ctrl+cmd+p"], .exitCompact: ["p", "ctrl+cmd+p"],
|
||||
.enableAlwaysOnTop: ["shift+p"], .disableAlwaysOnTop: ["shift+p"],
|
||||
.showQueue: ["cmd+shift+l"], .hideQueue: ["cmd+shift+l"],
|
||||
.openFile: ["o", "cmd+o"], .openStream: ["cmd+shift+o"],
|
||||
.closeFile: ["cmd+shift+w"], .quit: ["q", "cmd+q"]
|
||||
]
|
||||
|
||||
@@ -6,6 +6,7 @@ public enum PlayerAction: Hashable, Sendable {
|
||||
case enableAlwaysOnTop, disableAlwaysOnTop
|
||||
case openFile, openStream, closeFile, quit
|
||||
case showBufferInfo, hideBufferInfo
|
||||
case showQueue, hideQueue
|
||||
case enableCompactSnapping, disableCompactSnapping
|
||||
case enableAutomaticQuality, disableAutomaticQuality
|
||||
case qualityDefault, quality(Int)
|
||||
@@ -21,6 +22,7 @@ public enum PlayerAction: Hashable, Sendable {
|
||||
("enable_always_on_top", .enableAlwaysOnTop), ("disable_always_on_top", .disableAlwaysOnTop),
|
||||
("open_file", .openFile), ("open_stream", .openStream), ("close_file", .closeFile), ("quit", .quit),
|
||||
("show_buffer_info", .showBufferInfo), ("hide_buffer_info", .hideBufferInfo),
|
||||
("show_queue", .showQueue), ("hide_queue", .hideQueue),
|
||||
("enable_compact_snapping", .enableCompactSnapping), ("disable_compact_snapping", .disableCompactSnapping),
|
||||
("enable_automatic_quality", .enableAutomaticQuality), ("disable_automatic_quality", .disableAutomaticQuality),
|
||||
("quality_default", .qualityDefault)
|
||||
@@ -60,6 +62,8 @@ public enum PlayerAction: Hashable, Sendable {
|
||||
case .disableAlwaysOnTop: return .enableAlwaysOnTop
|
||||
case .showBufferInfo: return .hideBufferInfo
|
||||
case .hideBufferInfo: return .showBufferInfo
|
||||
case .showQueue: return .hideQueue
|
||||
case .hideQueue: return .showQueue
|
||||
case .enableCompactSnapping: return .disableCompactSnapping
|
||||
case .disableCompactSnapping: return .enableCompactSnapping
|
||||
case .enableAutomaticQuality: return .disableAutomaticQuality
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
- [ ] 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.
|
||||
- [x] 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
|
||||
- [ ] sidebar: fetch data quicker, so titles are populated immediately instead of only after everything from the current video has been fetched.
|
||||
@@ -47,7 +47,7 @@ The manifest is bounded to 8 MiB, version 1, and 1–4096 entries in playback or
|
||||
|
||||
Use canonical YouTube video pages or supported Miruro episode pages, never expiring source addresses. Stable identities must be unique even across alternate YouTube forms or different Miruro slugs. Reports must have distinct absolute nonexistent destinations, each under a canonical, nonsymlink, user-owned `0700` directory. Tubular retains each item's existing identity registration/session and stores the manifest in the first session. The CLI validates the complete document before launching; the GUI validates it again before opening any media. Invalid queues fail rather than falling back to a single stream. Queue launch requires either `--new-instance` (replace in a separate player) or `--enqueue` (append to the running player), accepts `--no-config`, and cannot combine with `--url`, `--source-file` or `--progress-file`. `--enqueue` and `--new-instance` are mutually exclusive.
|
||||
|
||||
The supplied order is preserved (local-file batches still use natural filename order). Existing sidebar reorder/remove/play and previous/next navigation apply. Natural end advances without clearing the queue. Sources resolve only when selected, resolve afresh on revisits, and canceled/stale results cannot replace a newer selection. Labels start with descriptive page information and update from resolved titles/optional metadata.
|
||||
The supplied order is preserved (local-file batches still use natural filename order). Existing sidebar reorder/remove/play and previous/next navigation apply. Natural end advances without clearing the queue. Unselected labels are enriched serially at utility priority after playback loading settles; buffering, selection and quality changes cancel background work. yt-dlp extraction supplies page titles; Miruro uses optional watch-route metadata without fetching playback sources. Failures keep the supplied label and do not block later entries or surface playback errors. Results follow item IDs across reordering and cannot rename removed/replaced rows. Only titles are retained: playback resolves fresh on selection/revisits, and canceled/stale results cannot replace a newer selection.
|
||||
|
||||
Enqueue uses native macOS URL-open delivery to the explicit application, not launch arguments: repeated requests reach the existing process. Both one-item and multi-item requests use the same manifest. The current selection, playback/pause state, timeline, pending resolution and metadata stay intact; an empty player selects and starts the first entry. Original single-file, single-stream and resolved-source playback remain navigable when items are appended. Enqueue never silently deduplicates: any identity still in the queue, or any report-path collision, rejects the complete batch with an error in the player. Removed videos can be re-added with fresh report destinations, including the original standalone video. Old report paths remain reserved for the process lifetime; at most 4096 queue-report destinations may be registered, counting retired sessions. Existing files are never adopted as reporting sessions. Keep manifests and private sessions available until delivery; CLI success acknowledges macOS delivery, not GUI acceptance or playback.
|
||||
|
||||
@@ -55,6 +55,8 @@ Each report is immutably bound to its stable identity in the native decoder. Reg
|
||||
|
||||
Runnable headless queue checks: `make check-cli check-queue check-reporting check-resolved`. The existing `check-resolved` local HTTP/HLS/VTT harness drives a real native `PlayerModel` through mixed YouTube/Miruro queues with injected fixture resolution, stale-result cancellation, manual navigation, natural-end advancement, fresh revisits/resume and isolated v1/v2 reports. It also delivers repeated enqueue URLs while paused and playing, checks original standalone selection/timeline preservation, lazy append ordering, atomic invalid/conflicting requests, independent single/queue report revisits, removal/re-addition with fresh reports and unchanged retired files, and empty-player startup. These checks require no browser or live streaming service. Actual LaunchServices/UI delivery remains a separate user-owned verification step; no GUI smoke result is claimed for enqueue.
|
||||
|
||||
Background-title verification: `make check-resolved check-queue check-youtube all` passed. The native local-HLS smoke exercises unplayed YouTube/Miruro titles, serial utility-priority enrichment, unchanged paused selection/position/title, reordering during lookup, failure fallbacks, fresh playback preemption, and stale removal/replacement results. No browser or live service was used; sidebar appearance remains a user-owned visual check.
|
||||
|
||||
Removal/re-addition verification: the new native registration regression failed before the fix and passed afterward. `make check-reporting check-queue .build/resolved-playback-check all` passed. A focused `.build/resolved-playback-check /absolute/source.json --enqueue-only` smoke using local video-only H.264 HLS passed paused/playing append, natural-end advancement, atomic conflicts/rollback, removal and re-addition of the current standalone video and an inactive queued video, fresh-session playback, byte-for-byte retired-report preservation, and empty-player startup. The source document requires `headers` and `subtitles` (empty values are valid). No full A/V-suite or packaged-GUI/LaunchServices pass is claimed; the app was built, not installed. Actual UI confirmation remains user-owned; temporary fixture/server files were removed.
|
||||
|
||||
### Optional native metadata
|
||||
|
||||
Reference in New Issue
Block a user