Skip to content

Commit 33b6ec1

Browse files
committed
✨feat: enhance mobile UX with long-press recording, icon navigation, and improved audio permissions
- Add long-press voice recording to yellow add button with visual feedback - Convert mobile navigation bar to icon-only display with hub/ai pages - Implement localStorage caching for microphone permissions to eliminate repeated prompts - Fix audio dialog height flickering issues on various devices with fixed container dimensions - Update Android theme colors to match CSS background variables (#0B0B0C for dark mode) - Improve dialog close button styling and behavior for better UX
1 parent dd23a58 commit 33b6ec1

File tree

13 files changed

+374
-202
lines changed

13 files changed

+374
-202
lines changed

app/src/components/BlinkoAddButton/index.tsx

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { useState } from 'react';
1+
import { useState, useRef } from 'react';
22
import { motion } from 'motion/react';
33
import { Icon } from '@/components/Common/Iconify/icons';
44
import { observer } from 'mobx-react-lite';
55
import { ShowEditBlinkoModel } from '../BlinkoRightClickMenu';
66
import { FocusEditorFixMobile } from "@/components/Common/Editor/editorUtils";
7+
import { eventBus } from '@/lib/event';
78

89
export const BlinkoAddButton = observer(() => {
910
const ICON_SIZE = {
@@ -15,14 +16,51 @@ export const BlinkoAddButton = observer(() => {
1516
CENTER: 50 // Size for center button
1617
};
1718
const [isDragging, setIsDragging] = useState(false);
18-
// Handle write action
19+
const [isLongPressing, setIsLongPressing] = useState(false);
20+
const longPressTimer = useRef<NodeJS.Timeout | null>(null);
21+
1922
const handleWriteAction = () => {
2023
ShowEditBlinkoModel('2xl', 'create')
2124
FocusEditorFixMobile()
2225
};
2326

24-
const handleClick = () => {
25-
handleWriteAction();
27+
const handleAudioRecording = () => {
28+
ShowEditBlinkoModel('2xl', 'create');
29+
setTimeout(() => {
30+
eventBus.emit('editor:startAudioRecording');
31+
}, 300);
32+
};
33+
34+
const handleMouseDown = () => {
35+
longPressTimer.current = setTimeout(() => {
36+
setIsLongPressing(true);
37+
handleAudioRecording();
38+
// Reset immediately after triggering recording
39+
setTimeout(() => setIsLongPressing(false), 100);
40+
}, 800);
41+
};
42+
43+
const handleMouseUp = () => {
44+
if (longPressTimer.current) {
45+
clearTimeout(longPressTimer.current);
46+
longPressTimer.current = null;
47+
}
48+
49+
if (!isLongPressing) {
50+
handleWriteAction();
51+
}
52+
53+
// Always reset immediately on release
54+
setIsLongPressing(false);
55+
};
56+
57+
const handleMouseLeave = () => {
58+
if (longPressTimer.current) {
59+
clearTimeout(longPressTimer.current);
60+
longPressTimer.current = null;
61+
}
62+
// Always reset immediately when leaving
63+
setIsLongPressing(false);
2664
};
2765

2866
return (<div style={{
@@ -34,24 +72,57 @@ export const BlinkoAddButton = observer(() => {
3472
zIndex: 50
3573
}}>
3674
<motion.div
37-
onClick={handleClick}
75+
onMouseDown={handleMouseDown}
76+
onMouseUp={handleMouseUp}
77+
onMouseLeave={handleMouseLeave}
78+
onTouchStart={handleMouseDown}
79+
onTouchEnd={handleMouseUp}
3880
animate={{
39-
scale: isDragging ? 1.1 : 1,
81+
scale: isDragging ? 1.1 : isLongPressing ? 1.1 : 1,
82+
backgroundColor: isLongPressing ? "#FF6B6B" : "#FFCC00",
83+
}}
84+
whileTap={{
85+
scale: 0.85,
86+
boxShadow: '0 0 15px 4px rgba(255, 204, 0, 0.8)'
87+
}}
88+
whileHover={{
89+
scale: 1.05,
90+
boxShadow: '0 0 20px 4px rgba(255, 204, 0, 0.7)'
4091
}}
4192
transition={{
4293
duration: 0.3,
4394
scale: {
4495
type: "spring",
45-
stiffness: 300,
46-
damping: 20
96+
stiffness: 400,
97+
damping: 15
98+
},
99+
backgroundColor: {
100+
duration: 0.2
47101
}
48102
}}
49-
className="absolute inset-0 flex items-center justify-center bg-[#FFCC00] text-black rounded-full cursor-pointer"
103+
className="absolute inset-0 flex items-center justify-center text-black rounded-full cursor-pointer"
50104
style={{
51-
boxShadow: '0 0 10px 2px rgba(255, 204, 0, 0.5)'
105+
boxShadow: isLongPressing
106+
? '0 0 20px 6px rgba(255, 107, 107, 0.6)'
107+
: '0 0 10px 2px rgba(255, 204, 0, 0.5)'
52108
}}
53109
>
54-
<Icon icon="material-symbols:add" width={ICON_SIZE.CENTER} height={ICON_SIZE.CENTER} />
110+
<motion.div
111+
animate={{
112+
scale: isLongPressing ? 1.2 : 1
113+
}}
114+
transition={{
115+
type: "spring",
116+
stiffness: 300,
117+
damping: 20
118+
}}
119+
>
120+
<Icon
121+
icon={isLongPressing ? "hugeicons:voice-id" : "material-symbols:add"}
122+
width={ICON_SIZE.CENTER}
123+
height={ICON_SIZE.CENTER}
124+
/>
125+
</motion.div>
55126
</motion.div>
56127
</div>
57128
);

app/src/components/Common/AudioDialog/index.tsx

Lines changed: 38 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ export const MyAudioRecorder = ({ onComplete }: MyAudioRecorderProps) => {
1818
const [timerId, setTimerId] = useState<NodeJS.Timeout | null>(null);
1919
const [milliseconds, setMilliseconds] = useState<number>(0);
2020
const millisecondTimerRef = useRef<NodeJS.Timeout | null>(null);
21-
const [audioPermissionGranted, setAudioPermissionGranted] = useState<boolean>(false);
21+
const [audioPermissionGranted, setAudioPermissionGranted] = useState<boolean>(() => {
22+
// Initialize with cached permission status
23+
return localStorage.getItem('microphone_permission_granted') === 'true';
24+
});
2225
const [audioLevel, setAudioLevel] = useState<number[]>(Array(30).fill(0));
2326
const animationFrameRef = useRef<number | null>(null);
2427
const audioContextRef = useRef<AudioContext | null>(null);
@@ -132,23 +135,28 @@ export const MyAudioRecorder = ({ onComplete }: MyAudioRecorderProps) => {
132135
useEffect(() => {
133136
const initRecording = async () => {
134137
try {
135-
// First check/request permission
136-
const hasPermission = await checkMicrophonePermission();
137-
if (!hasPermission) {
138-
const granted = await requestMicrophonePermission();
139-
if (!granted) {
140-
setAudioPermissionGranted(false);
141-
console.error('Microphone permission denied');
142-
return;
138+
// If we already have cached permission, skip permission check
139+
const cachedPermission = localStorage.getItem('microphone_permission_granted') === 'true';
140+
141+
if (!cachedPermission) {
142+
// First check/request permission
143+
const hasPermission = await checkMicrophonePermission();
144+
if (!hasPermission) {
145+
const granted = await requestMicrophonePermission();
146+
if (!granted) {
147+
setAudioPermissionGranted(false);
148+
console.error('Microphone permission denied');
149+
return;
150+
}
143151
}
152+
// Permission granted, update state
153+
setAudioPermissionGranted(true);
144154
}
145155

146156
const stream = await startRecording();
147157
if (stream) {
148158
setupAudioAnalyser(stream);
149-
setAudioPermissionGranted(true);
150159
} else {
151-
setAudioPermissionGranted(false);
152160
console.error('Failed to start recording');
153161
return;
154162
}
@@ -167,6 +175,9 @@ export const MyAudioRecorder = ({ onComplete }: MyAudioRecorderProps) => {
167175
millisecondTimerRef.current = msTimer;
168176
} catch (error) {
169177
console.error("Failed to start recording:", error);
178+
// Clear cached permission on error
179+
localStorage.removeItem('microphone_permission_granted');
180+
setAudioPermissionGranted(false);
170181
}
171182
};
172183

@@ -268,36 +279,16 @@ export const MyAudioRecorder = ({ onComplete }: MyAudioRecorderProps) => {
268279
}, [recordingBlob, onComplete, recordingTime]);
269280

270281
const handleDelete = useCallback(() => {
271-
setLastRecordingBlob(null);
272-
setRecordingTime(0);
273-
setMilliseconds(0);
274-
275-
const initNewRecording = async () => {
276-
try {
277-
const stream = await startRecording();
278-
if (stream) {
279-
setupAudioAnalyser(stream);
280-
}
281-
setIsRecording(true);
282-
283-
// Restart timer
284-
const timer = setInterval(() => {
285-
setRecordingTime(prev => prev + 1);
286-
}, 1000);
287-
setTimerId(timer);
282+
// Stop current recording
283+
stopRecording();
288284

289-
// Restart milliseconds timer
290-
const msTimer = setInterval(() => {
291-
setMilliseconds(prev => (prev + 1) % 100);
292-
}, 10);
293-
millisecondTimerRef.current = msTimer;
294-
} catch (error) {
295-
console.error("Failed to restart recording:", error);
296-
}
297-
};
285+
// Clean up timers
286+
if (timerId) clearInterval(timerId);
287+
if (millisecondTimerRef.current) clearInterval(millisecondTimerRef.current);
298288

299-
initNewRecording();
300-
}, [startRecording, setupAudioAnalyser]);
289+
// Close the dialog
290+
RootStore.Get(DialogStandaloneStore).close();
291+
}, [stopRecording, timerId]);
301292

302293
// Format time display as MM:SS.XX
303294
const formattedTime = useMemo(() => {
@@ -310,7 +301,7 @@ export const MyAudioRecorder = ({ onComplete }: MyAudioRecorderProps) => {
310301
const { t } = useTranslation();
311302

312303
return (
313-
<div className="relative flex flex-col items-center overflow-hidden">
304+
<div className="relative flex flex-col items-center overflow-hidden w-full h-[450px]">
314305
{!audioPermissionGranted ? (
315306
// Permission Request UI - Clean and Professional
316307
<div className="flex flex-col items-center justify-center w-full h-full p-8 rounded-lg">
@@ -345,15 +336,13 @@ export const MyAudioRecorder = ({ onComplete }: MyAudioRecorderProps) => {
345336
</div>
346337
) : (
347338
// Recording UI - Original design when permission is granted
348-
<div className="flex flex-col items-center justify-center w-full h-full p-4 bg-neutral-900 rounded-lg">
349-
<div className="w-full">
350-
<div className="flex items-center">
351-
<span className="text-white font-bold">REC</span>
352-
<span className="ml-2 w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>
353-
</div>
339+
<div className="flex flex-col items-center w-full h-full p-4 bg-neutral-900 rounded-lg">
340+
<div className="w-full h-8 flex items-center">
341+
<span className="text-white font-bold">REC</span>
342+
<span className="ml-2 w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>
354343
</div>
355344

356-
<div className="w-full flex-1 flex flex-col items-center justify-center my-4">
345+
<div className="w-full flex-1 flex flex-col items-center justify-center py-4 min-h-[200px]">
357346
<div className="my-4 w-full">
358347
<div className="w-full h-[40px] flex items-center justify-center rounded">
359348
<div className="w-full h-full flex items-end justify-center space-x-1 px-2">
@@ -378,7 +367,7 @@ export const MyAudioRecorder = ({ onComplete }: MyAudioRecorderProps) => {
378367
</div>
379368
</div>
380369

381-
<div className="flex justify-center mt-4 w-full">
370+
<div className="flex justify-center mt-4 w-full h-16">
382371
{isRecording ? (
383372
<button
384373
className="w-16 h-16 rounded-full bg-green-500 flex items-center justify-center focus:outline-none active:transform active:scale-95 transition-transform"
@@ -387,7 +376,7 @@ export const MyAudioRecorder = ({ onComplete }: MyAudioRecorderProps) => {
387376
<div className="w-6 h-6 bg-white rounded"></div>
388377
</button>
389378
) : (
390-
<div className="flex gap-5">
379+
<div className="flex gap-5 items-center justify-center h-full">
391380
<button
392381
className="w-16 h-16 rounded-full bg-neutral-800 flex items-center justify-center focus:outline-none active:transform active:scale-95 transition-transform"
393382
onClick={handleDelete}

app/src/components/Common/AudioRecorder/hook.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,10 @@ const useAudioRecorder: (
9999

100100
// Save stream reference for later cleanup
101101
mediaStreamRef.current = stream;
102-
102+
103+
// Cache microphone permission
104+
localStorage.setItem('microphone_permission_granted', 'true');
105+
103106
console.log("Microphone access granted, tracks:", stream.getAudioTracks().length);
104107
const audioTrack = stream.getAudioTracks()[0];
105108
if (audioTrack) {
@@ -173,6 +176,10 @@ const useAudioRecorder: (
173176
return stream;
174177
} catch (err: any) {
175178
console.error("Failed to get microphone access:", err.name, err.message);
179+
180+
// Clear cached permission on any error
181+
localStorage.removeItem('microphone_permission_granted');
182+
176183
// Provide more detailed error information
177184
if (err.name === 'NotAllowedError') {
178185
console.error("User denied microphone permission");
@@ -181,7 +188,7 @@ const useAudioRecorder: (
181188
} else if (err.name === 'NotReadableError') {
182189
console.error("Microphone may be in use by another application");
183190
}
184-
191+
185192
onNotAllowedOrFound?.(err);
186193
throw err; // Rethrow error for UI handling
187194
}

0 commit comments

Comments
 (0)