Improve mini recorder animations and timing

This commit is contained in:
Beingpax 2025-08-06 23:15:06 +05:45
parent 55a8036b36
commit 6d55e77398
4 changed files with 221 additions and 7 deletions

View 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.

View File

@ -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()
}

View File

@ -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()
}
}
}
}
}
}
}

View File

@ -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 }