feat: add expandable compact queue and sidebar shortcut

This commit is contained in:
2026-10-22 18:11:13 +02:00
parent 6ed7db8167
commit 98e6dcb3c5
11 changed files with 330 additions and 39 deletions
+10 -5
View File
@@ -101,22 +101,24 @@ def check_queue_drag(root, app, fixtures):
def check_window_layout(root, app, fixtures): def check_window_layout(root, app, fixtures):
result_path = root / "window-layout.json" result_path = root / "window-layout.json"
diagnostics = root / "window-layout.log" 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( result = subprocess.run(
["open", "-g", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app), ["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, capture_output=True, text=True, timeout=40,
) )
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else "" log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
report = json.loads(result_path.read_text()) if result_path.exists() else {} report = json.loads(result_path.read_text()) if result_path.exists() else {}
expected_animations = 0 if report.get("reduceMotion") else 16 expected_animations = 0 if report.get("reduceMotion") else (4 if compact_queue_only else 19)
expected_queue_animations = 0 if report.get("reduceMotion") else 9 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") != 36 if (result.returncode != 0 or not report.get("passed") or report.get("steps") != 45
or report.get("animatedTransitions") != expected_animations or report.get("animatedTransitions") != expected_animations
or report.get("animatedQueueTransitions") != expected_queue_animations): or report.get("animatedQueueTransitions") != expected_queue_animations):
raise RuntimeError(f"native window layout failed: {report}; {result.stdout}{result.stderr}{log}") raise RuntimeError(f"native window layout failed: {report}; {result.stdout}{result.stderr}{log}")
print(f"PASS native SwiftUI window layout: {report['animatedTransitions']} compact and " print(f"PASS native SwiftUI window layout: {report['animatedTransitions']} compact and "
f"{report['animatedQueueTransitions']} queue animations, stable video size, responsive reversals, " 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): def check_startup_errors(root, app):
config = root / "invalid-startup.toml" config = root / "invalid-startup.toml"
@@ -781,6 +783,9 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK) descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK)
launch = None launch = None
try: 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": if os.environ.get("TEREBI_CHECK_QUEUE_ADD_ONLY") == "1":
check_queue_add(root, app, fixtures) check_queue_add(root, app, fixtures)
sys.exit(0) sys.exit(0)
+119 -4
View File
@@ -590,6 +590,7 @@ private enum WindowLayoutCheck {
static var step = 0 static var step = 0
static var windowIdentity: ObjectIdentifier? static var windowIdentity: ObjectIdentifier?
static var normalFrame = NSRect.zero static var normalFrame = NSRect.zero
static var compactFrame = NSRect.zero
static var animationStart: NSRect? static var animationStart: NSRect?
static var animationTarget = NSRect.zero static var animationTarget = NSRect.zero
static var sawIntermediateFrame = false static var sawIntermediateFrame = false
@@ -598,6 +599,7 @@ private enum WindowLayoutCheck {
static var queueVideoSize: NSSize? static var queueVideoSize: NSSize?
static var observingQueue = false static var observingQueue = false
static var animationQueueWidth: CGFloat = 0 static var animationQueueWidth: CGFloat = 0
static var compactQueueOnly: Bool { CommandLine.arguments.contains("--compact-queue-only") }
static var expectsAnimation: Bool { !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion } static var expectsAnimation: Bool { !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }
// Infrastructure timeout only; assertions depend on native transition completion. // Infrastructure timeout only; assertions depend on native transition completion.
static let deadline = ContinuousClock.now.advanced(by: .seconds(30)) static let deadline = ContinuousClock.now.advanced(by: .seconds(30))
@@ -677,7 +679,7 @@ private enum WindowLayoutCheck {
let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return } let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
if model.compactWindow.isTransitioning || model.isQueueTransitioning { if model.compactWindow.isTransitioning || model.isQueueTransitioning {
window.layoutIfNeeded() 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 finish("Top-right controls left the window corner during queue animation at step \(step)"); return
} }
if let size = queueVideoSize, let video = model.videoView { if let size = queueVideoSize, let video = model.videoView {
@@ -693,8 +695,8 @@ private enum WindowLayoutCheck {
// These run-loop-delivered requests must remain responsive mid-animation. // These run-loop-delivered requests must remain responsive mid-animation.
if model.isQueueTransitioning { if model.isQueueTransitioning {
let frame = window.frame let frame = window.frame
model.showsQueue.toggle() model.perform(model.queueAction)
model.showsQueue.toggle() model.perform(model.queueAction)
guard sameFrame(window.frame, frame) else { finish("Queue reversal jumped the window"); return } guard sameFrame(window.frame, frame) else { finish("Queue reversal jumped the window"); return }
} else { } else {
model.toggleCompact() model.toggleCompact()
@@ -720,9 +722,20 @@ private enum WindowLayoutCheck {
guard ObjectIdentifier(window) == windowIdentity else { finish("Transition replaced the playback window"); return } guard ObjectIdentifier(window) == windowIdentity else { finish("Transition replaced the playback window"); return }
if model.fullScreenIntent != nil { return } if model.fullScreenIntent != nil { return }
window.layoutIfNeeded() 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) { if model.showsQueue && !model.isCompact && !sidebarHeaderIsVisible(in: window) {
finish("Sidebar pin, Cast or settings glyph is not visible at step \(step)"); return 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 { if step >= 33 {
switch step { switch step {
case 33, 34: case 33, 34:
@@ -771,7 +784,9 @@ private enum WindowLayoutCheck {
observeAnimation(from: window, to: target) observeAnimation(from: window, to: target)
observingQueue = true observingQueue = true
queueVideoSize = model.videoView?.bounds.size 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: case 1, 5:
window.setFrame(NSRect(x: 100, y: 200, width: 763, height: 368), display: true) window.setFrame(NSRect(x: 100, y: 200, width: 763, height: 368), display: true)
@@ -800,6 +815,106 @@ private enum WindowLayoutCheck {
} }
step += 1 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 @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. // Run the real AppKit completion callbacks. The monotonic deadline is only a hang watchdog.
static func settled(_ controller: CompactWindowController, compact: Bool, 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)) let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while controller.isTransitioning { while controller.isTransitioning {
try checking() 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) CFRunLoopRunInMode(.defaultMode, 0.01, false)
} }
try checking() 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 { static func main() throws {
try geometry() try geometry()
try windowBehavior() try windowBehavior()
try reducedMotion() try reducedMotion()
try rememberedSize() try rememberedSize()
try continuousQueueResize() try continuousQueueResize()
try compactQueue()
print("Compact window checks passed") 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(PlayerAction(name: "open_youtube") == nil, "obsolete YouTube action has no alias")
try require(bindings.shortcuts(for: .openStream) == [try KeyChord("cmd+shift+o")], try require(bindings.shortcuts(for: .openStream) == [try KeyChord("cmd+shift+o")],
"stream action preserves its shortcut") "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": []]) } try requireError(["open_youtube"]) { _ = try KeyBindings(overrides: ["open_youtube": []]) }
let inversePairs: [(PlayerAction, PlayerAction)] = [ let inversePairs: [(PlayerAction, PlayerAction)] = [
(.play, .pause), (.mute, .unmute), (.enterFullscreen, .exitFullscreen), (.play, .pause), (.mute, .unmute), (.enterFullscreen, .exitFullscreen),
(.enterCompact, .exitCompact), (.enableAlwaysOnTop, .disableAlwaysOnTop), (.enterCompact, .exitCompact), (.enableAlwaysOnTop, .disableAlwaysOnTop),
(.showQueue, .hideQueue),
(.showBufferInfo, .hideBufferInfo), (.enableCompactSnapping, .disableCompactSnapping), (.showBufferInfo, .hideBufferInfo), (.enableCompactSnapping, .disableCompactSnapping),
(.enableAutomaticQuality, .disableAutomaticQuality) (.enableAutomaticQuality, .disableAutomaticQuality)
] ]
+6 -1
View File
@@ -27,7 +27,7 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self
- **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints. - **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. - **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. - **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. - **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 ### Keyboard shortcuts
@@ -42,6 +42,7 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self
| Mute / unmute | M | | Mute / unmute | M |
| Fullscreen | F or ⌃⌘F; Esc to leave | | Fullscreen | F or ⌃⌘F; Esc to leave |
| Compact mode | P or ⌃⌘P | | Compact mode | P or ⌃⌘P |
| Show/hide queue (normal or compact) | ⇧⌘L |
| Always on top | ⇧P | | Always on top | ⇧P |
| Close media | ⇧⌘W | | Close media | ⇧⌘W |
| Quit | Q or ⌘Q | | 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. 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 ## Limitations
- SDR output only; HDR is not supported. - 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 ## 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. 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)?)? var normalVideoLayout: (() -> (aspect: Double, sidebarWidth: CGFloat)?)?
private(set) var isCompact = false private(set) var isCompact = false
private(set) var isTransitioning = false private(set) var isTransitioning = false
private(set) var queueHeight: CGFloat = 0
static let transitionDuration: TimeInterval = 0.25 static let transitionDuration: TimeInterval = 0.25
var snappingEnabled: Bool { var snappingEnabled: Bool {
@@ -59,6 +60,7 @@ final class CompactWindowController: NSObject {
private var originatedFullScreen = false private var originatedFullScreen = false
private var videoAspect = 16.0 / 9.0 private var videoAspect = 16.0 / 9.0
private var fittedAspect: Double? private var fittedAspect: Double?
private var fittedVideoAspect = 16.0 / 9.0
private let reduceMotion: () -> Bool private let reduceMotion: () -> Bool
private let defaults: UserDefaults private let defaults: UserDefaults
private static let widthKey = "compactWindowWidth" private static let widthKey = "compactWindowWidth"
@@ -135,6 +137,34 @@ final class CompactWindowController: NSObject {
fitCompactWindow(window, initial: false) 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() { func snapAfterDrag() {
guard isCompact, !isTransitioning, snappingEnabled, let window else { return } guard isCompact, !isTransitioning, snappingEnabled, let window else { return }
let frame = Self.snappedFrame(window.frame, visibleFrame: visibleFrame(for: window)) 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 { func windowWillResize(_ sender: NSWindow, to proposedSize: NSSize) -> NSSize {
let proposed = original?.windowWillResize?(sender, to: proposedSize) ?? proposedSize let proposed = original?.windowWillResize?(sender, to: proposedSize) ?? proposedSize
guard let owner, !owner.isCompact, !owner.isTransitioning, guard let owner, !owner.isTransitioning,
!sender.styleMask.contains(.fullScreen), !sender.styleMask.contains(.fullScreen) else { return proposed }
let layout = owner.normalVideoLayout?(), let layout: (aspect: Double, sidebarWidth: CGFloat)
layout.aspect.isFinite, layout.aspect > 0 else { return proposed } 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. // 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 start = resizeStartSize ?? sender.frame.size
let widthChange = abs(proposed.width - start.width) let widthChange = abs(proposed.width - start.width)
let heightChange = abs(proposed.height - start.height) * layout.aspect let heightChange = abs(proposed.height - start.height) * layout.aspect
@@ -291,6 +332,7 @@ final class CompactWindowController: NSObject {
return return
} }
if let snapshot { if let snapshot {
queueHeight = 0
setState(compact: false, transitioning: animated) setState(compact: false, transitioning: animated)
window.aspectRatio = .zero window.aspectRatio = .zero
window.minSize = .zero window.minSize = .zero
@@ -318,6 +360,7 @@ final class CompactWindowController: NSObject {
private func enterCompact(_ window: NSWindow, animated: Bool) { private func enterCompact(_ window: NSWindow, animated: Bool) {
// After didExitFullScreen, this is the normal frame, not the fullscreen frame. // After didExitFullScreen, this is the normal frame, not the fullscreen frame.
snapshot = Snapshot(window) snapshot = Snapshot(window)
queueHeight = 0
setState(compact: true, transitioning: animated) setState(compact: true, transitioning: animated)
window.aspectRatio = .zero window.aspectRatio = .zero
window.minSize = .zero window.minSize = .zero
@@ -422,18 +465,22 @@ final class CompactWindowController: NSObject {
let aspect = videoAspect let aspect = videoAspect
let savedWidth = initial ? defaults.double(forKey: Self.widthKey) : Double(window.frame.width) let savedWidth = initial ? defaults.double(forKey: Self.widthKey) : Double(window.frame.width)
let width = savedWidth.isFinite && savedWidth > 0 ? CGFloat(savedWidth) : 320 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 { if !initial {
frame.origin.x = min(max(window.frame.minX, usable.minX), usable.maxX - frame.width) 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) window.resizeIncrements = NSSize(width: 1, height: 1)
animateFrame(frame, of: window, animated: animated) { [weak self, weak window] in animateFrame(frame, of: window, animated: animated) { [weak self, weak window] in
guard let self, let window else { return } 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) window.aspectRatio = NSSize(width: aspect, height: 1)
} }
self.fittedAspect = aspect self.fittedAspect = aspect
self.fittedVideoAspect = video.width / video.height
if initial { self.reconcile() } if initial { self.reconcile() }
} }
} }
+3 -2
View File
@@ -282,8 +282,9 @@ struct PlayerCommands: Commands {
.disabled(!hasFocusedScene || !model.canPerform(bufferInfo)) .disabled(!hasFocusedScene || !model.canPerform(bufferInfo))
} }
CommandGroup(replacing: .sidebar) { CommandGroup(replacing: .sidebar) {
Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() } Button(model.queueVisible ? "Hide Queue" : "Show Queue") { model.performMenuAction(model.queueAction) }
.disabled(!hasFocusedScene || model.isCompact) .keyboardShortcut(model.keyBindings.shortcuts(for: model.queueAction).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(model.queueAction))
Button(fullscreen == .exitFullscreen ? "Exit Full Screen" : "Enter Full Screen") { Button(fullscreen == .exitFullscreen ? "Exit Full Screen" : "Enter Full Screen") {
model.performMenuAction(fullscreen) model.performMenuAction(fullscreen)
} }
+54 -5
View File
@@ -41,9 +41,14 @@ final class PlayerModel: ObservableObject {
@Published private(set) var startContentHeight: CGFloat = 400 @Published private(set) var startContentHeight: CGFloat = 400
@Published var showsQueue = false { @Published var showsQueue = false {
didSet { 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 @Published private(set) var queueWidth: CGFloat = 0
private var queueAnimation: Timer? private var queueAnimation: Timer?
private var queueAnimationCompletion: (() -> Void)? private var queueAnimationCompletion: (() -> Void)?
@@ -156,7 +161,7 @@ final class PlayerModel: ObservableObject {
var compactContentMinimumHeight: CGFloat { var compactContentMinimumHeight: CGFloat {
guard let window else { return 90 } guard let window else { return 90 }
// Hosting-view minimums include the native titlebar safe area, even with full-size content. // 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? let videoView: NSView?
@@ -1013,6 +1018,7 @@ final class PlayerModel: ObservableObject {
selectSubtitle(nil) selectSubtitle(nil)
subtitleTracks = [] subtitleTracks = []
compactIntent = nil compactIntent = nil
finishQueueTransition()
compactWindow.restore(resumeFullScreen: false, animated: false) compactWindow.restore(resumeFullScreen: false, animated: false)
fileName = nil fileName = nil
fileIcon = nil fileIcon = nil
@@ -1077,6 +1083,21 @@ final class PlayerModel: ObservableObject {
queueAnimation?.invalidate() queueAnimation?.invalidate()
queueAnimation = nil queueAnimation = nil
queueAnimationCompletion = 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 let targetWidth: CGFloat = showsQueue ? 260 : 0
guard let window, fileName != nil, !isCompact, !compactWindow.isTransitioning else { guard let window, fileName != nil, !isCompact, !compactWindow.isTransitioning else {
queueWidth = targetWidth queueWidth = targetWidth
@@ -1114,6 +1135,10 @@ final class PlayerModel: ObservableObject {
window.aspectRatio = NSSize(width: self.state.video_aspect_ratio, height: 1) 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 { guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else {
finishQueueTransition() finishQueueTransition()
return return
@@ -1223,6 +1248,9 @@ final class PlayerModel: ObservableObject {
case .quit: return true case .quit: return true
case .showBufferInfo: return !showsBufferInfo case .showBufferInfo: return !showsBufferInfo
case .hideBufferInfo: return showsBufferInfo case .hideBufferInfo: return showsBufferInfo
case .showQueue, .hideQueue:
return window != nil && !compactWindow.isTransitioning &&
(action == .showQueue ? !queueVisible : queueVisible)
case .enableCompactSnapping: return !compactWindow.snappingEnabled case .enableCompactSnapping: return !compactWindow.snappingEnabled
case .disableCompactSnapping: return compactWindow.snappingEnabled case .disableCompactSnapping: return compactWindow.snappingEnabled
case .enableAutomaticQuality: return !automaticYouTubeQuality case .enableAutomaticQuality: return !automaticYouTubeQuality
@@ -1262,12 +1290,14 @@ final class PlayerModel: ObservableObject {
revealControls() revealControls()
case .enterCompact: case .enterCompact:
finishQueueTransition() finishQueueTransition()
showsCompactQueue = false
compactIntent = true compactIntent = true
compactWindow.toggle(aspect: state.video_aspect_ratio) compactWindow.toggle(aspect: state.video_aspect_ratio)
controlsHovered = false controlsHovered = false
titleHovered = false titleHovered = false
revealControls() revealControls()
case .exitCompact: case .exitCompact:
finishQueueTransition()
compactIntent = false compactIntent = false
compactWindow.restore() compactWindow.restore()
revealControls() revealControls()
@@ -1281,6 +1311,14 @@ final class PlayerModel: ObservableObject {
case .quit: NSApp.terminate(nil) case .quit: NSApp.terminate(nil)
case .showBufferInfo: setShowsBufferInfo(true) case .showBufferInfo: setShowsBufferInfo(true)
case .hideBufferInfo: setShowsBufferInfo(false) case .hideBufferInfo: setShowsBufferInfo(false)
case .showQueue, .hideQueue:
if isCompact {
showsCompactQueue = action == .showQueue
transitionQueue()
} else {
showsQueue = action == .showQueue
}
revealControls()
case .enableCompactSnapping: compactWindow.snappingEnabled = true case .enableCompactSnapping: compactWindow.snappingEnabled = true
case .disableCompactSnapping: compactWindow.snappingEnabled = false case .disableCompactSnapping: compactWindow.snappingEnabled = false
case .enableAutomaticQuality: setAutomaticYouTubeQuality(true) case .enableAutomaticQuality: setAutomaticYouTubeQuality(true)
@@ -2896,7 +2934,8 @@ private struct PlayerContent: View {
Menu { Menu {
PlayerActionButton(model: model, action: .openFile, title: "Open File…") PlayerActionButton(model: model, action: .openFile, title: "Open File…")
Button("Add to Queue…", action: model.addQueuePanel).disabled(!model.canOpen) 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("Previous Queue Item") { model.advanceQueue(-1) }.disabled(!model.canPreviousQueueItem)
Button("Next Queue Item") { model.advanceQueue(1) }.disabled(!model.canNextQueueItem) Button("Next Queue Item") { model.advanceQueue(1) }.disabled(!model.canNextQueueItem)
PlayerActionButton(model: model, action: .openStream, title: "Open Stream…") PlayerActionButton(model: model, action: .openStream, title: "Open Stream…")
@@ -3212,7 +3251,7 @@ private struct PlayerContent: View {
ScrollView { ScrollView {
VStack(spacing: 0) { VStack(spacing: 0) {
Color.clear Color.clear
.frame(height: 52) .frame(height: model.isCompact ? 12 : 52)
.allowsHitTesting(false) .allowsHitTesting(false)
.accessibilityHidden(true) .accessibilityHidden(true)
ForEach(Array(model.queue.items.enumerated()), id: \.element.id) { index, item in ForEach(Array(model.queue.items.enumerated()), id: \.element.id) { index, item in
@@ -3258,6 +3297,7 @@ private struct PlayerContent: View {
.fill(Color(SunsetPalette.actionFill)) .fill(Color(SunsetPalette.actionFill))
} }
} }
.padding(.horizontal, 8)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { model.selectQueueItem(item.id) } .onTapGesture { model.selectQueueItem(item.id) }
.help((item.url.isFileURL ? item.url.path : item.displayName) + "\nClick to play; drag to reorder.") .help((item.url.isFileURL ? item.url.path : item.displayName) + "\nClick to play; drag to reorder.")
@@ -3366,7 +3406,6 @@ private struct PlayerContent: View {
} }
.animation(queueAddAnimation, value: showsQueueURLInput) .animation(queueAddAnimation, value: showsQueueURLInput)
.onDisappear(perform: dismissQueueAdd) .onDisappear(perform: dismissQueueAdd)
.frame(width: 260)
.background(queueBackground) .background(queueBackground)
.overlay(alignment: .top) { .overlay(alignment: .top) {
queueChromeBackground(top: true) queueChromeBackground(top: true)
@@ -3383,16 +3422,26 @@ private struct PlayerContent: View {
var body: some View { var body: some View {
let titleControlsVisible = model.showsQueue || model.isQueueTransitioning || model.controlsVisible let titleControlsVisible = model.showsQueue || model.isQueueTransitioning || model.controlsVisible
let startScreen = model.fileName == nil && model.queue.items.isEmpty && !model.isCompact && !model.compactWindow.isTransitioning let startScreen = model.fileName == nil && model.queue.items.isEmpty && !model.isCompact && !model.compactWindow.isTransitioning
VStack(spacing: 0) {
HStack(spacing: 0) { HStack(spacing: 0) {
videoContent videoContent
if !model.isCompact && !model.compactWindow.isTransitioning && model.queueWidth > 0 { if !model.isCompact && !model.compactWindow.isTransitioning && model.queueWidth > 0 {
queueSidebar queueSidebar
.frame(width: 260)
.frame(width: model.queueWidth, alignment: .leading) .frame(width: model.queueWidth, alignment: .leading)
.clipped() .clipped()
.allowsHitTesting(model.showsQueue) .allowsHitTesting(model.showsQueue)
.accessibilityHidden(!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) { .overlay(alignment: .topTrailing) {
if !model.isCompact && !model.compactWindow.isTransitioning { if !model.isCompact && !model.compactWindow.isTransitioning {
titleControls titleControls
+1
View File
@@ -83,6 +83,7 @@ public struct KeyBindings: Equatable, Sendable {
.enterFullscreen: ["f", "ctrl+cmd+f"], .exitFullscreen: ["f", "ctrl+cmd+f", "escape"], .enterFullscreen: ["f", "ctrl+cmd+f"], .exitFullscreen: ["f", "ctrl+cmd+f", "escape"],
.enterCompact: ["p", "ctrl+cmd+p"], .exitCompact: ["p", "ctrl+cmd+p"], .enterCompact: ["p", "ctrl+cmd+p"], .exitCompact: ["p", "ctrl+cmd+p"],
.enableAlwaysOnTop: ["shift+p"], .disableAlwaysOnTop: ["shift+p"], .enableAlwaysOnTop: ["shift+p"], .disableAlwaysOnTop: ["shift+p"],
.showQueue: ["cmd+shift+l"], .hideQueue: ["cmd+shift+l"],
.openFile: ["o", "cmd+o"], .openStream: ["cmd+shift+o"], .openFile: ["o", "cmd+o"], .openStream: ["cmd+shift+o"],
.closeFile: ["cmd+shift+w"], .quit: ["q", "cmd+q"] .closeFile: ["cmd+shift+w"], .quit: ["q", "cmd+q"]
] ]
+4
View File
@@ -6,6 +6,7 @@ public enum PlayerAction: Hashable, Sendable {
case enableAlwaysOnTop, disableAlwaysOnTop case enableAlwaysOnTop, disableAlwaysOnTop
case openFile, openStream, closeFile, quit case openFile, openStream, closeFile, quit
case showBufferInfo, hideBufferInfo case showBufferInfo, hideBufferInfo
case showQueue, hideQueue
case enableCompactSnapping, disableCompactSnapping case enableCompactSnapping, disableCompactSnapping
case enableAutomaticQuality, disableAutomaticQuality case enableAutomaticQuality, disableAutomaticQuality
case qualityDefault, quality(Int) case qualityDefault, quality(Int)
@@ -21,6 +22,7 @@ public enum PlayerAction: Hashable, Sendable {
("enable_always_on_top", .enableAlwaysOnTop), ("disable_always_on_top", .disableAlwaysOnTop), ("enable_always_on_top", .enableAlwaysOnTop), ("disable_always_on_top", .disableAlwaysOnTop),
("open_file", .openFile), ("open_stream", .openStream), ("close_file", .closeFile), ("quit", .quit), ("open_file", .openFile), ("open_stream", .openStream), ("close_file", .closeFile), ("quit", .quit),
("show_buffer_info", .showBufferInfo), ("hide_buffer_info", .hideBufferInfo), ("show_buffer_info", .showBufferInfo), ("hide_buffer_info", .hideBufferInfo),
("show_queue", .showQueue), ("hide_queue", .hideQueue),
("enable_compact_snapping", .enableCompactSnapping), ("disable_compact_snapping", .disableCompactSnapping), ("enable_compact_snapping", .enableCompactSnapping), ("disable_compact_snapping", .disableCompactSnapping),
("enable_automatic_quality", .enableAutomaticQuality), ("disable_automatic_quality", .disableAutomaticQuality), ("enable_automatic_quality", .enableAutomaticQuality), ("disable_automatic_quality", .disableAutomaticQuality),
("quality_default", .qualityDefault) ("quality_default", .qualityDefault)
@@ -60,6 +62,8 @@ public enum PlayerAction: Hashable, Sendable {
case .disableAlwaysOnTop: return .enableAlwaysOnTop case .disableAlwaysOnTop: return .enableAlwaysOnTop
case .showBufferInfo: return .hideBufferInfo case .showBufferInfo: return .hideBufferInfo
case .hideBufferInfo: return .showBufferInfo case .hideBufferInfo: return .showBufferInfo
case .showQueue: return .hideQueue
case .hideQueue: return .showQueue
case .enableCompactSnapping: return .disableCompactSnapping case .enableCompactSnapping: return .disableCompactSnapping
case .disableCompactSnapping: return .enableCompactSnapping case .disableCompactSnapping: return .enableCompactSnapping
case .enableAutomaticQuality: return .disableAutomaticQuality case .enableAutomaticQuality: return .disableAutomaticQuality
+2 -5
View File
@@ -1,7 +1,4 @@
- [x] in the playlist sidebar, pressing the `+` button by the queue indicator should it expand it to double it's size (with a nice transition) split down the middle with the left side being a file icon and the right side being a link icon. - [x] in compact mode, "side"bar should be expandable downwards (but hidden by default).
- 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. - 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 - 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.