feat: animate queue sidebar expansion and collapse
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user