Compare commits

...

3 Commits

Author SHA1 Message Date
JSC
7a6288cc02 feat: update PlayNextQueue component and integrate it into Player; adjust layout in Playlist for improved UI
Some checks failed
Frontend CI / lint (push) Failing after 18s
Frontend CI / build (push) Has been skipped
2025-10-04 21:55:20 +02:00
JSC
0f8b96e73c feat: add PlayNextQueue component to display upcoming tracks in Player 2025-10-04 19:40:01 +02:00
JSC
9a2a9343d2 feat: add context menu to Playlist for adding tracks to play next queue 2025-10-04 19:16:25 +02:00
5 changed files with 219 additions and 61 deletions

View File

@@ -22,6 +22,7 @@ export function CompactPlayer({ className }: CompactPlayerProps) {
volume: 80, volume: 80,
previous_volume: 80, previous_volume: 80,
position: 0, position: 0,
play_next_queue: [],
}) })
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)

View File

@@ -0,0 +1,105 @@
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { filesService } from '@/lib/api/services/files'
import { type PlayerSound } from '@/lib/api/services/player'
import { formatDuration } from '@/utils/format-duration'
import { Music, ListPlus } from 'lucide-react'
interface PlayNextQueueProps {
queue: PlayerSound[]
}
export function PlayNextQueue({ queue }: PlayNextQueueProps) {
if (queue.length === 0) {
return null
}
// Calculate total duration
const totalDuration = queue.reduce((sum, sound) => sum + sound.duration, 0)
return (
<Popover>
<PopoverTrigger asChild>
<div className="flex items-center justify-between py-2 px-2 rounded hover:bg-muted/50 cursor-pointer transition-colors">
<div className="flex items-center gap-2">
<ListPlus className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Play Next</span>
</div>
<Badge variant="secondary" className="text-xs">
{queue.length} {queue.length === 1 ? 'track' : 'tracks'}
</Badge>
</div>
</PopoverTrigger>
<PopoverContent side="left" align="start" className="w-80 p-0">
<div className="w-full flex flex-col">
{/* Header - Fixed */}
<div className="flex items-center justify-between p-3 border-b">
<h4 className="font-semibold text-sm">Play Next Queue</h4>
<Badge variant="secondary" className="text-xs">
{queue.length}
</Badge>
</div>
{/* Track List - Scrollable */}
<ScrollArea className="h-96">
<div className="w-full space-y-1 p-3 pt-2">
{queue.map((sound, index) => (
<div
key={`${sound.id}-${index}`}
className="grid grid-cols-10 gap-2 items-center py-1.5 px-2 rounded hover:bg-muted/50 text-xs"
>
{/* Queue position - 1 column */}
<div className="col-span-1 flex justify-center">
<span className="text-muted-foreground">{index + 1}</span>
</div>
{/* Thumbnail - 1 column */}
<div className="col-span-1">
<div className="bg-muted rounded flex items-center justify-center overflow-hidden w-5 h-5">
{sound.thumbnail ? (
<img
src={filesService.getThumbnailUrl(sound.id)}
alt=""
className="w-full h-full object-cover"
/>
) : (
<Music className="h-3 w-3 text-muted-foreground" />
)}
</div>
</div>
{/* Track name - 6 columns */}
<div className="col-span-6">
<span className="font-medium truncate block text-xs">
{sound.name}
</span>
</div>
{/* Duration - 2 columns */}
<div className="col-span-2 text-right">
<span className="text-muted-foreground text-xs whitespace-nowrap">
{formatDuration(sound.duration)}
</span>
</div>
</div>
))}
</div>
</ScrollArea>
{/* Footer - Fixed */}
<div className="p-3 pt-2 border-t bg-muted/30">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Total playtime</span>
<span className="font-medium">{formatDuration(totalDuration)}</span>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -18,6 +18,7 @@ import {
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Playlist } from './Playlist' import { Playlist } from './Playlist'
import { PlayNextQueue } from './PlayNextQueue'
import { PlayerControls } from './PlayerControls' import { PlayerControls } from './PlayerControls'
import { PlayerProgress } from './PlayerProgress' import { PlayerProgress } from './PlayerProgress'
import { PlayerTrackInfo } from './PlayerTrackInfo' import { PlayerTrackInfo } from './PlayerTrackInfo'
@@ -66,6 +67,11 @@ function isPlayerStateEqual(state1: PlayerState, state2: PlayerState): boolean {
} }
} }
// Compare play_next_queue length
if (state1.play_next_queue.length !== state2.play_next_queue.length) {
return false
}
return true return true
} }
@@ -81,6 +87,7 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
volume: 80, volume: 80,
previous_volume: 80, previous_volume: 80,
position: 0, position: 0,
play_next_queue: [],
}) })
const [displayMode, setDisplayMode] = useState<PlayerDisplayMode>(() => { const [displayMode, setDisplayMode] = useState<PlayerDisplayMode>(() => {
// Initialize from localStorage or default to 'normal' // Initialize from localStorage or default to 'normal'
@@ -368,6 +375,13 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
) )
} }
/> />
{/* Play Next Queue */}
{state.play_next_queue.length > 0 && (
<div className="mt-2">
<PlayNextQueue queue={state.play_next_queue} />
</div>
)}
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -434,14 +448,14 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
{/* Playlist Sidebar */} {/* Playlist Sidebar */}
{state.playlist && ( {state.playlist && (
<div className="w-96 border-l bg-muted/10 backdrop-blur-sm"> <div className="w-96 border-l bg-muted/10 backdrop-blur-sm flex flex-col">
<div className="p-4 border-b"> <div className="p-4 border-b flex-shrink-0">
<h3 className="font-semibold">Playlist</h3> <h3 className="font-semibold">Playlist</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{state.playlist.sounds.length} tracks {state.playlist.sounds.length} tracks
</p> </p>
</div> </div>
<div className="p-4"> <div className="p-4 overflow-y-auto flex-1">
<Playlist <Playlist
playlist={state.playlist} playlist={state.playlist}
currentIndex={state.index} currentIndex={state.index}
@@ -453,6 +467,13 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
} }
variant="maximized" variant="maximized"
/> />
{/* Play Next Queue */}
{state.play_next_queue.length > 0 && (
<div className="mt-2">
<PlayNextQueue queue={state.play_next_queue} />
</div>
)}
</div> </div>
</div> </div>
)} )}

