Compare commits

..

3 Commits

Author SHA1 Message Date
hl-valdemar 98e6dcb3c5 feat: add expandable compact queue and sidebar shortcut 2026-10-22 18:38:01 +02:00
hl-valdemar 6ed7db8167 fix: resolve queued stream metadata in the background
Queue labels were only enriched through selected-entry playback. Resolve unplayed titles serially at utility priority, preempt enrichment for playback, and bind updates to queue item IDs.

Verified: make check-resolved check-queue check-youtube all.
2026-10-22 18:38:01 +02:00
hl-valdemar f1339050d9 feat: add expandable file and link controls to playlist 2026-10-22 18:38:01 +02:00
16 changed files with 853 additions and 78 deletions
+29 -5
View File
@@ -67,6 +67,22 @@ def check_window_drag(root, app, fixtures):
print("PASS native window dragging: " + str(report))
def check_queue_add(root, app, fixtures):
result_path = root / "queue-add.json"
diagnostics = root / "queue-add.log"
result = subprocess.run(
["open", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app),
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-window-drag", str(result_path),
"--queue-add-only", str(fixtures)],
capture_output=True, text=True, timeout=30,
)
report = json.loads(result_path.read_text()) if result_path.exists() else {}
if result.returncode != 0 or not report.get("passed"):
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
raise RuntimeError(f"native queue add failed: {report}; {result.stdout}{result.stderr}{log}")
print("PASS native queue add: + expands, link focuses input, toggle retains draft, Escape dismisses without moving window")
def check_queue_drag(root, app, fixtures):
result_path = root / "queue-drag.json"
diagnostics = root / "queue-drag.log"
@@ -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
View File
@@ -185,10 +185,12 @@ private enum WindowDragCheck {
static func run(result: URL) {
resultURL = result
_ = NSApplication.shared
if CommandLine.arguments.contains("--queue-add-only") { probe = 12 }
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
MainActor.assumeIsolated { advance() }
}
RunLoop.main.add(timer!, forMode: .common)
RunLoop.main.add(timer!, forMode: .modalPanel)
terebiAppMain()
}
@@ -230,6 +232,18 @@ private enum WindowDragCheck {
}
}
static func clickQueueAdd(in window: NSWindow, link: Bool) {
for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] {
let event = NSEvent.mouseEvent(with: type,
location: NSPoint(x: window.contentView!.bounds.width - (link ? 163 : 183), y: 18),
modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber, context: nil,
eventNumber: 1000 + phase * 2 + (type == .leftMouseDown ? 0 : 1),
clickCount: 1, pressure: type == .leftMouseDown ? 1 : 0)!
NSApp.postEvent(NSEvent(cgEvent: event.cgEvent!)!, atStart: false)
}
}
static func point(in window: NSWindow) -> NSPoint? {
let content = window.contentView!
let w = content.bounds.width
@@ -303,6 +317,7 @@ private enum WindowDragCheck {
interceptDrags(in: window)
model.setVolume(0)
model.setPlayback(playing: false)
if CommandLine.arguments.contains("--queue-add-only") { model.showsQueue = true }
window.makeKeyAndOrderFront(nil)
window.makeMain()
NSApp.activate(ignoringOtherApps: true)
@@ -365,6 +380,12 @@ private enum WindowDragCheck {
phase = 7
return
}
if probe == 12 {
urlWindowFrame = window.frame
clickQueueAdd(in: window, link: false)
phase = 7
return
}
probe += 1
if probe == names.count { finish(); return }
phase = 0
@@ -387,6 +408,48 @@ private enum WindowDragCheck {
probe += 1
phase = 0
return
} else if probe == 12 && phase == 7 {
guard NSApp.modalWindow == nil else { finish("Queue + opened the file picker instead of expanding"); return }
clickQueueAdd(in: window, link: true)
} else if probe == 12 && phase == 8 {
guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else { return }
editor.insertText(urlDraft, replacementRange: NSRange(location: NSNotFound, length: 0))
clickQueueAdd(in: window, link: true)
} else if probe == 12 && phase == 9 {
guard nativeView(in: window.contentView!, matching: { ($0 as? NSTextField)?.isEditable == true }) == nil else { return }
guard (window.firstResponder as? NSTextView)?.isFieldEditor != true else {
finish("Collapsing the queue URL field retained keyboard focus"); return
}
clickQueueAdd(in: window, link: true)
} else if probe == 12 && phase == 10 {
guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else { return }
guard editor.string == urlDraft else { finish("Reopening queue URL input lost its draft"); return }
let escape = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber,
context: nil, characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53)!
NSApp.postEvent(escape, atStart: false)
} else if probe == 12 && phase == 11 {
guard nativeView(in: window.contentView!, matching: { ($0 as? NSTextField)?.isEditable == true }) == nil else { return }
guard (window.firstResponder as? NSTextView)?.isFieldEditor != true,
window.frame == urlWindowFrame, drags == previousDrags else {
finish("Queue URL transitions retained focus, resized or dragged the window"); return
}
clickQueueAdd(in: window, link: false)
} else if probe == 12 && phase == 12 {
clickQueueAdd(in: window, link: false)
} else if probe == 12 && phase == 13 {
guard let panel = NSApp.modalWindow as? NSOpenPanel else { return }
guard panel.allowsMultipleSelection && panel.prompt == "Add to Queue" else {
panel.cancel(nil)
finish("Queue file icon did not open the multi-file queue picker"); return
}
panel.cancel(nil)
} else if probe == 12 && phase == 14 {
guard NSApp.modalWindow == nil else { return }
probe += 1
if CommandLine.arguments.contains("--queue-add-only") { finish(); return }
phase = 0
return
}
phase += 1
}
@@ -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
+63 -2
View File
@@ -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")
}
}
+6
View File
@@ -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)
]
+1 -1
View File
@@ -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")
+196 -3
View File
@@ -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
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)
+9 -4
View File
@@ -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.
+55 -8
View File
@@ -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 -3
View File
@@ -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]) {
+3 -2
View File
@@ -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)
}
+279 -29
View File
@@ -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,16 +3422,26 @@ 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
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(height: model.compactWindow.queueHeight, alignment: .top)
.clipped()
.allowsHitTesting(model.showsCompactQueue)
.accessibilityHidden(!model.showsCompactQueue)
}
}
.overlay(alignment: .topTrailing) {
if !model.isCompact && !model.compactWindow.isTransitioning {
titleControls
@@ -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
+4 -1
View File
@@ -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
+1
View File
@@ -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"]
]
+4
View File
@@ -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
+3 -5
View File
@@ -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).
- [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.
+3 -1
View File
@@ -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