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.
This commit is contained in:
2026-10-19 18:46:00 +02:00
parent 6d653e8875
commit f6485bf221
4 changed files with 54 additions and 10 deletions
+45 -8
View File
@@ -37,6 +37,8 @@ private enum WindowDragCheck {
static var implementations: [(Method, IMP)] = [] static var implementations: [(Method, IMP)] = []
static var target: NSWindow? static var target: NSWindow?
static var dragDepth = 0 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", static let names = ["video", "title", "playback padding", "play button", "volume slider",
"start whitespace", "start button", "start artwork", "URL text field", "start whitespace", "start button", "start artwork", "URL text field",
"queue whitespace", "queue footer", "queue row", "queue button", "queue whitespace", "queue footer", "queue row", "queue button",
@@ -76,6 +78,17 @@ private enum WindowDragCheck {
return nil return nil
} }
static func clickStartURL(in window: NSWindow) {
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? { static func point(in window: NSWindow) -> NSPoint? {
let content = window.contentView! let content = window.contentView!
let w = content.bounds.width let w = content.bounds.width
@@ -150,11 +163,13 @@ private enum WindowDragCheck {
model.setVolume(0) model.setVolume(0)
model.setPlayback(playing: false) model.setPlayback(playing: false)
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
window.makeMain()
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
} }
if phase == 0 { if phase == 0 {
guard NSApp.isActive, window.isKeyWindow else { guard NSApp.isActive, window.isKeyWindow, window.isMainWindow else {
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
window.makeMain()
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
return return
} }
@@ -162,13 +177,8 @@ private enum WindowDragCheck {
switch probe { switch probe {
case 5: model.closeFile() case 5: model.closeFile()
case 8: case 8:
for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { urlWindowFrame = window.frame
let event = NSEvent.mouseEvent(with: type, location: NSPoint(x: 400, y: 272), clickStartURL(in: window)
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: case 9:
model.open(fixtures.appendingPathComponent("cache.mp4")) model.open(fixtures.appendingPathComponent("cache.mp4"))
model.showsQueue = true model.showsQueue = true
@@ -205,10 +215,37 @@ private enum WindowDragCheck {
guard drags - previousDrags == expected else { guard drags - previousDrags == expected else {
finish("\(names[probe]): expected \(expected) native drag requests, got \(drags - previousDrags)"); return 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 probe += 1
if probe == names.count { finish(); return } if probe == names.count { finish(); return }
phase = 0 phase = 0
return 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 phase += 1
} }
+2
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. 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 250 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. Hiding or rapidly toggling the field preserves its draft, and hiding releases keyboard focus.
| Action | Default shortcut | | Action | Default shortcut |
| --- | --- | | --- | --- |
| Play / pause | Space or K | | Play / pause | Space or K |
+5 -1
View File
@@ -2189,7 +2189,10 @@ private struct PlayerContent: View {
} }
.buttonStyle(StartScreenButtonStyle()) .buttonStyle(StartScreenButtonStyle())
.accessibilityLabel("Open YouTube URL…") .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 { if let latest, let resumePosition {
Button { model.open(latest.url) } label: { Button { model.open(latest.url) } label: {
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -2224,6 +2227,7 @@ private struct PlayerContent: View {
.padding(.top, 20) .padding(.top, 20)
} }
} }
.animation(reduceMotion ? nil : .easeInOut(duration: 0.25), value: showsYouTubeInput)
.font(.system(size: 14, weight: .medium)) .font(.system(size: 14, weight: .medium))
.disabled(!model.canOpen) .disabled(!model.canOpen)
.padding(.horizontal, size.width < 600 ? 14 : 30) .padding(.horizontal, size.width < 600 ? 14 : 30)
+2 -1
View File
@@ -13,6 +13,7 @@
- [x] 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.
- 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 - 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: - [ ] on the start screen:
- [ ] make the youtube url input field that appears when clicking the youtube button appear with a nice transition - [x] make the youtube url input field that appears when clicking the youtube button appear with a nice transition
- 250 ms fade/scale with animated row reflow; native focus, draft-preservation and rapid-reversal checks passed, including an isolated Reduce Motion branch smoke check; animation feel still needs manual verification
- [ ] change the youtube button to general streaming - [ ] 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)