View File

@@ -3,11 +3,17 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { filesService } from '@/lib/api/services/files' import { filesService } from '@/lib/api/services/files'
import { type PlayerPlaylist } from '@/lib/api/services/player' import { type PlayerPlaylist, playerService } from '@/lib/api/services/player'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { formatDuration } from '@/utils/format-duration' import { formatDuration } from '@/utils/format-duration'
import { Music, Play, Search, X } from 'lucide-react' import { Music, Play, Search, X, ListPlus } from 'lucide-react'
interface PlaylistProps { interface PlaylistProps {
playlist: PlayerPlaylist playlist: PlayerPlaylist
@@ -28,6 +34,14 @@ export function Playlist({
sound.name.toLowerCase().includes(searchQuery.toLowerCase()) sound.name.toLowerCase().includes(searchQuery.toLowerCase())
) )
const handleAddToPlayNext = async (soundId: number) => {
try {
await playerService.addToPlayNext(soundId)
} catch (error) {
console.error('Failed to add track to play next:', error)
}
}
return ( return (
<div className="w-full"> <div className="w-full">
{/* Header */} {/* Header */}
@@ -62,70 +76,79 @@ export function Playlist({
{/* Track List */} {/* Track List */}
<ScrollArea <ScrollArea
className={variant === 'maximized' ? 'h-[calc(100vh-280px)]' : 'h-60'} className={variant === 'maximized' ? 'h-[calc(100vh-320px)]' : 'h-60'}
> >
<div className="w-full"> <div className="w-full">
{filteredSounds.map((sound) => { {filteredSounds.map((sound) => {
const originalIndex = playlist.sounds.findIndex((s) => s.id === sound.id) const originalIndex = playlist.sounds.findIndex((s) => s.id === sound.id)
return ( return (
<div <ContextMenu key={sound.id}>
key={sound.id} <ContextMenuTrigger asChild>
className={cn( <div
'grid grid-cols-10 gap-2 items-center py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer text-xs', className={cn(
currentIndex === originalIndex && 'bg-primary/10 text-primary', 'grid grid-cols-10 gap-2 items-center py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer text-xs',
)} currentIndex === originalIndex && 'bg-primary/10 text-primary',
onClick={() => onTrackSelect(originalIndex)} )}
> onClick={() => onTrackSelect(originalIndex)}
{/* Track number/play icon - 1 column */} >
<div className="col-span-1 flex justify-center"> {/* Track number/play icon - 1 column */}
{currentIndex === originalIndex ? ( <div className="col-span-1 flex justify-center">
<Play className="h-3 w-3" /> {currentIndex === originalIndex ? (
) : ( <Play className="h-3 w-3" />
<span className="text-muted-foreground">{originalIndex + 1}</span> ) : (
)} <span className="text-muted-foreground">{originalIndex + 1}</span>
</div> )}
</div>
{/* Thumbnail - 1 column */} {/* Thumbnail - 1 column */}
<div className="col-span-1"> <div className="col-span-1">
<div <div
className={cn( className={cn(
'bg-muted rounded flex items-center justify-center overflow-hidden', 'bg-muted rounded flex items-center justify-center overflow-hidden',
variant === 'maximized' ? 'w-6 h-6' : 'w-5 h-5', variant === 'maximized' ? 'w-6 h-6' : 'w-5 h-5',
)} )}
> >
{sound.thumbnail ? ( {sound.thumbnail ? (
<img <img
src={filesService.getThumbnailUrl(sound.id)} src={filesService.getThumbnailUrl(sound.id)}
alt="" alt=""
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (
<Music className="h-3 w-3 text-muted-foreground" /> <Music className="h-3 w-3 text-muted-foreground" />
)} )}
</div> </div>
</div> </div>
{/* Track name - 6 columns (takes most space) */} {/* Track name - 6 columns (takes most space) */}
<div className="col-span-6"> <div className="col-span-6">
<span <span
className={cn( className={cn(
'font-medium truncate block', 'font-medium truncate block',
variant === 'maximized' ? 'text-sm' : 'text-xs', variant === 'maximized' ? 'text-sm' : 'text-xs',
currentIndex === originalIndex ? 'text-primary' : 'text-foreground', currentIndex === originalIndex ? 'text-primary' : 'text-foreground',
)} )}
> >
{sound.name} {sound.name}
</span> </span>
</div> </div>
{/* Duration - 2 columns */} {/* Duration - 2 columns */}
<div className="col-span-2 text-right"> <div className="col-span-2 text-right">
<span className="text-muted-foreground text-xs whitespace-nowrap"> <span className="text-muted-foreground text-xs whitespace-nowrap">
{formatDuration(sound.duration)} {formatDuration(sound.duration)}
</span> </span>
</div> </div>
</div> </div>
)})} </ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => handleAddToPlayNext(sound.id)}>
<ListPlus className="mr-2 h-4 w-4" />
Add to play next
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)})}
</div> </div>
</ScrollArea> </ScrollArea>

View File

@@ -38,6 +38,7 @@ export interface PlayerState {
index?: number index?: number
current_sound?: PlayerSound current_sound?: PlayerSound
playlist?: PlayerPlaylist playlist?: PlayerPlaylist
play_next_queue: PlayerSound[]
} }
export interface PlayerSeekRequest { export interface PlayerSeekRequest {
@@ -147,6 +148,13 @@ export class PlayerService {
async getState(): Promise<PlayerState> { async getState(): Promise<PlayerState> {
return apiClient.get<PlayerState>('/api/v1/player/state') return apiClient.get<PlayerState>('/api/v1/player/state')
} }
/**
* Add a sound to the play next queue
*/
async addToPlayNext(soundId: number): Promise<MessageResponse> {
return apiClient.post<MessageResponse>(`/api/v1/player/play-next/${soundId}`)
}
} }
export const playerService = new PlayerService() export const playerService = new PlayerService()