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:
@@ -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,6 +686,8 @@ 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_window_drag(root, app, fixtures)
|
||||||
|
if os.environ.get("TEREBI_CHECK_DRAG_ONLY") != "1":
|
||||||
check_queue_drag(root, app, fixtures)
|
check_queue_drag(root, app, fixtures)
|
||||||
if os.environ.get("TEREBI_CHECK_QUEUE_ONLY") != "1":
|
if os.environ.get("TEREBI_CHECK_QUEUE_ONLY") != "1":
|
||||||
check_window_layout(root, app, fixtures)
|
check_window_layout(root, app, fixtures)
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
- [ ] 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)
|
- 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