diff --git a/app/api/spotify/playlists/[playlistId]/tracks/route.ts b/app/api/spotify/playlists/[playlistId]/tracks/route.ts index 66bb158cd8..c71cb7c8a4 100644 --- a/app/api/spotify/playlists/[playlistId]/tracks/route.ts +++ b/app/api/spotify/playlists/[playlistId]/tracks/route.ts @@ -99,11 +99,11 @@ async function getPlaylistTracks( id: track.id, name: track.name, artists: track.artists.map((artist) => artist.name).join(', '), - albumArt: - track.album.images && track.album.images.length > 0 - ? (track.album.images[0]?.url ?? null) - : null, - duration: track.duration_ms, + album: { + name: track.album.name, + images: track.album.images, + }, + duration_ms: track.duration_ms, uri: track.uri, } }) diff --git a/app/client/spotify-selection/page.tsx b/app/client/spotify-selection/page.tsx index 1b6a99fa8b..44988a6313 100644 --- a/app/client/spotify-selection/page.tsx +++ b/app/client/spotify-selection/page.tsx @@ -46,11 +46,11 @@ const SpotifySelectionPage = () => { executeSpotify('PLAY', { contextUri: uri }) } - const handleTrackPlay = (index: number) => { + const handleTrackPlay = (uri: string) => { if (selectedPlaylistId) { executeSpotify('PLAY', { contextUri: `spotify:playlist:${selectedPlaylistId}`, - offset: { position: index }, + offset: { uri }, }) } } diff --git a/components/Playlist/PlaylistTracksDisplay.tsx b/components/Playlist/PlaylistTracksDisplay.tsx index 6537894343..13e5bd6ef0 100644 --- a/components/Playlist/PlaylistTracksDisplay.tsx +++ b/components/Playlist/PlaylistTracksDisplay.tsx @@ -1,4 +1,3 @@ -// File: components/Playlist/PlaylistTracksDisplay.tsx 'use client' import { useCallback, useEffect, useState } from 'react' import { useWebSocket } from '@/context/WebSocketContext' @@ -7,28 +6,10 @@ import Box from '@mui/material/Box' import Typography from '@mui/material/Typography' import CircularProgress from '@mui/material/CircularProgress' import Alert from '@mui/material/Alert' -import IconButton from '@mui/material/IconButton' -import PlayArrowIcon from '@mui/icons-material/PlayArrow' -import PauseIcon from '@mui/icons-material/Pause' -import Table from '@mui/material/Table' -import TableBody from '@mui/material/TableBody' -import TableCell from '@mui/material/TableCell' -import TableContainer from '@mui/material/TableContainer' -import TableHead from '@mui/material/TableHead' -import TableRow from '@mui/material/TableRow' +import List from '@mui/material/List' import Paper from '@mui/material/Paper' -import Button from '@mui/material/Button' -import { formatDuration } from '@/lib/utils' -import Image from 'next/image' - -interface Track { - id: string - name: string - artists: string - albumArt: string | null - duration: number - uri: string -} +import { SpotifyPlaylistItem } from '@/types/core' +import { TrackListItem } from '../Spotify/TrackListItem' interface PlaylistTracksDisplayProps { playlistId: string @@ -37,46 +18,37 @@ interface PlaylistTracksDisplayProps { const PlaylistTracksDisplay = ({ playlistId }: PlaylistTracksDisplayProps) => { const { spotifyData } = useWebSocket() const { execute: executeSpotify } = useSpotifyCommand() - const [tracks, setTracks] = useState([]) + const [tracks, setTracks] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [offset, setOffset] = useState(0) - const [total, setTotal] = useState(0) - const limit = 20 - const fetchTracks = useCallback( - async (currentOffset: number) => { - try { - setLoading(true) - const response = await fetch( - `/api/spotify/playlists/${playlistId}/tracks?limit=${limit}&offset=${currentOffset}` - ) - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.message || 'Failed to fetch tracks') - } - const data = await response.json() - setTracks(data.tracks) - setTotal(data.total) - setLoading(false) - } catch (err) { - setError( - err instanceof Error ? err.message : 'An unknown error occurred' - ) - setLoading(false) + const fetchTracks = useCallback(async () => { + try { + setLoading(true) + const response = await fetch( + `/api/spotify/playlists/${playlistId}/tracks?limit=50` + ) + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.message || 'Failed to fetch tracks') } - }, - [playlistId] - ) + const data = await response.json() + setTracks(data.tracks) + setLoading(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'An unknown error occurred') + setLoading(false) + } + }, [playlistId]) useEffect(() => { - fetchTracks(offset) - }, [fetchTracks, offset]) + fetchTracks() + }, [fetchTracks]) - const handlePlayTrack = (playlistUri: string, position: number) => { + const handlePlayTrack = (playlistUri: string, uri: string) => { executeSpotify('PLAY', { contextUri: playlistUri, - offset: { position }, + offset: { uri }, }) } @@ -84,18 +56,6 @@ const PlaylistTracksDisplay = ({ playlistId }: PlaylistTracksDisplayProps) => { executeSpotify('PAUSE') } - const handleNextPage = () => { - if (offset + limit < total) { - setOffset(offset + limit) - } - } - - const handlePreviousPage = () => { - if (offset - limit >= 0) { - setOffset(offset - limit) - } - } - if (loading) { return ( @@ -122,79 +82,29 @@ const PlaylistTracksDisplay = ({ playlistId }: PlaylistTracksDisplayProps) => { return ( - - - - - Play - Title - Artist - Duration - - - - {tracks.map((track, index) => { - const isPlaying = - spotifyData.playback.is_playing && - spotifyData.playback.track.id === track.id - const playlistUri = `spotify:playlist:${playlistId}` - return ( - - - - isPlaying - ? handlePause() - : handlePlayTrack(playlistUri, offset + index) - } - aria-label={isPlaying ? 'Pause' : 'Play'} - > - {isPlaying ? : } - - - - - {track.albumArt && ( - - )} - {track.name} - - - {track.artists} - - {formatDuration(track.duration, { - unit: 'milliseconds', - format: 'MM:SS', - })} - - - ) - })} - -
-
- - - - Showing {offset + 1}-{Math.min(offset + limit, total)} of {total} - - - + + + {tracks.map((track) => { + const isPlaying = + spotifyData.playback.is_playing && + spotifyData.playback.track.id === track.id + const playlistUri = `spotify:playlist:${playlistId}` + + return ( + + isPlaying + ? handlePause() + : handlePlayTrack(playlistUri, track.uri) + } + /> + ) + })} + +
) } diff --git a/components/Spotify/PlaylistDetails.tsx b/components/Spotify/PlaylistDetails.tsx index 43e642f1cb..23d7b0338a 100644 --- a/components/Spotify/PlaylistDetails.tsx +++ b/components/Spotify/PlaylistDetails.tsx @@ -1,20 +1,16 @@ -// components/Spotify/PlaylistDetails.tsx -import MusicNote from '@mui/icons-material/MusicNote' import Box from '@mui/material/Box' import CircularProgress from '@mui/material/CircularProgress' import List from '@mui/material/List' -import ListItem from '@mui/material/ListItem' -import ListItemButton from '@mui/material/ListItemButton' -import ListItemText from '@mui/material/ListItemText' import Paper from '@mui/material/Paper' import Typography from '@mui/material/Typography' import { useCallback, useEffect, useState } from 'react' import InfiniteScroll from 'react-infinite-scroll-component' import { SpotifyPlaylistItem as Track } from '../../types/core' +import { TrackListItem } from './TrackListItem' interface PlaylistDetailsProps { playlistId: string - onTrackPlay: (index: number) => void + onTrackPlay: (uri: string) => void } const PlaylistDetails: React.FC = ({ @@ -101,19 +97,13 @@ const PlaylistDetails: React.FC = ({ } scrollableTarget="scrollable-playlist" > - + {tracks.map((track, index) => ( - - onTrackPlay(index)}> - - a.name).join(', ') : track.artists} - ${track.album?.name || 'Unknown Album'}`} - /> - - + onTrackPlay(track.uri)} + /> ))} diff --git a/components/Spotify/TrackListItem.tsx b/components/Spotify/TrackListItem.tsx new file mode 100644 index 0000000000..3c4df9eed1 --- /dev/null +++ b/components/Spotify/TrackListItem.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import Avatar from '@mui/material/Avatar' +import ListItem from '@mui/material/ListItem' +import ListItemAvatar from '@mui/material/ListItemAvatar' +import ListItemButton from '@mui/material/ListItemButton' +import ListItemText from '@mui/material/ListItemText' +import Typography from '@mui/material/Typography' +import MusicNoteIcon from '@mui/icons-material/MusicNote' +import { formatDuration } from '@/lib/utils' +import { getArtistNames } from '@/lib/spotify' +import { SpotifyPlaylistItem } from '@/types/core' + +interface TrackListItemProps { + track: SpotifyPlaylistItem + onClick: () => void + secondaryAction?: React.ReactNode + isSelected?: boolean +} + +export const TrackListItem: React.FC = ({ + track, + onClick, + secondaryAction, + isSelected = false, +}) => { + const albumName = track.album?.name || 'Single' + const albumThumbnail = track.album?.images?.at(-1)?.url || '' + + return ( + + {formatDuration(track.duration_ms, { + unit: 'milliseconds', + format: 'MM:SS', + })} + + )) + } + > + + + + + + + + + + ) +} diff --git a/hooks/useSpotifyCommand.ts b/hooks/useSpotifyCommand.ts index 2b62612edf..85b0a51173 100644 --- a/hooks/useSpotifyCommand.ts +++ b/hooks/useSpotifyCommand.ts @@ -16,7 +16,7 @@ type SpotifyPlayPayload = SpotifyBasePayload & { playlistUri?: string contextUri?: string uri?: string - offset?: { position: number } + offset?: { position?: number; uri?: string } } type SpotifyVolumePayload = SpotifyBasePayload & { diff --git a/lib/spotify.ts b/lib/spotify.ts index 8b4930c9ad..821aa7830d 100644 --- a/lib/spotify.ts +++ b/lib/spotify.ts @@ -63,3 +63,10 @@ export async function refreshSpotifyToken(refreshToken: string) { return response.json() } + +export const getArtistNames = ( + artists: { name: string }[] | string | undefined +) => { + if (Array.isArray(artists)) return artists.map((a) => a.name).join(', ') + return artists?.trim() || 'Unknown Artist' +} diff --git a/tests/unit/app/api/spotify/playlists/[playlistId]/tracks/route.test.ts b/tests/unit/app/api/spotify/playlists/[playlistId]/tracks/route.test.ts index 5d3e83706b..494b17b64f 100644 --- a/tests/unit/app/api/spotify/playlists/[playlistId]/tracks/route.test.ts +++ b/tests/unit/app/api/spotify/playlists/[playlistId]/tracks/route.test.ts @@ -19,7 +19,10 @@ describe('GET /api/spotify/playlists/[playlistId]/tracks', () => { id: 't1', name: 'Track 1', artists: [{ name: 'Artist 1' }], - album: { images: [{ url: 'http://example.com/art1.jpg' }] }, + album: { + name: 'Album 1', + images: [{ url: 'http://example.com/art1.jpg' }], + }, duration_ms: 180000, uri: 'spotify:track:t1', type: 'track', @@ -51,8 +54,11 @@ describe('GET /api/spotify/playlists/[playlistId]/tracks', () => { id: 't1', name: 'Track 1', artists: 'Artist 1', - albumArt: 'http://example.com/art1.jpg', - duration: 180000, + album: { + images: [{ url: 'http://example.com/art1.jpg' }], + name: 'Album 1', + }, + duration_ms: 180000, uri: 'spotify:track:t1', }) }) diff --git a/tests/unit/components/Playlist/PlaylistTracksDisplay.test.tsx b/tests/unit/components/Playlist/PlaylistTracksDisplay.test.tsx index ed5bf734c4..d5625cdce6 100644 --- a/tests/unit/components/Playlist/PlaylistTracksDisplay.test.tsx +++ b/tests/unit/components/Playlist/PlaylistTracksDisplay.test.tsx @@ -102,9 +102,9 @@ describe('PlaylistTracksDisplay', () => { id: 't1', name: 'Track 1', artists: 'Artist 1', - duration: 180000, + duration_ms: 180000, uri: 'spotify:track:t1', - albumArt: null, + album: { name: 'Album 1', images: [] }, }, ], total: 1, @@ -122,14 +122,14 @@ describe('PlaylistTracksDisplay', () => { ) - const playButton = await screen.findByRole('button', { name: /play/i }) - fireEvent.click(playButton) + const trackButton = await screen.findByText('Track 1') + fireEvent.click(trackButton) expect(executeMock).toHaveBeenCalledWith( 'PLAY', expect.objectContaining({ contextUri: 'spotify:playlist:123', - offset: { position: 0 }, + offset: { uri: 'spotify:track:t1' }, }) ) }) @@ -141,9 +141,9 @@ describe('PlaylistTracksDisplay', () => { id: 't1', name: 'Track 1', artists: 'Artist 1', - duration: 180000, + duration_ms: 180000, uri: 'spotify:track:t1', - albumArt: null, + album: { name: 'Album 1', images: [] }, }, ], total: 1, @@ -173,8 +173,8 @@ describe('PlaylistTracksDisplay', () => { ) - const pauseButton = await screen.findByRole('button', { name: /pause/i }) - fireEvent.click(pauseButton) + const trackButton = await screen.findByText('Track 1') + fireEvent.click(trackButton) expect(executeMock).toHaveBeenCalledWith('PAUSE') }) diff --git a/tests/unit/components/Spotify/PlaylistDetails.test.tsx b/tests/unit/components/Spotify/PlaylistDetails.test.tsx index df53ce9777..22ef9199c8 100644 --- a/tests/unit/components/Spotify/PlaylistDetails.test.tsx +++ b/tests/unit/components/Spotify/PlaylistDetails.test.tsx @@ -9,30 +9,23 @@ const mockFetch = jest.fn() global.fetch = mockFetch describe('PlaylistDetails', () => { - const mockTracksAsArray: Track[] = [ + const mockTracksAsString: Track[] = [ { id: '1', name: 'Track 1', uri: 'spotify:track:1', - artists: [{ name: 'Artist 1' }], - album: { name: 'Album 1' }, - imageUrl: '', + artists: 'Artist 1', + album: { name: 'Album 1', images: [] }, }, { id: '2', name: 'Track 2', uri: 'spotify:track:2', - artists: [{ name: 'Artist 2' }], - album: { name: 'Album 2' }, - imageUrl: '', + artists: 'Artist 2', + album: { name: 'Album 2', images: [] }, }, ] - const mockTracksAsString = mockTracksAsArray.map((track) => ({ - ...track, - artists: track.artists.map((a) => a.name).join(', '), - })) as unknown as Track[] - beforeEach(() => { jest.clearAllMocks() }) @@ -45,7 +38,7 @@ describe('PlaylistDetails', () => { () => resolve({ ok: true, - json: () => Promise.resolve({ tracks: mockTracksAsArray }), + json: () => Promise.resolve({ tracks: mockTracksAsString }), }), 100 ) @@ -59,35 +52,6 @@ describe('PlaylistDetails', () => { await waitFor(() => expect(screen.queryByRole('progressbar')).toBeNull()) }) - it('displays the track list and handles play clicks when artists is an array', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ tracks: mockTracksAsArray }), - }) - const onTrackPlay = jest.fn() - - render( - - ) - - await waitFor(() => { - expect(screen.getByText('Track 1')).toBeInTheDocument() - }) - expect( - screen.getByText('Artist 1 - Album 1', { exact: false }) - ).toBeInTheDocument() - expect(screen.getByText('Track 2')).toBeInTheDocument() - expect( - screen.getByText('Artist 2 - Album 2', { exact: false }) - ).toBeInTheDocument() - - fireEvent.click(screen.getByText('Track 1')) - expect(onTrackPlay).toHaveBeenCalledWith(0) - }) - it('displays the track list and handles play clicks when artists is a string', async () => { mockFetch.mockResolvedValue({ ok: true, @@ -106,15 +70,15 @@ describe('PlaylistDetails', () => { expect(screen.getByText('Track 1')).toBeInTheDocument() }) expect( - screen.getByText('Artist 1 - Album 1', { exact: false }) + screen.getByText('Artist 1 • Album 1', { exact: false }) ).toBeInTheDocument() expect(screen.getByText('Track 2')).toBeInTheDocument() expect( - screen.getByText('Artist 2 - Album 2', { exact: false }) + screen.getByText('Artist 2 • Album 2', { exact: false }) ).toBeInTheDocument() fireEvent.click(screen.getByText('Track 1')) - expect(onTrackPlay).toHaveBeenCalledWith(0) + expect(onTrackPlay).toHaveBeenCalledWith('spotify:track:1') }) it('displays an error message when the API call fails', async () => { diff --git a/types/core.ts b/types/core.ts index 0b6af0d311..4fe8789d1e 100644 --- a/types/core.ts +++ b/types/core.ts @@ -218,9 +218,12 @@ export interface SpotifyPlaylistItem { id: string name: string uri: string - artists?: { name: string }[] | string - album?: { name: string } - imageUrl?: string | null + artists?: string + album?: { + name: string + images: { url: string; height: number; width: number }[] + } + duration_ms?: number } /** @@ -232,7 +235,7 @@ export interface SpotifyCommandParameters { playlistUri?: string contextUri?: string uri?: string - offset?: { position: number } + offset?: { position?: number; uri?: string } } /** diff --git a/types/websocket.ts b/types/websocket.ts index 2fd543a51a..87a97ec6a8 100644 --- a/types/websocket.ts +++ b/types/websocket.ts @@ -11,6 +11,7 @@ import type { SpotifyPlaybackState as SpotifyData, // Single source of truth for playback state TimerMode, SpotifyCommand, + SpotifyCommandParameters, } from './core' // --- WebSocket Connection & Augmentation --- @@ -153,17 +154,9 @@ export interface TimerConfigMessage { restDuration: number } -export interface SpotifyCommandMessage { +export interface SpotifyCommandMessage extends SpotifyCommandParameters { type: 'SPOTIFY_COMMAND' command: SpotifyCommand - deviceId?: string - volume?: number - playlistUri?: string // Added to support your incoming message - contextUri?: string // Generic support for albums/artists - uri?: string - offset?: { - position: number - } } interface GetStateMessage { @@ -255,7 +248,8 @@ const SpotifyCommandMessageSchema = z.object({ uri: z.string().optional(), offset: z .object({ - position: z.number(), + position: z.number().optional(), + uri: z.string().optional(), }) .optional(), }) diff --git a/utils/socketManager.ts b/utils/socketManager.ts index 33aa5b7b47..b38868dcf1 100644 --- a/utils/socketManager.ts +++ b/utils/socketManager.ts @@ -426,26 +426,9 @@ const handleIncomingMessage = ( ) const spotifyService = services.spotifyService - const spotifyCommandParams: { - deviceId?: string - volume?: number - playlistUri?: string - contextUri?: string - uri?: string - offset?: { position: number } - } = {} - if (commandMsg.deviceId) - spotifyCommandParams.deviceId = commandMsg.deviceId - if (commandMsg.volume !== undefined) - spotifyCommandParams.volume = commandMsg.volume - if (commandMsg.playlistUri) - spotifyCommandParams.playlistUri = commandMsg.playlistUri - if (commandMsg.contextUri) - spotifyCommandParams.contextUri = commandMsg.contextUri - if (commandMsg.uri) spotifyCommandParams.uri = commandMsg.uri - if (commandMsg.offset) spotifyCommandParams.offset = commandMsg.offset - - spotifyService.handleCommand(commandMsg.command, spotifyCommandParams) + const { type: _type, command, ...spotifyCommandParams } = commandMsg + + spotifyService.handleCommand(command, spotifyCommandParams) break } default: {