From 8012a532358fb3c37d628b5b8b459c89c579a6e1 Mon Sep 17 00:00:00 2001 From: JSC Date: Mon, 7 Jul 2025 16:10:06 +0200 Subject: [PATCH] feat: implement MusicPlayer component with volume control and playlist management --- src/App.tsx | 7 +- src/components/AppLayout.tsx | 3 + src/components/MusicPlayer.tsx | 385 ++++++++++++++++++++++++++++ src/contexts/MusicPlayerContext.tsx | 344 +++++++++++++++++++++++++ src/index.css | 34 +++ 5 files changed, 771 insertions(+), 2 deletions(-) create mode 100644 src/components/MusicPlayer.tsx create mode 100644 src/contexts/MusicPlayerContext.tsx diff --git a/src/App.tsx b/src/App.tsx index 0c45519..1b03b11 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { ProtectedRoute } from '@/components/ProtectedRoute' import { Button } from '@/components/ui/button' import { AuthProvider } from '@/components/AuthProvider' import { SocketProvider } from '@/contexts/SocketContext' +import { MusicPlayerProvider } from '@/contexts/MusicPlayerContext' import { AccountPage } from '@/pages/AccountPage' import { ActivityPage } from '@/pages/ActivityPage' import { AdminUsersPage } from '@/pages/AdminUsersPage' @@ -21,7 +22,8 @@ function App() { - + + } /> } /> @@ -110,7 +112,8 @@ function App() { } /> } /> - + + diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index cf56470..2520d1c 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -1,6 +1,8 @@ import { PageHeader } from '@/components/PageHeader' import { AppSidebar } from '@/components/sidebar/AppSidebar' import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar' +import { MusicPlayer } from '@/components/MusicPlayer' +import { useMusicPlayer } from '@/contexts/MusicPlayerContext' import { type ReactNode } from 'react' interface AppLayoutProps { @@ -27,6 +29,7 @@ export function AppLayout({
{children}
+ ) } diff --git a/src/components/MusicPlayer.tsx b/src/components/MusicPlayer.tsx new file mode 100644 index 0000000..b0fb9c8 --- /dev/null +++ b/src/components/MusicPlayer.tsx @@ -0,0 +1,385 @@ +import { useRef } from 'react' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Separator } from '@/components/ui/separator' +import { useMusicPlayer } from '@/contexts/MusicPlayerContext' +import { + Play, + Pause, + Square, + SkipBack, + SkipForward, + Volume2, + VolumeX, + Repeat, + Repeat1, + Shuffle, + List, + Maximize2, + Minimize2 +} from 'lucide-react' + +export function MusicPlayer() { + const { + isPlaying, + currentTime, + duration, + volume, + isMuted, + playMode, + currentTrack, + playlist, + currentTrackIndex, + isMinimized, + showPlaylist, + togglePlayPause, + stop, + previousTrack, + nextTrack, + seekTo, + setVolume, + toggleMute, + setPlayMode, + playTrack, + toggleMaximize, + togglePlaylistVisibility, + } = useMusicPlayer() + + const progressBarRef = useRef(null) + + if (!currentTrack) { + return null + } + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + const handleVolumeChange = (e: React.ChangeEvent) => { + const newVolume = parseFloat(e.target.value) + setVolume(newVolume) + } + + const handleProgressClick = (e: React.MouseEvent) => { + if (progressBarRef.current) { + const rect = progressBarRef.current.getBoundingClientRect() + const clickX = e.clientX - rect.left + const percentage = clickX / rect.width + const newTime = percentage * duration + seekTo(newTime) + } + } + + const getPlayModeIcon = () => { + switch (playMode) { + case 'loop-playlist': + return + case 'loop-one': + return + case 'random': + return + default: + return + } + } + + const handlePlayModeToggle = () => { + const modes = ['continuous', 'loop-playlist', 'loop-one', 'random'] as const + const currentIndex = modes.indexOf(playMode) + const nextIndex = (currentIndex + 1) % modes.length + setPlayMode(modes[nextIndex]) + } + + const progressPercentage = (currentTime / duration) * 100 + + if (isMinimized) { + return ( + + {/* Thumbnail */} + {currentTrack.thumbnail && ( +
+ {currentTrack.title} +
+ +
+
+ )} + +
+ {/* Track info */} +
+

+ {currentTrack.title} +

+ {currentTrack.artist && ( +

+ {currentTrack.artist} +

+ )} +
+ + {/* Progress bar */} +
+
+
+
+
+ {formatTime(currentTime)} + {formatTime(duration)} +
+
+ + {/* Main controls */} +
+ + + + +
+ + {/* Secondary controls */} +
+
+ + +
+ + {/* Volume control */} +
+ + +
+
+ + {/* Playlist */} + {showPlaylist && ( + <> + +
+

Playlist

+ {playlist.map((track, index) => ( +
playTrack(index)} + > +
+

{track.title}

+ {track.artist && ( +

+ {track.artist} +

+ )} +
+ + {formatTime(track.duration)} + +
+ ))} +
+ + )} +
+ + ) + } + + // Maximized view - overlay + return ( + + {/* Header */} +
+

