Compare commits

..

4 Commits

Author SHA1 Message Date
JSC
b8bac2b6a9 Merge branch 'player'
Some checks failed
Frontend CI / lint (push) Failing after 5m8s
Frontend CI / build (push) Has been skipped
2025-07-07 21:18:39 +02:00
JSC
32fab283be refactor: remove console logs for cleaner code and improved readability 2025-07-07 21:18:00 +02:00
JSC
2230fa32e5 feat: enhance MusicPlayer and SocketProvider with playlist management and real-time updates 2025-07-07 20:51:46 +02:00
JSC
8012a53235 feat: implement MusicPlayer component with volume control and playlist management 2025-07-07 16:10:06 +02:00
8 changed files with 750 additions and 10 deletions

View File

@@ -3,6 +3,7 @@ import { ProtectedRoute } from '@/components/ProtectedRoute'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { AuthProvider } from '@/components/AuthProvider' import { AuthProvider } from '@/components/AuthProvider'
import { SocketProvider } from '@/contexts/SocketContext' import { SocketProvider } from '@/contexts/SocketContext'
import { MusicPlayerProvider } from '@/contexts/MusicPlayerContext'
import { AccountPage } from '@/pages/AccountPage' import { AccountPage } from '@/pages/AccountPage'
import { ActivityPage } from '@/pages/ActivityPage' import { ActivityPage } from '@/pages/ActivityPage'
import { AdminUsersPage } from '@/pages/AdminUsersPage' import { AdminUsersPage } from '@/pages/AdminUsersPage'
@@ -21,6 +22,7 @@ function App() {
<Toaster /> <Toaster />
<AuthProvider> <AuthProvider>
<SocketProvider> <SocketProvider>
<MusicPlayerProvider>
<Router> <Router>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
@@ -111,6 +113,7 @@ function App() {
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>
</Router> </Router>
</MusicPlayerProvider>
</SocketProvider> </SocketProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>

View File

@@ -1,6 +1,8 @@
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { AppSidebar } from '@/components/sidebar/AppSidebar' import { AppSidebar } from '@/components/sidebar/AppSidebar'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar' import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import { MusicPlayer } from '@/components/MusicPlayer'
import { useMusicPlayer } from '@/contexts/MusicPlayerContext'
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
interface AppLayoutProps { interface AppLayoutProps {
@@ -27,6 +29,7 @@ export function AppLayout({
<div className="container mx-auto p-6">{children}</div> <div className="container mx-auto p-6">{children}</div>
</main> </main>
</SidebarInset> </SidebarInset>
<MusicPlayer />
</SidebarProvider> </SidebarProvider>
) )
} }

View File

@@ -23,7 +23,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Listen for refresh token expiration events // Listen for refresh token expiration events
const handleRefreshTokenExpired = () => { const handleRefreshTokenExpired = () => {
console.log('Refresh token expired, logging out user')
setUser(null) setUser(null)
} }

View File

@@ -0,0 +1,386 @@
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<HTMLDivElement>(null)
// Show player if there's a playlist, even if no current track is playing
if (playlist.length === 0) {
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<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value)
setVolume(newVolume)
}
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
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 <Repeat className="h-4 w-4" />
case 'loop-one':
return <Repeat1 className="h-4 w-4" />
case 'random':
return <Shuffle className="h-4 w-4" />
default:
return <Play className="h-4 w-4" />
}
}
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 (
<Card className="fixed bottom-4 right-4 w-80 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border shadow-lg z-50">
{/* Thumbnail */}
{currentTrack?.thumbnail && (
<div className="relative h-32 w-full overflow-hidden rounded-t-lg">
<img
src={currentTrack.thumbnail}
alt={currentTrack.title}
className="h-full w-full object-cover"
/>
<div className="absolute top-2 right-2">
<Button
variant="secondary"
size="sm"
onClick={toggleMaximize}
className="h-8 w-8 p-0 bg-black/50 hover:bg-black/70"
>
<Maximize2 className="h-4 w-4" />
</Button>
</div>
</div>
)}
<div className="p-4 space-y-3">
{/* Track info */}
<div className="space-y-1">
<h3 className="font-medium text-sm leading-tight line-clamp-1">
{currentTrack?.title || 'No track selected'}
</h3>
{currentTrack?.artist && (
<p className="text-xs text-muted-foreground line-clamp-1">
{currentTrack.artist}
</p>
)}
</div>
{/* Progress bar */}
<div className="space-y-2">
<div
ref={progressBarRef}
className="w-full h-2 bg-muted rounded-full cursor-pointer"
onClick={handleProgressClick}
>
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${progressPercentage}%` }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
{/* Main controls */}
<div className="flex items-center justify-center space-x-2">
<Button variant="ghost" size="sm" onClick={previousTrack}>
<SkipBack className="h-4 w-4" />
</Button>
<Button variant="default" size="sm" onClick={togglePlayPause}>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="sm" onClick={stop}>
<Square className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={nextTrack}>
<SkipForward className="h-4 w-4" />
</Button>
</div>
{/* Secondary controls */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="sm"
onClick={handlePlayModeToggle}
className="h-8 w-8 p-0"
>
{getPlayModeIcon()}
</Button>
<Button
variant="ghost"
size="sm"
onClick={togglePlaylistVisibility}
className="h-8 w-8 p-0"
>
<List className="h-4 w-4" />
</Button>
</div>
{/* Volume control */}
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={toggleMute}
className="h-8 w-8 p-0"
>
{isMuted || volume === 0 ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="w-16 h-1 bg-muted rounded-lg appearance-none cursor-pointer slider"
/>
</div>
</div>
{/* Playlist */}
{showPlaylist && (
<>
<Separator />
<div className="space-y-2 max-h-40 overflow-y-auto">
<h4 className="font-medium text-sm">Playlist</h4>
{playlist.map((track, index) => (
<div
key={track.id}
className={`flex items-center space-x-2 p-2 rounded cursor-pointer hover:bg-muted/50 ${
index === currentTrackIndex ? 'bg-muted' : ''
}`}
onClick={() => playTrack(index)}
>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium line-clamp-1">{track.title}</p>
{track.artist && (
<p className="text-xs text-muted-foreground line-clamp-1">
{track.artist}
</p>
)}
</div>
<span className="text-xs text-muted-foreground">
{formatTime(track.duration)}
</span>
</div>
))}
</div>
</>
)}
</div>
</Card>
)
}
// Maximized view - overlay
return (
<Card className="fixed inset-0 bg-background/95 backdrop-blur-md supports-[backdrop-filter]:bg-background/80 z-50 flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-lg font-semibold">Music Player</h2>
<Button variant="ghost" size="sm" onClick={toggleMaximize}>
<Minimize2 className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 flex">
{/* Main player area */}
<div className="flex-1 flex flex-col items-center justify-center p-8">
{/* Large thumbnail */}
{currentTrack?.thumbnail && (
<div className="w-80 h-80 rounded-lg overflow-hidden mb-6 shadow-lg">
<img
src={currentTrack.thumbnail}
alt={currentTrack.title}
className="h-full w-full object-cover"
/>
</div>
)}
{/* Track info */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold mb-2">{currentTrack?.title || 'No track selected'}</h1>
{currentTrack?.artist && (
<p className="text-lg text-muted-foreground">{currentTrack.artist}</p>
)}
</div>
{/* Progress bar */}
<div className="w-full max-w-md space-y-4 mb-8">
<div
ref={progressBarRef}
className="w-full h-3 bg-muted rounded-full cursor-pointer"
onClick={handleProgressClick}
>
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${progressPercentage}%` }}
/>
</div>
<div className="flex justify-between text-sm text-muted-foreground">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
{/* Main controls */}
<div className="flex items-center justify-center space-x-4 mb-6">
<Button variant="ghost" size="lg" onClick={previousTrack}>
<SkipBack className="h-6 w-6" />
</Button>
<Button variant="default" size="lg" onClick={togglePlayPause} className="h-14 w-14">
{isPlaying ? <Pause className="h-8 w-8" /> : <Play className="h-8 w-8" />}
</Button>
<Button variant="ghost" size="lg" onClick={stop}>
<Square className="h-6 w-6" />
</Button>
<Button variant="ghost" size="lg" onClick={nextTrack}>
<SkipForward className="h-6 w-6" />
</Button>
</div>
{/* Secondary controls */}
<div className="flex items-center space-x-6">
<Button
variant="ghost"
size="sm"
onClick={handlePlayModeToggle}
>
{getPlayModeIcon()}
</Button>
{/* Volume control */}
<div className="flex items-center space-x-3">
<Button
variant="ghost"
size="sm"
onClick={toggleMute}
>
{isMuted || volume === 0 ? (
<VolumeX className="h-5 w-5" />
) : (
<Volume2 className="h-5 w-5" />
)}
</Button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="w-24 h-2 bg-muted rounded-lg appearance-none cursor-pointer"
/>
</div>
</div>
</div>
{/* Playlist sidebar */}
<div className="w-80 border-l bg-muted/30 flex flex-col">
<div className="p-4 border-b">
<h3 className="font-semibold">Playlist</h3>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{playlist.map((track, index) => (
<div
key={track.id}
className={`flex items-center space-x-3 p-3 rounded-lg cursor-pointer hover:bg-background/50 transition-colors ${
index === currentTrackIndex ? 'bg-background shadow-sm' : ''
}`}
onClick={() => playTrack(index)}
>
<div className="flex-1 min-w-0">
<p className="font-medium line-clamp-1">{track.title}</p>
{track.artist && (
<p className="text-sm text-muted-foreground line-clamp-1">
{track.artist}
</p>
)}
</div>
<span className="text-sm text-muted-foreground">
{formatTime(track.duration)}
</span>
</div>
))}
</div>
</div>
</div>
</Card>
)
}

