From 3084efe139cdc20d88ae5356d0a0f0fffd09bf8c Mon Sep 17 00:00:00 2001 From: JSC Date: Sun, 10 Aug 2025 10:31:55 +0200 Subject: [PATCH] feat: implement compact player and enhance display mode management in AppLayout and Player components --- src/components/AppLayout.tsx | 20 +- src/components/AppSidebar.tsx | 16 +- src/components/player/CompactPlayer.tsx | 237 ++++++++++++++++++++++++ src/components/player/Player.tsx | 43 ++++- 4 files changed, 307 insertions(+), 9 deletions(-) create mode 100644 src/components/player/CompactPlayer.tsx diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 74e3f8d..0db5a8b 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { SidebarProvider, SidebarInset, SidebarTrigger } from '@/components/ui/sidebar' import { AppSidebar } from './AppSidebar' import { Separator } from '@/components/ui/separator' @@ -9,7 +10,7 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from '@/components/ui/breadcrumb' -import { Player } from './player/Player' +import { Player, type PlayerDisplayMode } from './player/Player' interface AppLayoutProps { children: React.ReactNode @@ -22,9 +23,20 @@ interface AppLayoutProps { } export function AppLayout({ children, breadcrumb }: AppLayoutProps) { + const [playerDisplayMode, setPlayerDisplayMode] = useState(() => { + // Initialize from localStorage or default to 'normal' + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('playerDisplayMode') as PlayerDisplayMode + return saved && ['normal', 'minimized', 'maximized', 'sidebar'].includes(saved) ? saved : 'normal' + } + return 'normal' + }) + + // Note: localStorage is managed by the Player component + return ( - +
@@ -58,7 +70,9 @@ export function AppLayout({ children, breadcrumb }: AppLayoutProps) { {children}
- + ) } \ No newline at end of file diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 6924be8..4b75924 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -16,9 +16,15 @@ import { import { NavGroup } from './nav/NavGroup' import { NavItem } from './nav/NavItem' import { UserNav } from './nav/UserNav' +import { CompactPlayer } from './player/CompactPlayer' +import { Separator } from '@/components/ui/separator' import { useAuth } from '@/contexts/AuthContext' -export function AppSidebar() { +interface AppSidebarProps { + showCompactPlayer?: boolean +} + +export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) { const { user, logout } = useAuth() if (!user) return null @@ -49,6 +55,14 @@ export function AppSidebar() { + {showCompactPlayer && ( + <> +
+ +
+ + + )}
diff --git a/src/components/player/CompactPlayer.tsx b/src/components/player/CompactPlayer.tsx new file mode 100644 index 0000000..1460fa6 --- /dev/null +++ b/src/components/player/CompactPlayer.tsx @@ -0,0 +1,237 @@ +import { useState, useEffect, useCallback } from 'react' +import { Button } from '@/components/ui/button' +import { Progress } from '@/components/ui/progress' +import { + Play, + Pause, + SkipBack, + SkipForward, + Volume2, + VolumeX, + Music, + Maximize2 +} from 'lucide-react' +import { playerService, type PlayerState, type MessageResponse } from '@/lib/api/services/player' +import { filesService } from '@/lib/api/services/files' +import { playerEvents, PLAYER_EVENTS } from '@/lib/events' +import { toast } from 'sonner' +import { cn } from '@/lib/utils' +import { formatDuration } from '@/utils/format-duration' + +interface CompactPlayerProps { + className?: string +} + +export function CompactPlayer({ className }: CompactPlayerProps) { + const [state, setState] = useState({ + status: 'stopped', + mode: 'continuous', + volume: 80, + position: 0 + }) + const [isLoading, setIsLoading] = useState(false) + + // Load initial state + useEffect(() => { + const loadState = async () => { + try { + const initialState = await playerService.getState() + setState(initialState) + } catch (error) { + console.error('Failed to load player state:', error) + } + } + loadState() + }, []) + + // Listen for player state updates + useEffect(() => { + const handlePlayerState = (newState: PlayerState) => { + setState(newState) + } + + playerEvents.on(PLAYER_EVENTS.PLAYER_STATE, handlePlayerState) + + return () => { + playerEvents.off(PLAYER_EVENTS.PLAYER_STATE, handlePlayerState) + } + }, []) + + const executeAction = useCallback(async (action: () => Promise, actionName: string) => { + setIsLoading(true) + try { + await action() + } catch (error) { + console.error(`Failed to ${actionName}:`, error) + toast.error(`Failed to ${actionName}`) + } finally { + setIsLoading(false) + } + }, []) + + const handlePlayPause = useCallback(() => { + if (state.status === 'playing') { + executeAction(playerService.pause, 'pause') + } else { + executeAction(playerService.play, 'play') + } + }, [state.status, executeAction]) + + const handlePrevious = useCallback(() => { + executeAction(playerService.previous, 'go to previous track') + }, [executeAction]) + + const handleNext = useCallback(() => { + executeAction(playerService.next, 'go to next track') + }, [executeAction]) + + const handleVolumeToggle = useCallback(() => { + if (state.volume === 0) { + executeAction(() => playerService.setVolume(50), 'unmute') + } else { + executeAction(() => playerService.setVolume(0), 'mute') + } + }, [state.volume, executeAction]) + + // Don't show if no current sound + if (!state.current_sound) { + return null + } + + return ( +
+ {/* Collapsed state - only play/pause button */} +
+ +
+ + {/* Expanded state - full player */} +
+ {/* Track Info */} +
+
+ {state.current_sound?.thumbnail ? ( + {state.current_sound.name} { + // Hide image and show music icon if thumbnail fails to load + const target = e.target as HTMLImageElement + target.style.display = 'none' + const musicIcon = target.nextElementSibling as HTMLElement + if (musicIcon) musicIcon.style.display = 'block' + }} + /> + ) : null} + +
+
+
+ {state.current_sound.name} +
+
+ {state.playlist?.name} +
+
+ +
+ + {/* Progress Bar */} +
+ +
+ {formatDuration(state.position)} + {formatDuration(state.duration || 0)} +
+
+ + {/* Controls */} +
+ + + + + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/player/Player.tsx b/src/components/player/Player.tsx index 7542ee6..972c0ae 100644 --- a/src/components/player/Player.tsx +++ b/src/components/player/Player.tsx @@ -34,20 +34,37 @@ import { cn } from '@/lib/utils' import { formatDuration } from '@/utils/format-duration' import { Playlist } from './Playlist' -export type PlayerDisplayMode = 'normal' | 'minimized' | 'maximized' +export type PlayerDisplayMode = 'normal' | 'minimized' | 'maximized' | 'sidebar' interface PlayerProps { className?: string + onPlayerModeChange?: (mode: PlayerDisplayMode) => void } -export function Player({ className }: PlayerProps) { +export function Player({ className, onPlayerModeChange }: PlayerProps) { const [state, setState] = useState({ status: 'stopped', mode: 'continuous', volume: 80, position: 0 }) - const [displayMode, setDisplayMode] = useState('normal') + const [displayMode, setDisplayMode] = useState(() => { + // Initialize from localStorage or default to 'normal' + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('playerDisplayMode') as PlayerDisplayMode + return saved && ['normal', 'minimized', 'maximized', 'sidebar'].includes(saved) ? saved : 'normal' + } + return 'normal' + }) + + // Notify parent when display mode changes and save to localStorage + useEffect(() => { + onPlayerModeChange?.(displayMode) + // Save to localStorage + if (typeof window !== 'undefined') { + localStorage.setItem('playerDisplayMode', displayMode) + } + }, [displayMode, onPlayerModeChange]) const [showPlaylist, setShowPlaylist] = useState(false) const [isLoading, setIsLoading] = useState(false) const [isMuted, setIsMuted] = useState(false) @@ -187,12 +204,18 @@ export function Player({ className }: PlayerProps) { } } + const expandFromSidebar = useCallback(() => { + setDisplayMode('normal') + }, []) + const getPlayerPosition = () => { switch (displayMode) { case 'minimized': return 'fixed bottom-4 right-4 z-50' case 'maximized': return 'fixed inset-0 z-50 bg-background' + case 'sidebar': + return 'hidden' // Hidden when in sidebar mode default: return 'fixed bottom-4 right-4 z-50' } @@ -263,9 +286,9 @@ export function Player({ className }: PlayerProps) { @@ -682,6 +705,16 @@ export function Player({ className }: PlayerProps) { ) + // Expose expand function for external use + useEffect(() => { + // Store expand function globally so sidebar can access it + const windowWithExpand = window as unknown as { __expandPlayerFromSidebar?: () => void } + windowWithExpand.__expandPlayerFromSidebar = expandFromSidebar + return () => { + delete windowWithExpand.__expandPlayerFromSidebar + } + }, [expandFromSidebar]) + if (!state) return null return (