Compare commits

...

3 Commits

Author SHA1 Message Date
hl-valdemar d068c52e04 fix: route the URL shortcut to the inline input 2026-10-19 19:11:36 +02:00
hl-valdemar f6485bf221 feat: animate the start-screen URL field
Use a scoped 250 ms fade and top-anchored scale transition with animated recent-row reflow. Respect Reduce Motion, preserve the fixed window size, and exercise native focus, draft retention and rapid reversals.
2026-10-19 19:05:17 +02:00
hl-valdemar 6d653e8875 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.
2026-10-19 19:05:17 +02:00
9 changed files with 322 additions and 36 deletions
+22 -5
View File
@@ -36,6 +36,21 @@ class StartupStreamingServer(StreamingServer):
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):
result_path = root / "queue-drag.json"
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:
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")
check_queue_drag(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_QUEUE_ONLY") != "1":
check_window_layout(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_LAYOUT_ONLY") != "1":
check_cli_instances(root, app, fixtures)
check_window_drag(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_DRAG_ONLY") != "1":
check_queue_drag(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_QUEUE_ONLY") != "1":
check_window_layout(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_LAYOUT_ONLY") != "1":
check_cli_instances(root, app, fixtures)
finally:
os.close(descriptor)
subprocess.run(
+238
View File
@@ -1,10 +1,15 @@
import AppKit
import Darwin
import MediaPlayer
import ObjectiveC
@_cdecl("terebi_app_main")
@MainActor
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") {
QueueDragCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
return
@@ -20,6 +25,239 @@ public func checkAppStartup() {
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 var urlWindowFrame = NSRect.zero
static let urlDraft = "https://www.youtube.com/watch?v=aqz-KE-bpKQ"
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 clickStartURL(in window: NSWindow) {
if phase != 0 {
let event = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [.command, .shift],
timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber,
context: nil, characters: "O", charactersIgnoringModifiers: "o", isARepeat: false, keyCode: 31)!
NSApp.postEvent(event, atStart: false)
return
}
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: probe * 100 + phase * 2 + (type == .leftMouseDown ? 0 : 1),
clickCount: 1, pressure: type == .leftMouseDown ? 1 : 0)!
NSApp.postEvent(NSEvent(cgEvent: event.cgEvent!)!, atStart: false)
}
}
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)
window.makeMain()
NSApp.activate(ignoringOtherApps: true)
}
if phase == 0 {
guard NSApp.isActive, window.isKeyWindow, window.isMainWindow else {
window.makeKeyAndOrderFront(nil)
window.makeMain()
NSApp.activate(ignoringOtherApps: true)
return
}
model.setVolume(0)
switch probe {
case 5: model.closeFile()
case 8:
urlWindowFrame = window.frame
clickStartURL(in: window)
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
}
if probe == 8 {
guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else {
finish("Revealing the URL field did not focus its editor"); return
}
editor.insertText(urlDraft, replacementRange: NSRange(location: NSNotFound, length: 0))
clickStartURL(in: window)
phase = 7
return
}
probe += 1
if probe == names.count { finish(); return }
phase = 0
return
} else if probe == 8 && phase == 7 {
guard (window.firstResponder as? NSTextView)?.isFieldEditor != true else {
finish("Collapsing the URL field retained keyboard focus"); return
}
clickStartURL(in: window) // Reverse the collapse before its animation completes.
} else if probe == 8 && phase == 8 {
guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else { return }
guard editor.string == urlDraft else { finish("Reopening the URL field lost its draft"); return }
clickStartURL(in: window)
} else if probe == 8 && phase == 9 {
guard nativeView(in: window.contentView!, matching: { ($0 as? NSTextField)?.isEditable == true }) == nil else { return }
guard (window.firstResponder as? NSTextView)?.isFieldEditor != true,
window.frame == urlWindowFrame, drags == previousDrags else {
finish("URL-field transitions retained focus, resized or dragged the start window"); return
}
probe += 1
phase = 0
return
}
phase += 1
}
}
@MainActor
private enum QueueDragCheck {
static var timer: Timer?
-2
View File
@@ -211,8 +211,6 @@ struct CompactWindowChecks {
"compact removes incompatible primary and auxiliary policies")
try CompactWindowChecks.require(window.contentMinSize == NSSize(width: 160, height: 90),
"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.titleVisibility == .hidden
&& window.titlebarAppearsTransparent,
+1 -1
View File
@@ -47,9 +47,9 @@ struct SystemPlaybackChecks {
window.isReleasedWhenClosed = false
defer { window.close() }
let surface = PlaybackSurface.DragView(model: model)
surface.frame = window.contentView!.bounds
window.contentView!.addSubview(surface)
model.attachWindow(window)
surface.frame = window.contentView!.bounds
@MainActor func event(_ type: NSEvent.EventType, clicks: Int = 1) -> NSEvent {
NSEvent.mouseEvent(with: type, location: NSPoint(x: 100, y: 100), modifierFlags: [],
timestamp: 0, windowNumber: window.windowNumber, context: nil,
+3 -1
View File
@@ -41,6 +41,8 @@ The start screen uses a fixed 640 × 400-point two-pane window: Terebi's identit
Start-screen actions use 14-point text and begin 64 points below the top edge. Branding uses a 16-point app name and 12-point version; the drop hint uses 13-point text. Open actions have no idle fill; their background appears on hover or press.
URL-field expansion and collapse use a 200 ms ease-in/out fade with a subtle top-anchored scale; recent rows move with the transition without resizing the window. Reduce Motion makes the change immediate. The Open URL button and ⇧⌘O toggle the same inline input, retaining its draft and releasing keyboard focus when hidden. During playback, the input appears over the player without interrupting playback; no separate URL dialog opens.
| Action | Default shortcut |
| --- | --- |
| Play / pause | Space or K |
@@ -61,7 +63,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.
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.
+2 -1
View File
@@ -334,7 +334,8 @@ final class CompactWindowController: NSObject {
window.collectionBehavior = behavior
window.level = .floating
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)
}
+3
View File
@@ -94,6 +94,9 @@ extension PlayerModel {
private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool {
guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil else { return false }
if chord.modifiers.contains(.command), keyBindings.shortcuts(for: .openYoutube).contains(chord) {
return perform(.openYoutube)
}
let responder = window.firstResponder
if let text = responder as? NSTextView, text.isEditable || text.isSelectable { return false }
if let text = responder as? NSTextField, text.isEditable || text.isSelectable { return false }
+44 -23
View File
@@ -29,6 +29,7 @@ final class PlayerModel: ObservableObject {
@Published private(set) var state = PlayerState()
@Published private(set) var fileName: String?
@Published var showsYouTubeInput = false
@Published private(set) var fileIcon: NSImage?
@Published private(set) var commandError: String?
@Published private(set) var queue = PlaybackQueue()
@@ -292,23 +293,6 @@ final class PlayerModel: ObservableObject {
if append { addToQueue(panel.urls) } else { openFiles(panel.urls) }
}
func openYouTubePanel() {
guard canOpen else { return }
defer { window?.makeFirstResponder(nil) }
revealControls()
let alert = NSAlert()
alert.messageText = "Open YouTube Video"
alert.informativeText = "Paste a YouTube video URL."
alert.addButton(withTitle: "Open")
alert.addButton(withTitle: "Cancel")
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 360, height: 24))
field.placeholderString = "https://www.youtube.com/watch?v=…"
field.setAccessibilityLabel("YouTube video URL")
alert.accessoryView = field
alert.window.initialFirstResponder = field
guard alert.runModal() == .alertFirstButtonReturn else { return }
openYouTube(field.stringValue)
}
func openYouTube(_ text: String) {
guard canOpen else { return }
@@ -320,6 +304,7 @@ final class PlayerModel: ObservableObject {
revealControls()
return
}
showsYouTubeInput = false
clearQueue()
resolveYouTube(url, height: nil)
}
@@ -963,7 +948,7 @@ final class PlayerModel: ObservableObject {
window?.level = isOnTop ? .floating : .normal
revealControls()
case .openFile: openPanel()
case .openYoutube: openYouTubePanel()
case .openYoutube: showsYouTubeInput.toggle()
case .closeFile: closeFile()
case .quit: NSApp.terminate(nil)
case .showBufferInfo: setShowsBufferInfo(true)
@@ -2094,13 +2079,14 @@ private struct PlayerContent: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ViewState private var showsRemainingTime = false
@ViewState private var dropTargeted = false
@ViewState private var showsYouTubeInput = false
private var showsYouTubeInput: Bool { model.showsYouTubeInput }
@ViewState private var youTubeURL = ""
@FocusState private var youTubeInputFocused: Bool
@ViewState private var controlSize = CGSize(width: 440, height: 68)
@ViewState private var queueFrames: [UUID: CGRect] = [:]
@ViewState private var queueDrag: PlaybackQueueDrag?
@GestureState private var queueDragging = false
@GestureState private var windowDragging = false
@ViewState private var queueScroll = ScrollPosition(y: 0)
@ViewState private var queueScrollOffset: CGFloat = 0
@ViewState private var queueViewportHeight: CGFloat = 0
@@ -2132,9 +2118,7 @@ private struct PlayerContent: View {
}
private func toggleYouTubeInput() {
if showsYouTubeInput { youTubeInputFocused = false }
showsYouTubeInput.toggle()
if showsYouTubeInput { youTubeInputFocused = true }
model.perform(.openYoutube)
}
private func startScreen(in size: CGSize) -> some View {
@@ -2188,7 +2172,10 @@ private struct PlayerContent: View {
}
.buttonStyle(StartScreenButtonStyle())
.accessibilityLabel("Open YouTube URL…")
if showsYouTubeInput { startURLInput.padding(.horizontal, 14) }
if showsYouTubeInput {
startURLInput.padding(.horizontal, 14)
.transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .top)))
}
if let latest, let resumePosition {
Button { model.open(latest.url) } label: {
HStack(spacing: 8) {
@@ -2223,11 +2210,14 @@ private struct PlayerContent: View {
.padding(.top, 20)
}
}
.animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: showsYouTubeInput)
.font(.system(size: 14, weight: .medium))
.disabled(!model.canOpen)
.padding(.horizontal, size.width < 600 ? 14 : 30)
.padding(.top, 64)
.padding(.bottom, 24)
.frame(maxWidth: .infinity, minHeight: size.height, alignment: .topLeading)
.background(windowDragBackground)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@@ -2337,6 +2327,7 @@ private struct PlayerContent: View {
.padding(.trailing, 92 - 88 * model.queueWidth / 260)
.frame(height: 32)
.foregroundStyle(Color(SunsetPalette.text))
.background(windowDragBackground)
.onHover(perform: model.setTitleHovered)
.animation(controlsVisibilityAnimation) { content in
content.opacity(model.controlsVisible ? 1 : 0)
@@ -2357,6 +2348,7 @@ private struct PlayerContent: View {
}
.padding(.horizontal, 4 * (1 - model.queueWidth / 260))
.frame(height: 24)
.background(windowDragBackground)
.background {
PlayerGlass(cornerRadius: 8)
.clipShape(RoundedRectangle(cornerRadius: 8))
@@ -2434,6 +2426,7 @@ private struct PlayerContent: View {
.padding(.horizontal, 12)
.padding(.vertical, showsBufferReadout ? 6 : 8)
.foregroundStyle(Color(SunsetPalette.text))
.background(windowDragBackground)
.background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10)))
.onHover(perform: model.setControlsHovered)
.animation(controlsVisibilityAnimation) { content in
@@ -2673,6 +2666,8 @@ private struct PlayerContent: View {
.highPriorityGesture(queueRowDrag(item))
}
}
.frame(maxWidth: .infinity, minHeight: queueViewportHeight, alignment: .top)
.background(windowDragBackground)
.coordinateSpace(name: "queueContent")
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { queueContentHeight = $0 }
}
@@ -2743,6 +2738,7 @@ private struct PlayerContent: View {
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(windowDragBackground)
.background { queueChromeBackground(top: false) }
}
.frame(width: 260)
@@ -2794,6 +2790,25 @@ private struct PlayerContent: View {
.foregroundStyle(Color(SunsetPalette.text))
.tint(Color(SunsetPalette.accent))
.background(WindowChrome(model: model))
.onChange(of: windowDragging) { _, active in model.setControlsDragging(active) }
.onChange(of: showsYouTubeInput) { _, shown in youTubeInputFocused = shown }
.overlay(alignment: .top) {
if model.fileName != nil && showsYouTubeInput {
startURLInput
.padding(12)
.background(PlayerGlass().allowsHitTesting(false))
.frame(maxWidth: 420)
.padding(.top, 44)
.transition(.opacity)
}
}
}
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 {
@@ -2837,6 +2852,7 @@ private struct PlayerContent: View {
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
.padding(.top, 44).padding(.trailing, 16)
.frame(maxWidth: .infinity, alignment: .trailing)
.allowsHitTesting(false)
}
if let error = model.errorText {
VStack(spacing: 12) {
@@ -2849,6 +2865,7 @@ private struct PlayerContent: View {
.frame(maxWidth: 420)
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(windowDragBackground)
} else if model.isBusy {
VStack(spacing: 12) {
ProgressView().controlSize(.large)
@@ -2857,6 +2874,7 @@ private struct PlayerContent: View {
.font(.callout).foregroundStyle(Color(SunsetPalette.secondaryText))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.allowsHitTesting(false)
} else if model.fileName == nil {
startScreen(in: geometry.size)
} else if let device = model.castDevice {
@@ -2867,6 +2885,7 @@ private struct PlayerContent: View {
}
.foregroundStyle(Color(SunsetPalette.text))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(windowDragBackground)
} else if !model.state.has_video && model.state.has_audio {
VStack(spacing: 16) {
Image(systemName: "waveform").font(.system(size: 64, weight: .ultraLight))
@@ -2875,6 +2894,7 @@ private struct PlayerContent: View {
.foregroundStyle(Color(SunsetPalette.secondaryText))
.padding(40)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.allowsHitTesting(false)
}
if model.state.buffering && !model.isBusy && model.errorText == nil {
ProgressView("Buffering…")
@@ -2928,6 +2948,7 @@ private struct PlayerContent: View {
if model.canTogglePlayback {
playbackControls
.frame(width: 440, height: controlHeight)
.background(windowDragBackground)
.background {
PlayerGlass()
.clipShape(RoundedRectangle(cornerRadius: 12))
+9 -3
View File
@@ -10,6 +10,12 @@
- 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
- 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.
- [ ] 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)
- [x] make the window draggable (in all cases/view states) so long as a button/clickable element is not hovered.
- 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:
- [x] make the youtube url input field that appears when clicking the youtube button appear with a nice transition
- 200 ms fade/scale with animated row reflow; the button and ⇧⌘O toggle the same inline input, including while editing; native focus, draft-preservation and rapid-reversal checks cover mouse/shortcut interoperation
- [ ] 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)
- [ ] check whether we're using appropriately new apple ui APIs / SDKs for the project.
- we should strive to use modern and idiomatic SDKs and APIs including SwiftUI among others