Skip to content

Commit b370209

Browse files
committed
feat: implement voice control and enhance music playback with shuffle and repeat modes, including UI updates and refined song progression logic.
1 parent b76f205 commit b370209

11 files changed

Lines changed: 1438 additions & 185 deletions

File tree

client/src/Context/PlayerContext.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export function PlayerProvider({ children }) {
5555
}
5656

5757
const handleEnded = () => {
58-
handleNextSong()
58+
handleNextSong(true)
5959
}
6060

6161
audio.addEventListener("timeupdate", handleTimeUpdate)
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { AnimatePresence, motion } from "framer-motion"
2+
import { Loader2, Mic, X } from "lucide-react"
3+
import { memo, useCallback, useEffect, useState } from "react"
4+
import { createPortal } from "react-dom"
5+
import { toast } from "sonner"
6+
import { useBackendSearchQuery } from "@/hooks/queries/useSongQueries"
7+
import { useVoiceCommandExecutor } from "@/hooks/useVoiceCommandExecutor"
8+
import { useVoiceControl } from "@/hooks/useVoiceControl"
9+
import { cn } from "@/lib/utils"
10+
import { usePlayerStore } from "@/stores/playerStore"
11+
12+
const AudioWave = memo(() => (
13+
<div className="flex items-center gap-0.5 h-4">
14+
{[0, 0.1, 0.2, 0.3, 0.2].map((delay, i) => (
15+
<motion.div
16+
key={i}
17+
className="w-0.5 bg-white rounded-full"
18+
animate={{ height: ["8px", "16px", "8px"] }}
19+
transition={{ duration: 0.6, repeat: Infinity, delay }}
20+
/>
21+
))}
22+
</div>
23+
))
24+
25+
const FloatingVoiceControl = memo(() => {
26+
const currentSong = usePlayerStore((s) => s.currentSong)
27+
const setCurrentSong = usePlayerStore((s) => s.setCurrentSong)
28+
const setPlaylist = usePlayerStore((s) => s.setPlaylist)
29+
const { executeCommand } = useVoiceCommandExecutor()
30+
31+
const [searchQuery, setSearchQuery] = useState("")
32+
const [isSearching, setIsSearching] = useState(false)
33+
34+
const { data: searchResults, isLoading: searchLoading } = useBackendSearchQuery(searchQuery, {
35+
enabled: !!searchQuery && isSearching,
36+
})
37+
38+
useEffect(() => {
39+
if (searchResults?.songs?.length > 0 && isSearching) {
40+
const songs = searchResults.songs.slice(0, 5)
41+
setPlaylist(songs)
42+
setCurrentSong(songs[0])
43+
toast.success(`Playing "${songs[0].name || songs[0].title}"`, { duration: 2000 })
44+
setSearchQuery("")
45+
setIsSearching(false)
46+
} else if (searchResults && searchResults.songs?.length === 0 && isSearching) {
47+
toast.error(`No songs found for "${searchQuery}"`, { duration: 2000 })
48+
setSearchQuery("")
49+
setIsSearching(false)
50+
}
51+
}, [searchResults, isSearching, setPlaylist, setCurrentSong, searchQuery])
52+
53+
const handleCommand = useCallback(
54+
(intent) => {
55+
if (!intent) return
56+
57+
if (intent.action === "search" && intent.query) {
58+
setSearchQuery(intent.query)
59+
setIsSearching(true)
60+
return
61+
}
62+
63+
const result = executeCommand(intent)
64+
if (result.success) {
65+
toast.success(result.message, { duration: 2000 })
66+
} else {
67+
toast.error(result.message || "Command not recognized", { duration: 2000 })
68+
}
69+
},
70+
[executeCommand],
71+
)
72+
73+
const { isListening, isSupported, transcript, error, startListening, stopListening } =
74+
useVoiceControl({ onCommand: handleCommand })
75+
76+
const handleClick = useCallback(() => {
77+
if (isListening) {
78+
stopListening()
79+
} else {
80+
startListening()
81+
}
82+
}, [isListening, startListening, stopListening])
83+
84+
if (!isSupported || !currentSong) return null
85+
86+
const showSearching = isSearching && searchLoading
87+
88+
const content = (
89+
<div className="fixed bottom-20 right-4 z-[9999] flex flex-col items-end gap-2">
90+
<AnimatePresence>
91+
{(isListening || showSearching) && (
92+
<motion.div
93+
initial={{ opacity: 0, y: 10, scale: 0.95 }}
94+
animate={{ opacity: 1, y: 0, scale: 1 }}
95+
exit={{ opacity: 0, y: 5, scale: 0.95 }}
96+
transition={{ duration: 0.2 }}
97+
className="bg-background/95 backdrop-blur-md border border-border rounded-xl px-4 py-3 shadow-xl min-w-[180px]"
98+
>
99+
<div className="flex items-center gap-3 mb-2">
100+
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
101+
{showSearching ? (
102+
<Loader2 className="w-4 h-4 animate-spin text-primary" />
103+
) : (
104+
<AudioWave />
105+
)}
106+
</div>
107+
<div>
108+
<p className="text-sm font-medium text-foreground">
109+
{showSearching ? "Searching..." : "Listening..."}
110+
</p>
111+
<p className="text-xs text-muted-foreground">
112+
{showSearching ? searchQuery : "Say a command"}
113+
</p>
114+
</div>
115+
</div>
116+
{transcript && !showSearching && (
117+
<motion.div
118+
initial={{ opacity: 0 }}
119+
animate={{ opacity: 1 }}
120+
className="bg-muted/50 rounded-lg px-3 py-2"
121+
>
122+
<p className="text-sm text-foreground">"{transcript}"</p>
123+
</motion.div>
124+
)}
125+
{error && <p className="text-xs text-destructive mt-2">{error}</p>}
126+
</motion.div>
127+
)}
128+
</AnimatePresence>
129+
130+
<motion.button
131+
onClick={handleClick}
132+
disabled={showSearching}
133+
whileHover={{ scale: 1.05 }}
134+
whileTap={{ scale: 0.95 }}
135+
className={cn(
136+
"relative w-12 h-12 rounded-full flex items-center justify-center shadow-lg transition-colors",
137+
isListening || showSearching
138+
? "bg-primary text-primary-foreground"
139+
: "bg-background/95 backdrop-blur-md border border-border text-foreground hover:bg-accent",
140+
)}
141+
>
142+
{(isListening || showSearching) && (
143+
<>
144+
<motion.div
145+
className="absolute inset-0 rounded-full border-2 border-primary"
146+
initial={{ scale: 1, opacity: 0.8 }}
147+
animate={{ scale: 1.6, opacity: 0 }}
148+
transition={{ duration: 1.5, repeat: Infinity, ease: "easeOut" }}
149+
/>
150+
<motion.div
151+
className="absolute inset-0 rounded-full border-2 border-primary"
152+
initial={{ scale: 1, opacity: 0.6 }}
153+
animate={{ scale: 1.4, opacity: 0 }}
154+
transition={{ duration: 1.5, repeat: Infinity, ease: "easeOut", delay: 0.3 }}
155+
/>
156+
</>
157+
)}
158+
<AnimatePresence mode="wait" initial={false}>
159+
<motion.span
160+
key={isListening ? "listening" : showSearching ? "searching" : "idle"}
161+
initial={{ scale: 0.5, opacity: 0 }}
162+
animate={{ scale: 1, opacity: 1 }}
163+
exit={{ scale: 0.5, opacity: 0 }}
164+
transition={{ duration: 0.15 }}
165+
>
166+
{showSearching ? (
167+
<Loader2 className="h-5 w-5 animate-spin" />
168+
) : isListening ? (
169+
<X className="h-5 w-5" />
170+
) : (
171+
<Mic className="h-5 w-5" />
172+
)}
173+
</motion.span>
174+
</AnimatePresence>
175+
</motion.button>
176+
</div>
177+
)
178+
179+
return createPortal(content, document.body)
180+
})
181+
182+
AudioWave.displayName = "AudioWave"
183+
FloatingVoiceControl.displayName = "FloatingVoiceControl"
184+
export default FloatingVoiceControl

client/src/Pages/Music/BottomPlayer/PlayerControls.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { usePlayerStore } from "@/stores/playerStore"
77
import { MusicControls, VolumeControl } from "../Common"
88
import SleepTimerModal from "../SleepTimer"
99

10-
const PlayerControls = memo(({ onMinimize, onOpenModal }) => {
10+
const PlayerControls = memo(({ onMinimize, onOpenModal, isMobile }) => {
1111
const handlePlayPause = usePlayerStore((s) => s.handlePlayPause)
1212
const handleNextSong = usePlayerStore((s) => s.handleNextSong)
1313
const handlePrevSong = usePlayerStore((s) => s.handlePrevSong)
@@ -32,7 +32,7 @@ const PlayerControls = memo(({ onMinimize, onOpenModal }) => {
3232

3333
return (
3434
<div className="flex items-center gap-1 sm:gap-2">
35-
<MusicControls />
35+
<MusicControls showExtras={!isMobile} />
3636

3737
<div className="hidden sm:flex items-center gap-1">
3838
<VolumeControl showVolume={true} />

0 commit comments

Comments
 (0)