feat: animate queue sidebar expansion and collapse

This commit is contained in:
2026-10-18 21:48:06 +02:00
parent e4504ea5c4
commit 22f13dd08d
6 changed files with 183 additions and 31 deletions
+7 -4
View File
@@ -47,11 +47,14 @@ def check_window_layout(root, app, fixtures):
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 16
if (result.returncode != 0 or not report.get("passed") or report.get("steps") != 32 expected_queue_animations = 0 if report.get("reduceMotion") else 9
or report.get("animatedTransitions") != expected_animations): if (result.returncode != 0 or not report.get("passed") or report.get("steps") != 36
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}") raise RuntimeError(f"native window layout failed: {report}; {result.stdout}{result.stderr}{log}")
print(f"PASS native SwiftUI window layout: {report['animatedTransitions']} observed animations, " print(f"PASS native SwiftUI window layout: {report['animatedTransitions']} compact and "
"responsive mid-animation requests, queue open/closed, minimum sizes and exact normal geometry restored") f"{report['animatedQueueTransitions']} queue animations, stable video size, responsive reversals, "
"screen-edge fitting, fullscreen fixed bounds, minimum sizes and exact normal geometry restored")
def check_cli_instances(root, app, fixtures): def check_cli_instances(root, app, fixtures):
+78 -7
View File
@@ -27,6 +27,10 @@ private enum WindowLayoutCheck {
static var animationTarget = NSRect.zero static var animationTarget = NSRect.zero
static var sawIntermediateFrame = false static var sawIntermediateFrame = false
static var animatedTransitions = 0 static var animatedTransitions = 0
static var animatedQueueTransitions = 0
static var queueVideoSize: NSSize?
static var observingQueue = false
static var animationQueueWidth: CGFloat = 0
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))
@@ -53,7 +57,9 @@ private enum WindowLayoutCheck {
static func finish(_ error: String? = nil) { static func finish(_ error: String? = nil) {
timer?.invalidate() timer?.invalidate()
let result: [String: Any] = ["passed": error == nil, "error": error ?? "", "steps": step, let result: [String: Any] = ["passed": error == nil, "error": error ?? "", "steps": step,
"animatedTransitions": animatedTransitions, "reduceMotion": !expectsAnimation] "animatedTransitions": animatedTransitions,
"animatedQueueTransitions": animatedQueueTransitions,
"reduceMotion": !expectsAnimation]
try! JSONSerialization.data(withJSONObject: result).write(to: resultURL, options: .atomic) try! JSONSerialization.data(withJSONObject: result).write(to: resultURL, options: .atomic)
PlayerModel.shared.shutdown() PlayerModel.shared.shutdown()
NSApp.terminate(nil) NSApp.terminate(nil)
@@ -68,6 +74,7 @@ private enum WindowLayoutCheck {
animationStart = window.frame animationStart = window.frame
animationTarget = target animationTarget = target
sawIntermediateFrame = false sawIntermediateFrame = false
animationQueueWidth = PlayerModel.shared.queueWidth
} }
static func sidebarHeaderIsVisible(in window: NSWindow) -> Bool { static func sidebarHeaderIsVisible(in window: NSWindow) -> Bool {
@@ -81,11 +88,13 @@ private enum WindowLayoutCheck {
// Each control must paint a visible glyph, not merely exist in the view tree. // Each control must paint a visible glyph, not merely exist in the view tree.
return (0..<3).allSatisfy { index in return (0..<3).allSatisfy { index in
var visiblePixels = 0 var visiblePixels = 0
// Fullscreen disables pinning; its glyph uses the native dimmed tint.
let minimumBrightness = index == 0 && window.styleMask.contains(.fullScreen) ? 0.25 : 0.5
for x in Int(CGFloat(index * 28) * scale)..<Int(CGFloat(index * 28 + 24) * scale) { for x in Int(CGFloat(index * 28) * scale)..<Int(CGFloat(index * 28 + 24) * scale) {
for y in 0..<bitmap.pixelsHigh { for y in 0..<bitmap.pixelsHigh {
if let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB), if let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB),
color.alphaComponent > 0.5, color.alphaComponent > 0.5,
min(color.redComponent, color.greenComponent, color.blueComponent) > 0.5 { min(color.redComponent, color.greenComponent, color.blueComponent) > minimumBrightness {
visiblePixels += 1 visiblePixels += 1
} }
} }
@@ -99,29 +108,73 @@ private enum WindowLayoutCheck {
guard ContinuousClock.now < deadline else { finish("Window layout did not settle at step \(step)"); return } guard ContinuousClock.now < deadline else { finish("Window layout did not settle at step \(step)"); return }
guard model.canTogglePlayback, model.state.has_video, guard model.canTogglePlayback, model.state.has_video,
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 { if model.compactWindow.isTransitioning || model.isQueueTransitioning {
window.layoutIfNeeded()
if let size = queueVideoSize, let video = model.videoView {
guard abs(video.bounds.width - size.width) < 2, abs(video.bounds.height - size.height) < 2 else {
finish("Queue animation changed video size at step \(step): \(video.bounds.size), expected \(size)"); return
}
}
if expectsAnimation, let start = animationStart, !sawIntermediateFrame, if expectsAnimation, let start = animationStart, !sawIntermediateFrame,
!sameFrame(window.frame, start), !sameFrame(window.frame, animationTarget) { (!sameFrame(window.frame, start) && !sameFrame(window.frame, animationTarget) ||
observingQueue && abs(model.queueWidth - animationQueueWidth) > 1 &&
model.queueWidth > 1 && model.queueWidth < 259) {
sawIntermediateFrame = true sawIntermediateFrame = true
// These run-loop-delivered requests must remain responsive mid-animation. // These run-loop-delivered requests must remain responsive mid-animation.
if model.isQueueTransitioning {
let frame = window.frame
model.showsQueue.toggle()
model.showsQueue.toggle()
guard sameFrame(window.frame, frame) else { finish("Queue reversal jumped the window"); return }
} else {
model.toggleCompact() model.toggleCompact()
model.toggleCompact() model.toggleCompact()
} }
}
return return
} }
if animationStart != nil { if animationStart != nil {
if expectsAnimation { if expectsAnimation {
guard sawIntermediateFrame else { finish("No nonblocking animation frames at step \(step)"); return } guard sawIntermediateFrame else { finish("No nonblocking animation frames at step \(step)"); return }
animatedTransitions += 1 if observingQueue { animatedQueueTransitions += 1 }
else { animatedTransitions += 1 }
} }
animationStart = nil animationStart = nil
guard sameFrame(window.frame, animationTarget) else {
finish("Animation missed target at step \(step): \(window.frame), expected \(animationTarget)"); return
}
queueVideoSize = nil
observingQueue = false
} }
if windowIdentity == nil { windowIdentity = ObjectIdentifier(window) } if windowIdentity == nil { windowIdentity = ObjectIdentifier(window) }
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 }
window.layoutIfNeeded() window.layoutIfNeeded()
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 >= 33 {
switch step {
case 33, 34:
guard model.isFullScreen else { finish("Fullscreen entry failed"); return }
normalFrame = window.frame
observeAnimation(from: window, to: normalFrame)
observingQueue = true
model.showsQueue.toggle()
case 35:
guard sameFrame(window.frame, normalFrame), model.showsQueue,
let video = model.videoView, let content = window.contentView,
abs(video.bounds.width - (content.bounds.width - 260)) < 2 else {
finish("Fullscreen queue did not retain fixed window bounds and fit the video"); return
}
model.toggleFullScreen()
default:
guard !model.isFullScreen else { finish("Fullscreen exit failed"); return }
finish(); return
}
step += 1
return
}
switch step % 8 { switch step % 8 {
case 0, 4: case 0, 4:
if step > 0 { if step > 0 {
@@ -130,8 +183,26 @@ private enum WindowLayoutCheck {
abs(actual.width - normalFrame.width) < 1, abs(actual.height - normalFrame.height) < 1, abs(actual.width - normalFrame.width) < 1, abs(actual.height - normalFrame.height) < 1,
!model.isCompact else { finish("Normal geometry was not restored at step \(step): \(actual)"); return } !model.isCompact else { finish("Normal geometry was not restored at step \(step): \(actual)"); return }
} }
if step == 32 { finish(); return } if step == 32 {
model.showsQueue = step % 8 == 4 model.toggleFullScreen()
step += 1
return
}
let showsQueue = step % 8 == 4
if showsQueue != model.showsQueue {
if showsQueue, let screen = window.screen?.visibleFrame {
window.setFrameOrigin(NSPoint(x: screen.maxX - window.frame.width, y: window.frame.minY))
}
var target = window.frame
target.size.width += showsQueue ? 260 : -260
if let screen = window.screen?.visibleFrame {
target.origin.x = max(screen.minX, min(target.minX, screen.maxX - target.width))
}
observeAnimation(from: window, to: target)
observingQueue = true
queueVideoSize = model.videoView?.bounds.size
model.showsQueue = showsQueue
}
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)
normalFrame = window.frame normalFrame = window.frame
+4
View File
@@ -299,10 +299,12 @@ struct SystemPlaybackChecks {
model.attachWindow(window) model.attachWindow(window)
let videoFrame = window.frame let videoFrame = window.frame
model.showsQueue = true model.showsQueue = true
until("queue opening settles") { !model.isQueueTransitioning }
require(abs(window.frame.width - videoFrame.width - 260) < 0.01 && require(abs(window.frame.width - videoFrame.width - 260) < 0.01 &&
abs(window.frame.height - videoFrame.height) < 0.01, abs(window.frame.height - videoFrame.height) < 0.01,
"opening queue grows window without shrinking video") "opening queue grows window without shrinking video")
model.showsQueue = false model.showsQueue = false
until("queue closing settles") { !model.isQueueTransitioning }
require(abs(window.frame.width - videoFrame.width) < 0.01 && require(abs(window.frame.width - videoFrame.width) < 0.01 &&
abs(window.frame.height - videoFrame.height) < 0.01, abs(window.frame.height - videoFrame.height) < 0.01,
"closing queue restores video-only window size") "closing queue restores video-only window size")
@@ -327,6 +329,7 @@ struct SystemPlaybackChecks {
} }
until("open starts authoritative playback") { model.canTogglePlayback && model.state.status == PLAYER_PLAYING } until("open starts authoritative playback") { model.canTogglePlayback && model.state.status == PLAYER_PLAYING }
model.showsQueue = true model.showsQueue = true
until("queue opens before live resize") { !model.isQueueTransitioning }
for delta in [NSSize(width: 160, height: 0), NSSize(width: 0, height: 90), for delta in [NSSize(width: 160, height: 0), NSSize(width: 0, height: 90),
NSSize(width: -100, height: 0), NSSize(width: 0, height: -60)] { NSSize(width: -100, height: 0), NSSize(width: 0, height: -60)] {
let proposal = NSSize(width: window.frame.width + delta.width, height: window.frame.height + delta.height) let proposal = NSSize(width: window.frame.width + delta.width, height: window.frame.height + delta.height)
@@ -343,6 +346,7 @@ struct SystemPlaybackChecks {
"aspect-locked queue window follows the dragged axis") "aspect-locked queue window follows the dragged axis")
} }
model.showsQueue = false model.showsQueue = false
until("queue closes after live resize") { !model.isQueueTransitioning }
require(center.playbackState == .playing, "playing publishes macOS playback ownership") require(center.playbackState == .playing, "playing publishes macOS playback ownership")
requirePlaybackPower(system: true, display: true, "local video keeps the Mac and display awake") requirePlaybackPower(system: true, display: true, "local video keeps the Mac and display awake")
var bufferingState = model.state var bufferingState = model.state
+1 -1
View File
@@ -67,7 +67,7 @@ The fixed bottom row holds the queue label/count and **+** on the left, with pre
Queue rows show a medium-weight, two-line filename without its extension, with a smaller format label beneath it. Rows have a 56-point minimum height, 8-point vertical and 4-point horizontal internal padding, and no horizontal outer insets. The subdued remove button sits beneath the media icon in the left column, leaving the remaining width for the filename. The current item has an accent play marker and a subtle rounded highlight. Full filenames and paths remain available in row tooltips. Queue rows show a medium-weight, two-line filename without its extension, with a smaller format label beneath it. Rows have a 56-point minimum height, 8-point vertical and 4-point horizontal internal padding, and no horizontal outer insets. The subdued remove button sits beneath the media icon in the left column, leaving the remaining width for the filename. The current item has an accent play marker and a subtle rounded highlight. Full filenames and paths remain available in row tooltips.
In a normal window, showing the queue expands the window outward while preserving the video's size; hiding it removes that extra width. The sidebar meets the video directly, without a separate divider gap. The window shifts left when necessary near the screen's right edge. Fullscreen keeps its fixed bounds. The queue expands and collapses with a 250 ms ease-in/out reveal. In a normal window, the sidebar and window resize together while preserving the video's size; hiding it removes that extra width. Repeated toggles reverse from the current width without jumping. The sidebar meets the video directly, without a separate divider gap, and the window shifts left when necessary near the screen's right edge. Fullscreen animates the sidebar within its fixed bounds. Reduce Motion makes the change immediate; entering compact/fullscreen mode or starting a native resize settles an in-flight reveal first.
Resizing with the queue open preserves the video's aspect ratio while keeping the sidebar at a fixed width; closing the queue retains ordinary aspect-locked video resizing. Resizing with the queue open preserves the video's aspect ratio while keeping the sidebar at a fixed width; closing the queue retains ordinary aspect-locked video resizing.
+89 -16
View File
@@ -34,21 +34,14 @@ final class PlayerModel: ObservableObject {
@Published private(set) var queue = PlaybackQueue() @Published private(set) var queue = PlaybackQueue()
@Published var showsQueue = false { @Published var showsQueue = false {
didSet { didSet {
guard showsQueue != oldValue, let window, !isFullScreen, if showsQueue != oldValue { transitionQueue() }
!isCompact, !compactWindow.isTransitioning else { return }
// Setting aspectRatio to zero leaves AppKit's resize increments at zero.
window.resizeIncrements = NSSize(width: 1, height: 1)
var frame = window.frame
frame.size.width += showsQueue ? 260 : -260
if let screen = window.screen?.visibleFrame {
frame.origin.x = max(screen.minX, min(frame.minX, screen.maxX - frame.width))
}
window.setFrame(frame, display: true)
if !showsQueue, state.has_video, state.video_aspect_ratio > 0 {
window.aspectRatio = NSSize(width: state.video_aspect_ratio, height: 1)
}
} }
} }
@Published private(set) var queueWidth: CGFloat = 0
private var queueAnimation: Timer?
private var queueAnimationCompletion: (() -> Void)?
private var queueWindowVideoWidth: CGFloat?
var isQueueTransitioning: Bool { queueAnimation != nil }
private var queuePlaybackRevision: UInt64? private var queuePlaybackRevision: UInt64?
private var queueCompletionHandled = false private var queueCompletionHandled = false
private var queueCastDevice: CastDevice? private var queueCastDevice: CastDevice?
@@ -759,8 +752,75 @@ final class PlayerModel: ObservableObject {
perform(muteAction) perform(muteAction)
} }
private func transitionQueue() {
queueAnimation?.invalidate()
queueAnimation = nil
queueAnimationCompletion = nil
let targetWidth: CGFloat = showsQueue ? 260 : 0
guard let window, !isCompact, !compactWindow.isTransitioning else {
queueWidth = targetWidth
queueWindowVideoWidth = nil
return
}
let startWidth = queueWidth
let startFrame = window.frame
var targetFrame = startFrame
if !isFullScreen {
// Setting aspectRatio to zero leaves AppKit's resize increments at zero.
window.resizeIncrements = NSSize(width: 1, height: 1)
let videoWidth = queueWindowVideoWidth ?? startFrame.width - startWidth
queueWindowVideoWidth = videoWidth
targetFrame.size.width = videoWidth + targetWidth
if let screen = window.screen?.visibleFrame {
targetFrame.origin.x = max(screen.minX, min(startFrame.minX, screen.maxX - targetFrame.width))
}
}
// One clock drives the reveal and native frame so the video never shrinks between them.
let update: @MainActor (Double) -> Void = { [weak self, weak window] progress in
guard let self, let window else { return }
self.queueWidth = startWidth + (targetWidth - startWidth) * progress
if targetFrame != startFrame {
window.setFrame(NSRect(x: startFrame.minX + (targetFrame.minX - startFrame.minX) * progress,
y: startFrame.minY,
width: startFrame.width + (targetFrame.width - startFrame.width) * progress,
height: startFrame.height), display: true)
}
}
queueAnimationCompletion = { [weak self, weak window] in
update(1)
if let self, let window, !self.showsQueue, !self.isFullScreen,
self.state.has_video, self.state.video_aspect_ratio > 0 {
window.aspectRatio = NSSize(width: self.state.video_aspect_ratio, height: 1)
}
}
guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else {
finishQueueTransition()
return
}
let start = ContinuousClock.now
let animation = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] _ in
MainActor.assumeIsolated {
let progress = min(1, start.duration(to: .now) / .milliseconds(250))
if progress >= 1 { self?.finishQueueTransition() }
else { update(progress * progress * (3 - 2 * progress)) }
}
}
queueAnimation = animation
RunLoop.main.add(animation, forMode: .common)
}
func finishQueueTransition() {
queueAnimation?.invalidate()
queueAnimation = nil
let completion = queueAnimationCompletion
queueAnimationCompletion = nil
queueWindowVideoWidth = nil
completion?()
}
func attachWindow(_ window: NSWindow) { func attachWindow(_ window: NSWindow) {
guard self.window !== window else { return } guard self.window !== window else { return }
finishQueueTransition()
self.window = window self.window = window
window.title = fileName ?? "Terebi" window.title = fileName ?? "Terebi"
window.contentMinSize = NSSize(width: 400, height: 240) window.contentMinSize = NSSize(width: 400, height: 240)
@@ -853,6 +913,7 @@ final class PlayerModel: ObservableObject {
case .stepFrameForward: return stepFrame(1) case .stepFrameForward: return stepFrame(1)
case .stepFrameBackward: return stepFrame(-1) case .stepFrameBackward: return stepFrame(-1)
case .enterFullscreen, .exitFullscreen: case .enterFullscreen, .exitFullscreen:
finishQueueTransition()
if isCompact { if isCompact {
compactIntent = false compactIntent = false
compactWindow.restore(resumeFullScreen: false, animated: false) compactWindow.restore(resumeFullScreen: false, animated: false)
@@ -861,6 +922,7 @@ final class PlayerModel: ObservableObject {
window?.toggleFullScreen(nil) window?.toggleFullScreen(nil)
revealControls() revealControls()
case .enterCompact: case .enterCompact:
finishQueueTransition()
compactIntent = true compactIntent = true
compactWindow.toggle(aspect: state.video_aspect_ratio) compactWindow.toggle(aspect: state.video_aspect_ratio)
controlsHovered = false controlsHovered = false
@@ -970,7 +1032,7 @@ final class PlayerModel: ObservableObject {
private func fitVideoWindow() { private func fitVideoWindow() {
guard needsVideoFit, canTogglePlayback, let window, !isFullScreen, guard needsVideoFit, canTogglePlayback, let window, !isFullScreen,
!compactWindow.isTransitioning else { return } !compactWindow.isTransitioning, !isQueueTransitioning else { return }
let aspect = state.video_aspect_ratio let aspect = state.video_aspect_ratio
if isCompact { if isCompact {
compactWindow.updateAspect(state.video_aspect_ratio) compactWindow.updateAspect(state.video_aspect_ratio)
@@ -1094,6 +1156,7 @@ final class PlayerModel: ObservableObject {
} }
func shutdown() { func shutdown() {
finishQueueTransition()
saveProgress() saveProgress()
if castDevice != nil { stopCasting(resume: false) } if castDevice != nil { stopCasting(resume: false) }
castDiscovery.stop() castDiscovery.stop()
@@ -2440,11 +2503,15 @@ private struct PlayerContent: View {
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
videoContent videoContent
if model.showsQueue && !model.isCompact && !model.compactWindow.isTransitioning { if !model.isCompact && !model.compactWindow.isTransitioning && model.queueWidth > 0 {
queueSidebar queueSidebar
.frame(width: model.queueWidth, alignment: .leading)
.clipped()
.allowsHitTesting(model.showsQueue)
.accessibilityHidden(!model.showsQueue)
} }
} }
.frame(minWidth: model.isCompact || model.compactWindow.isTransitioning ? 160 : (model.showsQueue ? 660 : 400), .frame(minWidth: model.isCompact || model.compactWindow.isTransitioning ? 160 : 400 + model.queueWidth,
minHeight: model.isCompact || model.compactWindow.isTransitioning ? model.compactContentMinimumHeight : 240) minHeight: model.isCompact || model.compactWindow.isTransitioning ? model.compactContentMinimumHeight : 240)
.ignoresSafeArea() .ignoresSafeArea()
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
@@ -2758,6 +2825,12 @@ private struct WindowChrome: NSViewRepresentable {
window.acceptsMouseMovedEvents = true window.acceptsMouseMovedEvents = true
window.collectionBehavior.insert(.fullScreenPrimary) window.collectionBehavior.insert(.fullScreenPrimary)
window.appearance = NSAppearance(named: .darkAqua) window.appearance = NSAppearance(named: .darkAqua)
for name in [NSWindow.willEnterFullScreenNotification, NSWindow.willStartLiveResizeNotification,
NSWindow.willCloseNotification] {
observers.append(NotificationCenter.default.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in
MainActor.assumeIsolated { self?.model.finishQueueTransition() }
})
}
for name in [NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification, for name in [NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification,
NSWindow.didEnterFullScreenNotification, NSWindow.didExitFullScreenNotification] { NSWindow.didEnterFullScreenNotification, NSWindow.didExitFullScreenNotification] {
observers.append(NotificationCenter.default.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in observers.append(NotificationCenter.default.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in
+2 -1
View File
@@ -8,6 +8,7 @@
- installs to ~/Applications, migrates the old hidden app, registers with Launch Services and requests Spotlight indexing; actual install, CLI, code signature, registration and Spotlight query passed; Spotlight UI remains unverified - installs to ~/Applications, migrates the old hidden app, registers with Launch Services and requests Spotlight indexing; actual install, CLI, code signature, registration and Spotlight query passed; Spotlight UI remains unverified
- [x] takes an awfully long time seek in local videos which i find weird, since seeking in streamed youtube videos is pretty much instant - [x] takes an awfully long time seek in local videos which i find weird, since seeking in streamed youtube videos is pretty much instant
- local seek preroll skips disposable non-reference pictures without sacrificing exact target frames; measured 1080p/10-second-GOP paused seek improved from 541 ms to 400 ms and playing seek from 682 ms to 488 ms; timestamp/pixel checks passed across six decoder formats; long-GOP reference decoding remains necessary, and the originally affected files still need manual verification - local seek preroll skips disposable non-reference pictures without sacrificing exact target frames; measured 1080p/10-second-GOP paused seek improved from 541 ms to 400 ms and playing seek from 682 ms to 488 ms; timestamp/pixel checks passed across six decoder formats; long-GOP reference decoding remains necessary, and the originally affected files still need manual verification
- [ ] make the sidebar expand/collapse with a nice transition - [x] make the sidebar expand/collapse with a nice transition
- synchronized 250 ms sidebar/window reveal, reversible mid-transition, with immediate Reduce Motion changes; native checks passed for stable video size, reversals, screen-edge fitting, fullscreen and compact restoration; animation feel and Reduce Motion still need manual verification
- [ ] in the "choose what to play" screen, we should change the youtube button to general streaming - [ ] in the "choose what to play" screen, we should change the youtube button to general streaming
- we can figure out the stream and how to handle it from the url (i.e. if youtube url, handle as youtube stream; if miruro url, handle accordingly) - we can figure out the stream and how to handle it from the url (i.e. if youtube url, handle as youtube stream; if miruro url, handle accordingly)