fix: enable window dragging outside interactive controls

Use background-only native drag gestures so scroll content and overlays remain draggable without stealing control, text, or queue-row input. Compact mode uses the same explicit drag surfaces rather than broad native background movement.
This commit is contained in:
2026-10-19 17:41:55 +02:00
parent 997eb144d2
commit 6d653e8875
8 changed files with 249 additions and 13 deletions
+22 -5
View File
@@ -36,6 +36,21 @@ class StartupStreamingServer(StreamingServer):
super()._get(request) super()._get(request)
def check_window_drag(root, app, fixtures):
result_path = root / "window-drag.json"
diagnostics = root / "window-drag.log"
result = subprocess.run(
["open", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app),
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-window-drag", str(result_path), str(fixtures)],
capture_output=True, text=True, timeout=30,
)
report = json.loads(result_path.read_text()) if result_path.exists() else {}
if result.returncode != 0 or not report.get("passed"):
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
raise RuntimeError(f"native window drag failed: {report}; {result.stdout}{result.stderr}{log}")
print("PASS native window dragging: " + str(report))
def check_queue_drag(root, app, fixtures): def check_queue_drag(root, app, fixtures):
result_path = root / "queue-drag.json" result_path = root / "queue-drag.json"
diagnostics = root / "queue-drag.log" diagnostics = root / "queue-drag.log"
@@ -671,11 +686,13 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier: if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier:
raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}") raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}")
print("PASS packaged bootstrap: registered app PID/identity, in-process player load, actual playback and Now Playing") print("PASS packaged bootstrap: registered app PID/identity, in-process player load, actual playback and Now Playing")
check_queue_drag(root, app, fixtures) check_window_drag(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_QUEUE_ONLY") != "1": if os.environ.get("TEREBI_CHECK_DRAG_ONLY") != "1":
check_window_layout(root, app, fixtures) check_queue_drag(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_LAYOUT_ONLY") != "1": if os.environ.get("TEREBI_CHECK_QUEUE_ONLY") != "1":
check_cli_instances(root, app, fixtures) check_window_layout(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_LAYOUT_ONLY") != "1":
check_cli_instances(root, app, fixtures)
finally: finally:
os.close(descriptor) os.close(descriptor)
subprocess.run( subprocess.run(
+194
View File
@@ -1,10 +1,15 @@
import AppKit import AppKit
import Darwin import Darwin
import MediaPlayer import MediaPlayer
import ObjectiveC
@_cdecl("terebi_app_main") @_cdecl("terebi_app_main")
@MainActor @MainActor
public func checkAppStartup() { public func checkAppStartup() {
if let index = CommandLine.arguments.firstIndex(of: "--check-window-drag") {
WindowDragCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
return
}
if let index = CommandLine.arguments.firstIndex(of: "--check-queue-drag") { if let index = CommandLine.arguments.firstIndex(of: "--check-queue-drag") {
QueueDragCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1])) QueueDragCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
return return
@@ -20,6 +25,195 @@ public func checkAppStartup() {
AppStartupCheck.run() AppStartupCheck.run()
} }
@MainActor
private enum WindowDragCheck {
static var timer: Timer?
static var resultURL: URL!
static var phase = 0
static var probe = 0
static var drags = 0
static var previousDrags = 0
static var start = NSPoint.zero
static var implementations: [(Method, IMP)] = []
static var target: NSWindow?
static var dragDepth = 0
static let names = ["video", "title", "playback padding", "play button", "volume slider",
"start whitespace", "start button", "start artwork", "URL text field",
"queue whitespace", "queue footer", "queue row", "queue button",
"error backdrop", "error button", "audio artwork",
"compact video", "compact padding", "compact button", "hidden controls"]
static let controls: Set<Int> = [3, 4, 6, 8, 11, 12, 14, 18]
// Infrastructure timeout only; verdicts depend on input routing, not elapsed time.
static let deadline = ContinuousClock.now.advanced(by: .seconds(25))
static func run(result: URL) {
resultURL = result
_ = NSApplication.shared
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
MainActor.assumeIsolated { advance() }
}
RunLoop.main.add(timer!, forMode: .common)
terebiAppMain()
}
static func finish(_ error: String? = nil) {
timer?.invalidate()
for (method, original) in implementations {
imp_removeBlock(method_setImplementation(method, original))
}
try! JSONSerialization.data(withJSONObject: [
"passed": error == nil, "error": error ?? "", "probes": probe, "drags": drags,
]).write(to: resultURL, options: .atomic)
PlayerModel.shared.shutdown()
NSApp.terminate(nil)
}
static func nativeView(in view: NSView, matching predicate: (NSView) -> Bool) -> NSView? {
if predicate(view) { return view }
for child in view.subviews {
if let found = nativeView(in: child, matching: predicate) { return found }
}
return nil
}
static func point(in window: NSWindow) -> NSPoint? {
let content = window.contentView!
let w = content.bounds.width
let h = content.bounds.height
let view: NSView?
switch probe {
case 0, 7: return NSPoint(x: 100, y: 160)
case 1: return NSPoint(x: 150, y: h - 16)
case 2: return NSPoint(x: w / 2 - 210, y: 48)
case 3: return NSPoint(x: w / 2, y: 64)
case 4: view = nativeView(in: content) { ($0 as? NSSlider)?.accessibilityLabel() == "Volume" }
case 5: return NSPoint(x: 600, y: 160)
case 6: return NSPoint(x: 400, y: 272)
case 8: view = nativeView(in: content) { ($0 as? NSTextField)?.isEditable == true }
case 9: return NSPoint(x: w - 130, y: h / 2)
case 10: return NSPoint(x: w - 160, y: 18)
case 11: return NSPoint(x: w - 130, y: h - 80)
case 12: return NSPoint(x: w - 183, y: 18)
case 13: return NSPoint(x: 100, y: h / 2)
case 14: return NSPoint(x: w / 2, y: h / 2 - 51.5)
case 15, 19: return NSPoint(x: w / 2, y: h / 2)
case 16: return NSPoint(x: 100, y: 100)
case 17: return NSPoint(x: w / 2 - 77, y: 28)
case 18: return NSPoint(x: w / 2 - 60, y: 28)
default: return nil
}
guard let view, !view.bounds.isEmpty else { return nil }
return view.convert(NSPoint(x: view.bounds.midX, y: view.bounds.midY), to: nil)
}
static func interceptDrags(in window: NSWindow) {
target = window
let method = class_getInstanceMethod(type(of: window), #selector(NSWindow.performDrag(with:)))!
let original = unsafeBitCast(method_getImplementation(method),
to: (@convention(c) (NSWindow, Selector, NSEvent) -> Void).self)
let handler: @convention(block) (NSWindow, NSEvent) -> Void = { window, event in
MainActor.assumeIsolated {
if window === target && dragDepth == 0 { drags += 1 }
dragDepth += 1
original(window, #selector(NSWindow.performDrag(with:)), event)
dragDepth -= 1
}
}
implementations.append((method, method_setImplementation(method, imp_implementationWithBlock(handler))))
// SwiftUI uses AppKit's completion-taking entry point on newer macOS releases.
let selector = NSSelectorFromString("performWindowDragWithEvent:completionHandler:")
if let method = class_getInstanceMethod(type(of: window), selector) {
let original = unsafeBitCast(method_getImplementation(method),
to: (@convention(c) (NSWindow, Selector, NSEvent, AnyObject?) -> Void).self)
let handler: @convention(block) (NSWindow, NSEvent, AnyObject?) -> Void = { window, event, done in
MainActor.assumeIsolated {
if window === target && dragDepth == 0 { drags += 1 }
dragDepth += 1
original(window, selector, event, done)
dragDepth -= 1
}
}
implementations.append((method, method_setImplementation(method, imp_implementationWithBlock(handler))))
}
}
static func advance() {
guard ContinuousClock.now < deadline else {
finish("Window drag timed out at \(names[probe]), phase \(phase)"); return
}
guard let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
let model = PlayerModel.shared
let fixtures = URL(fileURLWithPath: CommandLine.arguments.last!, isDirectory: true)
if target == nil {
guard model.canTogglePlayback else { return }
interceptDrags(in: window)
model.setVolume(0)
model.setPlayback(playing: false)
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
if phase == 0 {
guard NSApp.isActive, window.isKeyWindow else {
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
model.setVolume(0)
switch probe {
case 5: model.closeFile()
case 8:
for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] {
let event = NSEvent.mouseEvent(with: type, location: NSPoint(x: 400, y: 272),
modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber, context: nil, eventNumber: type == .leftMouseDown ? 1 : 2,
clickCount: 1, pressure: type == .leftMouseDown ? 1 : 0)!
NSApp.postEvent(NSEvent(cgEvent: event.cgEvent!)!, atStart: false)
}
case 9:
model.open(fixtures.appendingPathComponent("cache.mp4"))
model.showsQueue = true
case 13:
model.showsQueue = false
model.openYouTube("not a video URL")
case 15: model.open(fixtures.appendingPathComponent("audio.wav"))
case 16: model.open(fixtures.appendingPathComponent("cache.mp4"))
case 19: model.hideControls(hideCursor: false)
default: break
}
previousDrags = drags
} else if phase == 1 {
guard !model.isQueueTransitioning, !model.compactWindow.isTransitioning, !model.isBusy else { return }
if probe == 16 && !model.isCompact { model.toggleCompact(); return }
// Posted events do not move the physical pointer into the compact window.
if probe != 19 { model.setPointerInsideWindow(true) }
guard let point = point(in: window) else { return }
start = point
} else if (2...4).contains(phase) {
let type: NSEvent.EventType = phase == 2 ? .leftMouseDown : (phase == 3 ? .leftMouseDragged : .leftMouseUp)
let point = phase == 2 ? start : NSPoint(x: start.x + 20, y: start.y + 48)
let event = NSEvent.mouseEvent(with: type, location: point, modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber,
context: nil, eventNumber: probe * 8 + phase, clickCount: 1, pressure: phase == 4 ? 0 : 1)!
let native = event.cgEvent!
if type == .leftMouseDragged {
native.setDoubleValueField(.mouseEventDeltaX, value: 20)
native.setDoubleValueField(.mouseEventDeltaY, value: -48)
}
NSApp.postEvent(NSEvent(cgEvent: native)!, atStart: false)
} else if phase == 6 {
let expected = controls.contains(probe) ? 0 : 1
guard drags - previousDrags == expected else {
finish("\(names[probe]): expected \(expected) native drag requests, got \(drags - previousDrags)"); return
}
probe += 1
if probe == names.count { finish(); return }
phase = 0
return
}
phase += 1
}
}
@MainActor @MainActor
private enum QueueDragCheck { private enum QueueDragCheck {
static var timer: Timer? static var timer: Timer?
-2
View File
@@ -211,8 +211,6 @@ struct CompactWindowChecks {
"compact removes incompatible primary and auxiliary policies") "compact removes incompatible primary and auxiliary policies")
try CompactWindowChecks.require(window.contentMinSize == NSSize(width: 160, height: 90), try CompactWindowChecks.require(window.contentMinSize == NSSize(width: 160, height: 90),
"compact uses 160x90 content minimum") "compact uses 160x90 content minimum")
try CompactWindowChecks.require(window.isMovableByWindowBackground,
"compact allows background dragging")
try CompactWindowChecks.require(window.canBecomeKey, "compact accepts keyboard playback controls") try CompactWindowChecks.require(window.canBecomeKey, "compact accepts keyboard playback controls")
try CompactWindowChecks.require(window.titleVisibility == .hidden try CompactWindowChecks.require(window.titleVisibility == .hidden
&& window.titlebarAppearsTransparent, && window.titlebarAppearsTransparent,
+1 -1
View File
@@ -47,9 +47,9 @@ struct SystemPlaybackChecks {
window.isReleasedWhenClosed = false window.isReleasedWhenClosed = false
defer { window.close() } defer { window.close() }
let surface = PlaybackSurface.DragView(model: model) let surface = PlaybackSurface.DragView(model: model)
surface.frame = window.contentView!.bounds
window.contentView!.addSubview(surface) window.contentView!.addSubview(surface)
model.attachWindow(window) model.attachWindow(window)
surface.frame = window.contentView!.bounds
@MainActor func event(_ type: NSEvent.EventType, clicks: Int = 1) -> NSEvent { @MainActor func event(_ type: NSEvent.EventType, clicks: Int = 1) -> NSEvent {
NSEvent.mouseEvent(with: type, location: NSPoint(x: 100, y: 100), modifierFlags: [], NSEvent.mouseEvent(with: type, location: NSPoint(x: 100, y: 100), modifierFlags: [],
timestamp: 0, windowNumber: window.windowNumber, context: nil, timestamp: 0, windowNumber: window.windowNumber, context: nil,
+1 -1
View File
@@ -61,7 +61,7 @@ Compact entry and exit animate asynchronously over 0.25 seconds, preserving the
The normal titlebar control container uses matching 4-point top and right margins. Its horizontal padding accounts for the buttons' internal spacing to visually match the title container. The normal titlebar control container uses matching 4-point top and right margins. Its horizontal padding accounts for the buttons' internal spacing to visually match the title container.
Drag the video or unused playback background to move the window, including with the queue open. A single video click hides the controls; double-click toggles fullscreen. Playback controls stay anchored at the bottom center of the video and cannot be dragged. Native window edges remain available for resizing; the open queue allows free resizing with letterboxed video. Drag the video or any noninteractive background to move the window, including the start screen, queue, playback chrome, loading/error screens, audio-only playback, casting and compact mode. Buttons, menus, sliders, editable/selectable text and queue rows retain their own mouse input. A single video click hides the controls; double-click toggles fullscreen. Playback controls stay anchored at the bottom center of the video. Native edges remain available for resizing; macOS fullscreen keeps its fixed window bounds.
Active playback prevents idle system sleep. Local video also keeps the display awake; audio-only playback allows display sleep. Pause, end, close, playback failure and quit release sleep prevention. Active playback prevents idle system sleep. Local video also keeps the display awake; audio-only playback allows display sleep. Pause, end, close, playback failure and quit release sleep prevention.
+2 -1
View File
@@ -334,7 +334,8 @@ final class CompactWindowController: NSObject {
window.collectionBehavior = behavior window.collectionBehavior = behavior
window.level = .floating window.level = .floating
window.hidesOnDeactivate = false window.hidesOnDeactivate = false
window.isMovableByWindowBackground = true // Explicit drag surfaces preserve controls' mouse tracking in compact mode too.
window.isMovableByWindowBackground = false
fitCompactWindow(window, initial: true, animated: animated) fitCompactWindow(window, initial: true, animated: animated)
} }
+23
View File
@@ -2101,6 +2101,7 @@ private struct PlayerContent: View {
@ViewState private var queueFrames: [UUID: CGRect] = [:] @ViewState private var queueFrames: [UUID: CGRect] = [:]
@ViewState private var queueDrag: PlaybackQueueDrag? @ViewState private var queueDrag: PlaybackQueueDrag?
@GestureState private var queueDragging = false @GestureState private var queueDragging = false
@GestureState private var windowDragging = false
@ViewState private var queueScroll = ScrollPosition(y: 0) @ViewState private var queueScroll = ScrollPosition(y: 0)
@ViewState private var queueScrollOffset: CGFloat = 0 @ViewState private var queueScrollOffset: CGFloat = 0
@ViewState private var queueViewportHeight: CGFloat = 0 @ViewState private var queueViewportHeight: CGFloat = 0
@@ -2228,6 +2229,8 @@ private struct PlayerContent: View {
.padding(.horizontal, size.width < 600 ? 14 : 30) .padding(.horizontal, size.width < 600 ? 14 : 30)
.padding(.top, 64) .padding(.top, 64)
.padding(.bottom, 24) .padding(.bottom, 24)
.frame(maxWidth: .infinity, minHeight: size.height, alignment: .topLeading)
.background(windowDragBackground)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
@@ -2337,6 +2340,7 @@ private struct PlayerContent: View {
.padding(.trailing, 92 - 88 * model.queueWidth / 260) .padding(.trailing, 92 - 88 * model.queueWidth / 260)
.frame(height: 32) .frame(height: 32)
.foregroundStyle(Color(SunsetPalette.text)) .foregroundStyle(Color(SunsetPalette.text))
.background(windowDragBackground)
.onHover(perform: model.setTitleHovered) .onHover(perform: model.setTitleHovered)
.animation(controlsVisibilityAnimation) { content in .animation(controlsVisibilityAnimation) { content in
content.opacity(model.controlsVisible ? 1 : 0) content.opacity(model.controlsVisible ? 1 : 0)
@@ -2357,6 +2361,7 @@ private struct PlayerContent: View {
} }
.padding(.horizontal, 4 * (1 - model.queueWidth / 260)) .padding(.horizontal, 4 * (1 - model.queueWidth / 260))
.frame(height: 24) .frame(height: 24)
.background(windowDragBackground)
.background { .background {
PlayerGlass(cornerRadius: 8) PlayerGlass(cornerRadius: 8)
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 8))
@@ -2434,6 +2439,7 @@ private struct PlayerContent: View {
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, showsBufferReadout ? 6 : 8) .padding(.vertical, showsBufferReadout ? 6 : 8)
.foregroundStyle(Color(SunsetPalette.text)) .foregroundStyle(Color(SunsetPalette.text))
.background(windowDragBackground)
.background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10))) .background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10)))
.onHover(perform: model.setControlsHovered) .onHover(perform: model.setControlsHovered)
.animation(controlsVisibilityAnimation) { content in .animation(controlsVisibilityAnimation) { content in
@@ -2673,6 +2679,8 @@ private struct PlayerContent: View {
.highPriorityGesture(queueRowDrag(item)) .highPriorityGesture(queueRowDrag(item))
} }
} }
.frame(maxWidth: .infinity, minHeight: queueViewportHeight, alignment: .top)
.background(windowDragBackground)
.coordinateSpace(name: "queueContent") .coordinateSpace(name: "queueContent")
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { queueContentHeight = $0 } .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { queueContentHeight = $0 }
} }
@@ -2743,6 +2751,7 @@ private struct PlayerContent: View {
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 8) .padding(.vertical, 8)
.background(windowDragBackground)
.background { queueChromeBackground(top: false) } .background { queueChromeBackground(top: false) }
} }
.frame(width: 260) .frame(width: 260)
@@ -2794,6 +2803,14 @@ private struct PlayerContent: View {
.foregroundStyle(Color(SunsetPalette.text)) .foregroundStyle(Color(SunsetPalette.text))
.tint(Color(SunsetPalette.accent)) .tint(Color(SunsetPalette.accent))
.background(WindowChrome(model: model)) .background(WindowChrome(model: model))
.onChange(of: windowDragging) { _, active in model.setControlsDragging(active) }
}
private var windowDragBackground: some View {
Color.clear.contentShape(Rectangle())
.gesture(WindowDragGesture()
.updating($windowDragging) { _, active, _ in active = true })
.padding(5) // Preserve the native resize border.
} }
private var videoContent: some View { private var videoContent: some View {
@@ -2837,6 +2854,7 @@ private struct PlayerContent: View {
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false)) .background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
.padding(.top, 44).padding(.trailing, 16) .padding(.top, 44).padding(.trailing, 16)
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
.allowsHitTesting(false)
} }
if let error = model.errorText { if let error = model.errorText {
VStack(spacing: 12) { VStack(spacing: 12) {
@@ -2849,6 +2867,7 @@ private struct PlayerContent: View {
.frame(maxWidth: 420) .frame(maxWidth: 420)
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false)) .background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(windowDragBackground)
} else if model.isBusy { } else if model.isBusy {
VStack(spacing: 12) { VStack(spacing: 12) {
ProgressView().controlSize(.large) ProgressView().controlSize(.large)
@@ -2857,6 +2876,7 @@ private struct PlayerContent: View {
.font(.callout).foregroundStyle(Color(SunsetPalette.secondaryText)) .font(.callout).foregroundStyle(Color(SunsetPalette.secondaryText))
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.allowsHitTesting(false)
} else if model.fileName == nil { } else if model.fileName == nil {
startScreen(in: geometry.size) startScreen(in: geometry.size)
} else if let device = model.castDevice { } else if let device = model.castDevice {
@@ -2867,6 +2887,7 @@ private struct PlayerContent: View {
} }
.foregroundStyle(Color(SunsetPalette.text)) .foregroundStyle(Color(SunsetPalette.text))
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(windowDragBackground)
} else if !model.state.has_video && model.state.has_audio { } else if !model.state.has_video && model.state.has_audio {
VStack(spacing: 16) { VStack(spacing: 16) {
Image(systemName: "waveform").font(.system(size: 64, weight: .ultraLight)) Image(systemName: "waveform").font(.system(size: 64, weight: .ultraLight))
@@ -2875,6 +2896,7 @@ private struct PlayerContent: View {
.foregroundStyle(Color(SunsetPalette.secondaryText)) .foregroundStyle(Color(SunsetPalette.secondaryText))
.padding(40) .padding(40)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.allowsHitTesting(false)
} }
if model.state.buffering && !model.isBusy && model.errorText == nil { if model.state.buffering && !model.isBusy && model.errorText == nil {
ProgressView("Buffering…") ProgressView("Buffering…")
@@ -2928,6 +2950,7 @@ private struct PlayerContent: View {
if model.canTogglePlayback { if model.canTogglePlayback {
playbackControls playbackControls
.frame(width: 440, height: controlHeight) .frame(width: 440, height: controlHeight)
.background(windowDragBackground)
.background { .background {
PlayerGlass() PlayerGlass()
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 12))
+6 -3
View File
@@ -10,6 +10,9 @@
- 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
- [x] 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; top-right controls stay anchored to the moving window corner; native checks passed for rendered controls during transitions, stable video size, reversals, screen-edge fitting, fullscreen and compact restoration; animation feel and Reduce Motion still need manual verification - synchronized 250 ms sidebar/window reveal, reversible mid-transition, with immediate Reduce Motion changes; top-right controls stay anchored to the moving window corner; native checks passed for rendered controls during transitions, stable video size, reversals, screen-edge fitting, fullscreen and compact restoration; animation feel and Reduce Motion still need manual verification
- [ ] make the window draggable (in all cases/view states) so long as a button/clickable element is not hovered. - [x] make the window draggable (in all cases/view states) so long as a button/clickable element is not hovered.
- [ ] in the "choose what to play" screen, we should change the youtube button to general streaming - scoped native background dragging across start/playback/queue/error/Cast/compact views, with passive loading/audio overlays passing input through; 20 native input-routing probes passed, including controls and row-reorder exclusions; physical pointer dragging and Cast-screen interaction still need manual verification
- 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) - [ ] on the start screen:
- [ ] make the youtube url input field that appears when clicking the youtube button appear with a nice transition
- [ ] 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)