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 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",
@@ -76,6 +78,17 @@ private enum WindowDragCheck {
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? {
let content = window.contentView!
let w = content.bounds.width
@@ -150,11 +163,13 @@ private enum WindowDragCheck {
model.setVolume(0)
model.setPlayback(playing: false)
window.makeKeyAndOrderFront(nil)
window.makeMain()
NSApp.activate(ignoringOtherApps: true)
}
if phase == 0 {
guard NSApp.isActive, window.isKeyWindow else {
guard NSApp.isActive, window.isKeyWindow, window.isMainWindow else {
window.makeKeyAndOrderFront(nil)
window.makeMain()
NSApp.activate(ignoringOtherApps: true)
return
}
@@ -162,13 +177,8 @@ private enum WindowDragCheck {
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)
}
urlWindowFrame = window.frame
clickStartURL(in: window)
case 9:
model.open(fixtures.appendingPathComponent("cache.mp4"))
model.showsQueue = true
@@ -205,10 +215,37 @@ private enum WindowDragCheck {
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
}
+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.
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 |
| --- | --- |
| Play / pause | Space or K |
+5 -1
View File
@@ -2189,7 +2189,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) {
@@ -2224,6 +2227,7 @@ private struct PlayerContent: View {
.padding(.top, 20)
}
}
.animation(reduceMotion ? nil : .easeInOut(duration: 0.25), value: showsYouTubeInput)
.font(.system(size: 14, weight: .medium))
.disabled(!model.canOpen)
.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.
- 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
- [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
- 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)