View File

@@ -0,0 +1,318 @@
import { createContext, useContext, useState, useRef, useEffect, type ReactNode } from 'react'
import { useSocket } from './SocketContext'
import { apiService } from '@/services/api'
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
loadPlaylist: (playlistId: number) => void
addToPlaylist: (track: Track) => void
removeFromPlaylist: (trackId: string) => void
clearPlaylist: () => void
toggleMaximize: () => void
togglePlaylistVisibility: () => void
}
const MusicPlayerContext = createContext<MusicPlayerContextType | undefined>(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 { socket } = useSocket()
// Playback state
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [volume, setVolumeState] = useState(80)
const [isMuted, setIsMuted] = useState(false)
const [playMode, setPlayModeState] = useState<PlayMode>('continuous')
// Playlist state
const [currentTrackIndex, setCurrentTrackIndex] = useState(0)
const [playlist, setPlaylist] = useState<Track[]>([])
const [currentPlaylistId, setCurrentPlaylistId] = useState<number | null>(null)
const [currentTrack, setCurrentTrack] = useState<Track | null>(null)
// UI state
const [isMinimized, setIsMinimized] = useState(true)
const [showPlaylist, setShowPlaylist] = useState(false)
// Fetch initial player state on mount
useEffect(() => {
const fetchInitialState = async () => {
try {
const response = await apiService.get('/api/player/state')
let state = await response.json()
// If no playlist is loaded, try to load the main playlist
if (!state.playlist_id) {
try {
await apiService.post('/api/player/load-main-playlist')
// Fetch state again after loading main playlist
const newResponse = await apiService.get('/api/player/state')
state = await newResponse.json()
} catch (loadError) {
console.warn('Failed to load main playlist:', loadError)
}
}
// Update all state from backend
setIsPlaying(state.is_playing || false)
setCurrentTime(state.current_time || 0)
setDuration(state.duration || 0)
setVolumeState(state.volume || 80)
setIsMuted(state.volume === 0)
setPlayModeState(state.play_mode || 'continuous')
setCurrentTrackIndex(state.current_track_index || 0)
setPlaylist(state.playlist || [])
setCurrentPlaylistId(state.playlist_id || null)
setCurrentTrack(state.current_track || null)
} catch (error) {
console.error('Failed to fetch initial player state:', error)
}
}
fetchInitialState()
}, [])
// Listen for real-time player updates via SocketIO
useEffect(() => {
if (!socket) {
return;
}
const handlePlayerStateUpdate = (state: any) => {
setIsPlaying(state.is_playing || false)
setCurrentTime(state.current_time || 0)
setDuration(state.duration || 0)
setVolumeState(state.volume || 80)
setIsMuted(state.volume === 0)
setPlayModeState(state.play_mode || 'continuous')
setCurrentTrackIndex(state.current_track_index || 0)
setPlaylist(state.playlist || [])
setCurrentPlaylistId(state.playlist_id || null)
setCurrentTrack(state.current_track || null)
}
socket.on('player_state_update', handlePlayerStateUpdate)
return () => {
socket.off('player_state_update', handlePlayerStateUpdate)
}
}, [socket])
const play = async () => {
try {
await apiService.post('/api/player/play')
} catch (error) {
console.error('Failed to play:', error)
}
}
const pause = async () => {
try {
await apiService.post('/api/player/pause')
} catch (error) {
console.error('Failed to pause:', error)
}
}
const stop = async () => {
try {
await apiService.post('/api/player/stop')
} catch (error) {
console.error('Failed to stop:', error)
}
}
const togglePlayPause = async () => {
if (isPlaying) {
await pause()
} else {
await play()
}
}
const seekTo = async (time: number) => {
try {
const position = duration > 0 ? time / duration : 0
await apiService.post('/api/player/seek', { position })
} catch (error) {
console.error('Failed to seek:', error)
}
}
const setVolume = async (newVolume: number) => {
try {
await apiService.post('/api/player/volume', { volume: newVolume })
} catch (error) {
console.error('Failed to set volume:', error)
}
}
const toggleMute = async () => {
try {
const newVolume = isMuted ? 80 : 0
await apiService.post('/api/player/volume', { volume: newVolume })
} catch (error) {
console.error('Failed to toggle mute:', error)
}
}
const nextTrack = async () => {
try {
await apiService.post('/api/player/next')
} catch (error) {
console.error('Failed to skip to next track:', error)
}
}
const previousTrack = async () => {
try {
await apiService.post('/api/player/previous')
} catch (error) {
console.error('Failed to skip to previous track:', error)
}
}
const playTrack = async (trackIndex: number) => {
try {
await apiService.post('/api/player/play-track', { index: trackIndex })
} catch (error) {
console.error('Failed to play track:', error)
}
}
const loadPlaylist = async (playlistId: number) => {
try {
await apiService.post('/api/player/playlist', { playlist_id: playlistId })
} catch (error) {
console.error('Failed to load playlist:', error)
}
}
const addToPlaylist = (track: Track) => {
// This would need to be implemented via API
console.log('Adding to playlist not yet implemented')
}
const removeFromPlaylist = (trackId: string) => {
// This would need to be implemented via API
console.log('Removing from playlist not yet implemented')
}
const clearPlaylist = () => {
// This would need to be implemented via API
console.log('Clearing playlist not yet implemented')
}
const setPlayMode = async (mode: PlayMode) => {
try {
await apiService.post('/api/player/mode', { mode })
} catch (error) {
console.error('Failed to set play mode:', error)
}
}
const toggleMaximize = () => {
setIsMinimized(!isMinimized)
}
const togglePlaylistVisibility = () => {
setShowPlaylist(!showPlaylist)
}
const value: MusicPlayerContextType = {
// Playback state
isPlaying,
currentTime,
duration,
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,
loadPlaylist,
addToPlaylist,
removeFromPlaylist,
clearPlaylist,
toggleMaximize,
togglePlaylistVisibility,
}
return (
<MusicPlayerContext.Provider value={value}>
{children}
</MusicPlayerContext.Provider>
)
}