Music Player

+ +
+ +
+ {/* Main player area */} +
+ {/* Large thumbnail */} + {currentTrack.thumbnail && ( +
+ {currentTrack.title} +
+ )} + + {/* Track info */} +
+

{currentTrack.title}

+ {currentTrack.artist && ( +

{currentTrack.artist}

+ )} +
+ + {/* Progress bar */} +
+
+
+
+
+ {formatTime(currentTime)} + {formatTime(duration)} +
+
+ + {/* Main controls */} +
+ + + + +
+ + {/* Secondary controls */} +
+ + + {/* Volume control */} +
+ + +
+
+
+ + {/* Playlist sidebar */} +
+
+

Playlist

+
+
+ {playlist.map((track, index) => ( +
playTrack(index)} + > +
+

{track.title}

+ {track.artist && ( +

+ {track.artist} +

+ )} +
+ + {formatTime(track.duration)} + +
+ ))} +
+
+
+ + ) +} \ No newline at end of file diff --git a/src/contexts/MusicPlayerContext.tsx b/src/contexts/MusicPlayerContext.tsx new file mode 100644 index 0000000..34ba42f --- /dev/null +++ b/src/contexts/MusicPlayerContext.tsx @@ -0,0 +1,344 @@ +import { createContext, useContext, useState, useRef, useEffect, type ReactNode } from 'react' + +export interface Track { + id: string + title: string + artist?: string + duration: number + thumbnail?: string + url: string +} + +export type PlayMode = 'continuous' | 'loop-playlist' | 'loop-one' | 'random' + +interface MusicPlayerContextType { + // Playback state + isPlaying: boolean + currentTime: number + duration: number + volume: number + isMuted: boolean + playMode: PlayMode + + // Current track and playlist + currentTrack: Track | null + playlist: Track[] + currentTrackIndex: number + + // UI state + isMinimized: boolean + showPlaylist: boolean + + // Actions + play: () => void + pause: () => void + stop: () => void + togglePlayPause: () => void + seekTo: (time: number) => void + setVolume: (volume: number) => void + toggleMute: () => void + setPlayMode: (mode: PlayMode) => void + nextTrack: () => void + previousTrack: () => void + playTrack: (trackIndex: number) => void + addToPlaylist: (track: Track) => void + removeFromPlaylist: (trackId: string) => void + clearPlaylist: () => void + toggleMaximize: () => void + togglePlaylistVisibility: () => void +} + +const MusicPlayerContext = createContext(undefined) + +export function useMusicPlayer() { + const context = useContext(MusicPlayerContext) + if (context === undefined) { + throw new Error('useMusicPlayer must be used within a MusicPlayerProvider') + } + return context +} + +interface MusicPlayerProviderProps { + children: ReactNode +} + +export function MusicPlayerProvider({ children }: MusicPlayerProviderProps) { + const audioRef = useRef(null) + + // Playback state + const [isPlaying, setIsPlaying] = useState(false) + const [currentTime, setCurrentTime] = useState(0) + const [duration, setDuration] = useState(0) + const [volume, setVolumeState] = useState(0.8) + const [isMuted, setIsMuted] = useState(false) + const [playMode, setPlayMode] = useState('continuous') + + // Playlist state + const [currentTrackIndex, setCurrentTrackIndex] = useState(0) + const [playlist, setPlaylist] = useState([ + { + id: '1', + title: 'Cheryl Lynn - Got To Be Real', + artist: 'Cheryl Lynn', + duration: 240, + thumbnail: '/api/sounds/stream/thumbnails/Cheryl Lynn - Got To Be Real Official Audio_fI569nw0YUQ.webp', + url: '/api/sounds/stream/Cheryl Lynn - Got To Be Real Official Audio_fI569nw0YUQ.opus' + }, + { + id: '2', + title: 'The Whispers - And The Beat Goes On', + artist: 'The Whispers', + duration: 280, + thumbnail: '/api/sounds/stream/thumbnails/The Whispers - And The Beat Goes On Official Video_pEmX5HR9ZxU.jpg', + url: '/api/sounds/stream/The Whispers - And The Beat Goes On Official Video_pEmX5HR9ZxU.opus' + }, + { + id: '3', + title: 'OLD RAP VS NEW RAP', + artist: 'Mister V, Jhon Rachid, Maskey', + duration: 320, + thumbnail: '/api/sounds/stream/thumbnails/OLD RAP VS NEW RAP Ft Mister V Jhon Rachid Maskey _PAFYcOFE3DY.webp', + url: '/api/sounds/stream/OLD RAP VS NEW RAP Ft Mister V Jhon Rachid Maskey _PAFYcOFE3DY.opus' + } + ]) + + // UI state + const [isMinimized, setIsMinimized] = useState(true) + const [showPlaylist, setShowPlaylist] = useState(false) + + const currentTrack = playlist[currentTrackIndex] || null + + // Initialize audio element + useEffect(() => { + audioRef.current = new Audio() + + const audio = audioRef.current + + const handleTimeUpdate = () => { + setCurrentTime(audio.currentTime) + } + + const handleLoadedMetadata = () => { + setDuration(audio.duration) + } + + const handleEnded = () => { + handleTrackEnd() + } + + audio.addEventListener('timeupdate', handleTimeUpdate) + audio.addEventListener('loadedmetadata', handleLoadedMetadata) + audio.addEventListener('ended', handleEnded) + + return () => { + audio.removeEventListener('timeupdate', handleTimeUpdate) + audio.removeEventListener('loadedmetadata', handleLoadedMetadata) + audio.removeEventListener('ended', handleEnded) + audio.pause() + } + }, []) + + // Update audio source when track changes + useEffect(() => { + if (audioRef.current && currentTrack) { + audioRef.current.src = currentTrack.url + audioRef.current.load() + } + }, [currentTrack]) + + // Update audio volume and mute state + useEffect(() => { + if (audioRef.current) { + audioRef.current.volume = isMuted ? 0 : volume + } + }, [volume, isMuted]) + + const handleTrackEnd = () => { + switch (playMode) { + case 'loop-one': + if (audioRef.current) { + audioRef.current.currentTime = 0 + audioRef.current.play() + } + break + case 'loop-playlist': + nextTrack() + break + case 'random': + playRandomTrack() + break + case 'continuous': + if (currentTrackIndex < playlist.length - 1) { + nextTrack() + } else { + stop() + } + break + } + } + + const play = () => { + if (audioRef.current && currentTrack) { + audioRef.current.play() + setIsPlaying(true) + } + } + + const pause = () => { + if (audioRef.current) { + audioRef.current.pause() + setIsPlaying(false) + } + } + + const stop = () => { + if (audioRef.current) { + audioRef.current.pause() + audioRef.current.currentTime = 0 + setIsPlaying(false) + setCurrentTime(0) + } + } + + const togglePlayPause = () => { + if (isPlaying) { + pause() + } else { + play() + } + } + + const seekTo = (time: number) => { + if (audioRef.current) { + audioRef.current.currentTime = time + setCurrentTime(time) + } + } + + const setVolume = (newVolume: number) => { + setVolumeState(newVolume) + setIsMuted(newVolume === 0) + } + + const toggleMute = () => { + setIsMuted(!isMuted) + } + + const nextTrack = () => { + if (playlist.length === 0) return + + const nextIndex = (currentTrackIndex + 1) % playlist.length + setCurrentTrackIndex(nextIndex) + + // Auto-play if currently playing + if (isPlaying) { + setTimeout(() => play(), 100) + } + } + + const previousTrack = () => { + if (playlist.length === 0) return + + const prevIndex = currentTrackIndex === 0 ? playlist.length - 1 : currentTrackIndex - 1 + setCurrentTrackIndex(prevIndex) + + // Auto-play if currently playing + if (isPlaying) { + setTimeout(() => play(), 100) + } + } + + const playRandomTrack = () => { + if (playlist.length <= 1) return + + let randomIndex + do { + randomIndex = Math.floor(Math.random() * playlist.length) + } while (randomIndex === currentTrackIndex) + + setCurrentTrackIndex(randomIndex) + setTimeout(() => play(), 100) + } + + const playTrack = (trackIndex: number) => { + if (trackIndex >= 0 && trackIndex < playlist.length) { + setCurrentTrackIndex(trackIndex) + setTimeout(() => play(), 100) + } + } + + const addToPlaylist = (track: Track) => { + setPlaylist(prev => [...prev, track]) + } + + const removeFromPlaylist = (trackId: string) => { + setPlaylist(prev => { + const newPlaylist = prev.filter(track => track.id !== trackId) + // Adjust current track index if necessary + const removedIndex = prev.findIndex(track => track.id === trackId) + if (removedIndex !== -1 && removedIndex < currentTrackIndex) { + setCurrentTrackIndex(current => Math.max(0, current - 1)) + } else if (removedIndex === currentTrackIndex && newPlaylist.length > 0) { + setCurrentTrackIndex(current => Math.min(current, newPlaylist.length - 1)) + } + return newPlaylist + }) + } + + const clearPlaylist = () => { + stop() + setPlaylist([]) + setCurrentTrackIndex(0) + } + + const toggleMaximize = () => { + setIsMinimized(!isMinimized) + } + + const togglePlaylistVisibility = () => { + setShowPlaylist(!showPlaylist) + } + + const value: MusicPlayerContextType = { + // Playback state + isPlaying, + currentTime, + duration: currentTrack?.duration || 0, + volume, + isMuted, + playMode, + + // Current track and playlist + currentTrack, + playlist, + currentTrackIndex, + + // UI state + isMinimized, + showPlaylist, + + // Actions + play, + pause, + stop, + togglePlayPause, + seekTo, + setVolume, + toggleMute, + setPlayMode, + nextTrack, + previousTrack, + playTrack, + addToPlaylist, + removeFromPlaylist, + clearPlaylist, + toggleMaximize, + togglePlaylistVisibility, + } + + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index 7550e24..523d5d4 100644 --- a/src/index.css +++ b/src/index.css @@ -117,4 +117,38 @@ body { @apply bg-background text-foreground; } +} + +@layer components { + /* Custom slider styles for volume control */ + input[type="range"] { + -webkit-appearance: none; + background: transparent; + } + + input[type="range"]::-webkit-slider-track { + @apply h-1 bg-muted rounded-lg; + } + + input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + @apply h-4 w-4 bg-primary rounded-full cursor-pointer; + margin-top: -6px; + } + + input[type="range"]::-moz-range-track { + @apply h-1 bg-muted rounded-lg border-0; + } + + input[type="range"]::-moz-range-thumb { + @apply h-4 w-4 bg-primary rounded-full cursor-pointer border-0; + } + + /* Line clamp utilities */ + .line-clamp-1 { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + } } \ No newline at end of file