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