View File

@@ -44,6 +44,7 @@ export const SocketProvider: React.FC<SocketProviderProps> = ({ children }) => {
// Set up event listeners // Set up event listeners
newSocket.on("connect", () => { newSocket.on("connect", () => {
// Send authentication after connection // Send authentication after connection
newSocket.emit("authenticate", {}); newSocket.emit("authenticate", {});
}); });
@@ -75,7 +76,9 @@ export const SocketProvider: React.FC<SocketProviderProps> = ({ children }) => {
// Connect/disconnect based on authentication state // Connect/disconnect based on authentication state
useEffect(() => { useEffect(() => {
if (!socket || loading) return; if (!socket || loading) {
return;
}
if (user && !isConnected) { if (user && !isConnected) {
socket.connect(); socket.connect();

View File

@@ -118,3 +118,37 @@
@apply bg-background text-foreground; @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;
}
}

View File

@@ -82,11 +82,8 @@ class ApiService {
}) })
if (response.ok) { if (response.ok) {
console.log('Token refreshed successfully')
return true return true
} else { } else {
console.log('Token refresh failed:', response.status)
// If refresh token is also expired (401), trigger logout // If refresh token is also expired (401), trigger logout
if (response.status === 401) { if (response.status === 401) {
this.handleLogout() this.handleLogout()
@@ -103,9 +100,6 @@ class ApiService {
* Handle logout when refresh token expires * Handle logout when refresh token expires
*/ */
private handleLogout() { private handleLogout() {
// Clear any local storage or state if needed
console.log('Refresh token expired, user needs to login again')
// Dispatch a custom event that the AuthContext can listen to // Dispatch a custom event that the AuthContext can listen to
window.dispatchEvent(new CustomEvent('auth:refresh-token-expired')) window.dispatchEvent(new CustomEvent('auth:refresh-token-expired'))
} }