Improve mini recorder animations and timing
This commit is contained in:
parent
55a8036b36
commit
6d55e77398
140
MiniRecorder_Morphing_Requirements.md
Normal file
140
MiniRecorder_Morphing_Requirements.md
Normal file
@ -0,0 +1,140 @@
|
||||
# MiniRecorder Morphing Window Requirements
|
||||
|
||||
## 🎯 Core Requirements
|
||||
|
||||
### **Visual Behavior**
|
||||
1. **Visualizer Always Centered**: The audio visualizer must remain in the exact same position throughout all animations
|
||||
2. **Fixed Window Position**: The MiniRecorderView window position should never change during morphing
|
||||
3. **Horizontal-Only Expansion**: Only width should change, height remains constant at 34px
|
||||
4. **Hover-Triggered**: Expansion should occur on hover, collapse on hover exit
|
||||
|
||||
### **Layout States**
|
||||
|
||||
#### **Compact State (Default)**
|
||||
- **Width**: ~70px (just enough for visualizer + minimal padding)
|
||||
- **Content**: Audio visualizer/status display only
|
||||
- **Buttons**: Hidden/not rendered
|
||||
- **Centering**: Visualizer perfectly centered in compact window
|
||||
|
||||
#### **Expanded State (On Hover)**
|
||||
- **Width**: ~160px (current full width)
|
||||
- **Content**: RecorderPromptButton + Visualizer + RecorderPowerModeButton
|
||||
- **Buttons**: Fully visible and functional
|
||||
- **Centering**: Visualizer remains in same absolute screen position
|
||||
|
||||
## 🔧 Technical Constraints
|
||||
|
||||
### **Window Positioning**
|
||||
- Window's center point must remain constant
|
||||
- When expanding from 70px → 160px, window should grow equally left and right (45px each side)
|
||||
- `NSRect` calculations must account for center-anchored growth
|
||||
|
||||
### **Animation Requirements**
|
||||
- Smooth spring animation (~0.3-0.4s duration)
|
||||
- Buttons should appear/disappear gracefully (fade in/out or slide from edges)
|
||||
- No jarring movements or position jumps
|
||||
- Reversible animation (expand ↔ collapse)
|
||||
|
||||
### **SwiftUI Layout Considerations**
|
||||
- HStack with conditional button rendering
|
||||
- Visualizer maintains `frame(maxWidth: .infinity)` behavior in both states
|
||||
- Proper spacing and padding calculations for both states
|
||||
|
||||
## 💡 Recommended Implementation Strategy
|
||||
|
||||
### **Approach: Center-Anchored Window Growth with Sliding Buttons**
|
||||
|
||||
#### **Window Management (MiniRecorderPanel)**
|
||||
```
|
||||
Compact Window Rect:
|
||||
- Width: 70px
|
||||
- Height: 34px
|
||||
- X: screenCenter - 35px
|
||||
- Y: current Y position
|
||||
|
||||
Expanded Window Rect:
|
||||
- Width: 160px
|
||||
- Height: 34px
|
||||
- X: screenCenter - 80px // Grows left by 45px
|
||||
- Y: same Y position // Grows right by 45px
|
||||
```
|
||||
|
||||
#### **SwiftUI Layout (MiniRecorderView)**
|
||||
```
|
||||
HStack(spacing: 0) {
|
||||
// Left button - slides in from left edge
|
||||
if isExpanded {
|
||||
RecorderPromptButton()
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
}
|
||||
|
||||
// Visualizer - always centered, never moves
|
||||
statusView
|
||||
.frame(width: visualizerWidth) // Fixed width
|
||||
|
||||
// Right button - slides in from right edge
|
||||
if isExpanded {
|
||||
RecorderPowerModeButton()
|
||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### **State Management**
|
||||
- `@State private var isExpanded = false`
|
||||
- `@State private var isHovering = false`
|
||||
- Hover detection with debouncing for smooth UX
|
||||
- Window resize triggered by state changes
|
||||
|
||||
## 🎨 Animation Sequence
|
||||
|
||||
### **Expansion (Compact → Expanded)**
|
||||
1. **Trigger**: Mouse enters window bounds
|
||||
2. **Window**: Animate width 70px → 160px (center-anchored)
|
||||
3. **Buttons**: Slide in from edges with fade-in
|
||||
4. **Duration**: ~0.3s with spring easing
|
||||
5. **Result**: Visualizer appears unmoved, buttons visible
|
||||
|
||||
### **Collapse (Expanded → Compact)**
|
||||
1. **Trigger**: Mouse leaves window bounds (with delay)
|
||||
2. **Buttons**: Slide out to edges with fade-out
|
||||
3. **Window**: Animate width 160px → 70px (center-anchored)
|
||||
4. **Duration**: ~0.3s with spring easing
|
||||
5. **Result**: Back to visualizer-only, same position
|
||||
|
||||
## 🚫 Critical Don'ts
|
||||
|
||||
- **Never move the visualizer's absolute screen position**
|
||||
- **Never change the window's center point**
|
||||
- **Never animate height or vertical position**
|
||||
- **Never show jarring button pop-ins (use smooth transitions)**
|
||||
- **Never let buttons overlap the visualizer during animation**
|
||||
|
||||
## 📐 Calculations
|
||||
|
||||
### **Visualizer Dimensions**
|
||||
- AudioVisualizer: 12 bars × 3px + 11 × 2px spacing = 58px width
|
||||
- With padding: ~70px total compact width
|
||||
|
||||
### **Button Dimensions**
|
||||
- Each button: ~24px width + padding
|
||||
- Total button space: ~90px (45px per side)
|
||||
- Total expanded width: 70px + 90px = 160px
|
||||
|
||||
### **Center-Anchored Growth**
|
||||
```
|
||||
Compact X position: screenCenterX - 35px
|
||||
Expanded X position: screenCenterX - 80px
|
||||
Growth: 45px left + 45px right = 90px total
|
||||
```
|
||||
|
||||
## 🎯 Success Criteria
|
||||
|
||||
✅ **Visualizer never visually moves during any animation**
|
||||
✅ **Window position anchor point remains constant**
|
||||
✅ **Smooth hover-based expansion/collapse**
|
||||
✅ **Buttons appear/disappear gracefully**
|
||||
✅ **No layout jumps or glitches**
|
||||
✅ **Maintains current functionality in expanded state**
|
||||
|
||||
This approach ensures the visualizer appears completely stationary while the window "grows around it" to reveal the buttons, creating a seamless morphing effect.
|
||||
@ -30,17 +30,20 @@ class MiniRecorderPanel: NSPanel {
|
||||
standardWindowButton(.closeButton)?.isHidden = true
|
||||
}
|
||||
|
||||
static func calculateWindowMetrics() -> NSRect {
|
||||
static func calculateWindowMetrics(expanded: Bool = false) -> NSRect {
|
||||
guard let screen = NSScreen.main else {
|
||||
return NSRect(x: 0, y: 0, width: 160, height: 34)
|
||||
return NSRect(x: 0, y: 0, width: expanded ? 160 : 70, height: 34)
|
||||
}
|
||||
|
||||
let width: CGFloat = 160
|
||||
let compactWidth: CGFloat = 100
|
||||
let expandedWidth: CGFloat = 160
|
||||
let width = expanded ? expandedWidth : compactWidth
|
||||
let height: CGFloat = 34
|
||||
let padding: CGFloat = 24
|
||||
|
||||
let visibleFrame = screen.visibleFrame
|
||||
let xPosition = visibleFrame.midX - (width / 2) - 5
|
||||
let centerX = visibleFrame.midX - 5
|
||||
let xPosition = centerX - (width / 2)
|
||||
let yPosition = visibleFrame.minY + padding
|
||||
|
||||
return NSRect(
|
||||
@ -52,11 +55,29 @@ class MiniRecorderPanel: NSPanel {
|
||||
}
|
||||
|
||||
func show() {
|
||||
let metrics = MiniRecorderPanel.calculateWindowMetrics()
|
||||
let metrics = MiniRecorderPanel.calculateWindowMetrics(expanded: false)
|
||||
setFrame(metrics, display: true)
|
||||
orderFrontRegardless()
|
||||
}
|
||||
|
||||
func expandWindow(completion: (() -> Void)? = nil) {
|
||||
let expandedMetrics = MiniRecorderPanel.calculateWindowMetrics(expanded: true)
|
||||
NSAnimationContext.runAnimationGroup({ context in
|
||||
context.duration = 0.25
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
animator().setFrame(expandedMetrics, display: true)
|
||||
}, completionHandler: completion)
|
||||
}
|
||||
|
||||
func collapseWindow(completion: (() -> Void)? = nil) {
|
||||
let compactMetrics = MiniRecorderPanel.calculateWindowMetrics(expanded: false)
|
||||
NSAnimationContext.runAnimationGroup({ context in
|
||||
context.duration = 0.25
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
animator().setFrame(compactMetrics, display: true)
|
||||
}, completionHandler: completion)
|
||||
}
|
||||
|
||||
func hide(completion: @escaping () -> Void) {
|
||||
completion()
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ struct MiniRecorderView: View {
|
||||
|
||||
@State private var showPowerModePopover = false
|
||||
@State private var showEnhancementPromptPopover = false
|
||||
@State private var isHovering = false
|
||||
|
||||
private var backgroundView: some View {
|
||||
ZStack {
|
||||
@ -45,16 +46,47 @@ struct MiniRecorderView: View {
|
||||
}
|
||||
.overlay {
|
||||
HStack(spacing: 0) {
|
||||
RecorderPromptButton(showPopover: $showEnhancementPromptPopover)
|
||||
// Left button zone
|
||||
Group {
|
||||
if windowManager.isExpanded {
|
||||
RecorderPromptButton(showPopover: $showEnhancementPromptPopover)
|
||||
.transition(.scale(scale: 0.5).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.frame(width: windowManager.isExpanded ? nil : 0)
|
||||
.clipped()
|
||||
.animation(.easeInOut(duration: 0.25), value: windowManager.isExpanded)
|
||||
|
||||
// Fixed visualizer zone
|
||||
statusView
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 6)
|
||||
|
||||
RecorderPowerModeButton(showPopover: $showPowerModePopover)
|
||||
// Right button zone
|
||||
Group {
|
||||
if windowManager.isExpanded {
|
||||
RecorderPowerModeButton(showPopover: $showPowerModePopover)
|
||||
.transition(.scale(scale: 0.5).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.frame(width: windowManager.isExpanded ? nil : 0)
|
||||
.clipped()
|
||||
.animation(.easeInOut(duration: 0.25), value: windowManager.isExpanded)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.onHover { hovering in
|
||||
isHovering = hovering
|
||||
if hovering {
|
||||
windowManager.expand()
|
||||
} else {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
if !isHovering {
|
||||
windowManager.collapse()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import AppKit
|
||||
|
||||
class MiniWindowManager: ObservableObject {
|
||||
@Published var isVisible = false
|
||||
@Published var isExpanded = false
|
||||
private var windowController: NSWindowController?
|
||||
private var miniPanel: MiniRecorderPanel?
|
||||
private let whisperState: WhisperState
|
||||
@ -40,6 +41,26 @@ class MiniWindowManager: ObservableObject {
|
||||
miniPanel?.show()
|
||||
}
|
||||
|
||||
func expand() {
|
||||
guard isVisible, !isExpanded else { return }
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
isExpanded = true
|
||||
}
|
||||
|
||||
miniPanel?.expandWindow()
|
||||
}
|
||||
|
||||
func collapse() {
|
||||
guard isVisible, isExpanded else { return }
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
isExpanded = false
|
||||
}
|
||||
|
||||
miniPanel?.collapseWindow()
|
||||
}
|
||||
|
||||
func hide() {
|
||||
guard isVisible else { return }
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user