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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user