Compare commits
3 Commits
4352e4c792
...
7a6288cc02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a6288cc02 | ||
|
|
0f8b96e73c | ||
|
|
9a2a9343d2 |
@@ -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)
|
||||||
|
|
||||||
|
|||||||
105
src/components/player/PlayNextQueue.tsx
Normal file
105
src/components/player/PlayNextQueue.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,14 +76,15 @@ 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 (
|
||||||
|
<ContextMenu key={sound.id}>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<div
|
||||||
key={sound.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid grid-cols-10 gap-2 items-center py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer text-xs',
|
'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',
|
currentIndex === originalIndex && 'bg-primary/10 text-primary',
|
||||||
@@ -125,6 +140,14 @@ export function Playlist({
|
|||||||
</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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user