From d4051b504694d2fa67f42e4e01b22dc94b989897 Mon Sep 17 00:00:00 2001 From: Simon Malboeuf Date: Sun, 1 Mar 2026 14:41:31 -0500 Subject: [PATCH] feat: add playback controls (play/pause, skip, seek, volume, shuffle, repeat) - Switch GET /me/player (superset of currently-playing) to fetch shuffle, repeat, and volume state - Add 8 new Spotify API wrappers: skipNext/Previous, pause/resume, seek, setVolume, setShuffle, setRepeat - Wire up 8 new tRPC mutations that call broadcastRefresh after each action - Extend NowPlayingSchema with shuffleState, repeatState, volumePercent - Rewrite NowPlaying.tsx with clickable seek bar, play/pause, skip, shuffle toggle, repeat cycle, and volume slider Co-Authored-By: Claude Sonnet 4.6 --- apps/server/src/lib/spotify.ts | 54 ++++++- apps/server/src/trpc/routers/spotify.ts | 112 +++++++++++++ apps/web/src/components/NowPlaying.tsx | 151 +++++++++++++++++- .../src/routes/session/$sessionId/index.tsx | 4 + packages/validators/src/spotify.ts | 3 + 5 files changed, 314 insertions(+), 10 deletions(-) diff --git a/apps/server/src/lib/spotify.ts b/apps/server/src/lib/spotify.ts index 634931d..909dc8d 100644 --- a/apps/server/src/lib/spotify.ts +++ b/apps/server/src/lib/spotify.ts @@ -97,23 +97,73 @@ export async function queueTrack(sessionId: string, trackUri: string, deviceId?: } export async function getNowPlaying(sessionId: string) { - const res = await spotifyFetch(sessionId, '/me/player/currently-playing') + const res = await spotifyFetch(sessionId, '/me/player') if (res.status === 204 || res.status === 404) { - return { isPlaying: false, track: null, progressMs: null } + return { isPlaying: false, track: null, progressMs: null, shuffleState: false, repeatState: 'off' as const, volumePercent: null } } if (!res.ok) throw new Error(`Failed to get now playing: ${res.status}`) const data = await res.json() as { is_playing: boolean item: unknown progress_ms: number + shuffle_state: boolean + repeat_state: 'off' | 'track' | 'context' + device: { volume_percent: number | null } } return { isPlaying: data.is_playing, track: data.item, progressMs: data.progress_ms, + shuffleState: data.shuffle_state, + repeatState: data.repeat_state, + volumePercent: data.device?.volume_percent ?? null, } } +export async function skipNext(sessionId: string): Promise { + const res = await spotifyFetch(sessionId, '/me/player/next', { method: 'POST' }) + if (!res.ok && res.status !== 204) throw new Error(`Failed to skip next: ${res.status}`) +} + +export async function skipPrevious(sessionId: string): Promise { + const res = await spotifyFetch(sessionId, '/me/player/previous', { method: 'POST' }) + if (!res.ok && res.status !== 204) throw new Error(`Failed to skip previous: ${res.status}`) +} + +export async function pausePlayback(sessionId: string): Promise { + const res = await spotifyFetch(sessionId, '/me/player/pause', { method: 'PUT' }) + if (!res.ok && res.status !== 204) throw new Error(`Failed to pause: ${res.status}`) +} + +export async function resumePlayback(sessionId: string): Promise { + const res = await spotifyFetch(sessionId, '/me/player/play', { method: 'PUT' }) + if (!res.ok && res.status !== 204) throw new Error(`Failed to resume: ${res.status}`) +} + +export async function setVolume(sessionId: string, volumePercent: number): Promise { + const params = new URLSearchParams({ volume_percent: String(Math.round(volumePercent)) }) + const res = await spotifyFetch(sessionId, `/me/player/volume?${params}`, { method: 'PUT' }) + if (!res.ok && res.status !== 204) throw new Error(`Failed to set volume: ${res.status}`) +} + +export async function setShuffle(sessionId: string, state: boolean): Promise { + const params = new URLSearchParams({ state: String(state) }) + const res = await spotifyFetch(sessionId, `/me/player/shuffle?${params}`, { method: 'PUT' }) + if (!res.ok && res.status !== 204) throw new Error(`Failed to set shuffle: ${res.status}`) +} + +export async function setRepeat(sessionId: string, state: 'off' | 'track' | 'context'): Promise { + const params = new URLSearchParams({ state }) + const res = await spotifyFetch(sessionId, `/me/player/repeat?${params}`, { method: 'PUT' }) + if (!res.ok && res.status !== 204) throw new Error(`Failed to set repeat: ${res.status}`) +} + +export async function seekToPosition(sessionId: string, positionMs: number): Promise { + const params = new URLSearchParams({ position_ms: String(Math.round(positionMs)) }) + const res = await spotifyFetch(sessionId, `/me/player/seek?${params}`, { method: 'PUT' }) + if (!res.ok && res.status !== 204) throw new Error(`Failed to seek: ${res.status}`) +} + export async function getQueue(sessionId: string) { const res = await spotifyFetch(sessionId, '/me/player/queue') if (!res.ok) throw new Error(`Failed to get queue: ${res.status}`) diff --git a/apps/server/src/trpc/routers/spotify.ts b/apps/server/src/trpc/routers/spotify.ts index 09dd05b..5b483ec 100644 --- a/apps/server/src/trpc/routers/spotify.ts +++ b/apps/server/src/trpc/routers/spotify.ts @@ -8,6 +8,14 @@ import { getNowPlaying, getQueue, getDevices, + skipNext, + skipPrevious, + pausePlayback, + resumePlayback, + setVolume, + setShuffle, + setRepeat, + seekToPosition, } from '../../lib/spotify' import { broadcastRefresh } from '../../lib/broadcaster' @@ -82,4 +90,108 @@ export const spotifyRouter = router({ updateSession(input.sessionId, { deviceId: input.deviceId }) return { success: true } }), + + skipNext: publicProcedure + .input(z.object({ sessionId: z.string() })) + .mutation(async ({ input }) => { + requireSession(input.sessionId) + try { + await skipNext(input.sessionId) + void broadcastRefresh(input.sessionId) + return { success: true } + } catch (err) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: String(err) }) + } + }), + + skipPrevious: publicProcedure + .input(z.object({ sessionId: z.string() })) + .mutation(async ({ input }) => { + requireSession(input.sessionId) + try { + await skipPrevious(input.sessionId) + void broadcastRefresh(input.sessionId) + return { success: true } + } catch (err) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: String(err) }) + } + }), + + pause: publicProcedure + .input(z.object({ sessionId: z.string() })) + .mutation(async ({ input }) => { + requireSession(input.sessionId) + try { + await pausePlayback(input.sessionId) + void broadcastRefresh(input.sessionId) + return { success: true } + } catch (err) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: String(err) }) + } + }), + + resume: publicProcedure + .input(z.object({ sessionId: z.string() })) + .mutation(async ({ input }) => { + requireSession(input.sessionId) + try { + await resumePlayback(input.sessionId) + void broadcastRefresh(input.sessionId) + return { success: true } + } catch (err) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: String(err) }) + } + }), + + setVolume: publicProcedure + .input(z.object({ sessionId: z.string(), volumePercent: z.number().min(0).max(100) })) + .mutation(async ({ input }) => { + requireSession(input.sessionId) + try { + await setVolume(input.sessionId, input.volumePercent) + void broadcastRefresh(input.sessionId) + return { success: true } + } catch (err) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: String(err) }) + } + }), + + setShuffle: publicProcedure + .input(z.object({ sessionId: z.string(), state: z.boolean() })) + .mutation(async ({ input }) => { + requireSession(input.sessionId) + try { + await setShuffle(input.sessionId, input.state) + void broadcastRefresh(input.sessionId) + return { success: true } + } catch (err) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: String(err) }) + } + }), + + setRepeat: publicProcedure + .input(z.object({ sessionId: z.string(), state: z.enum(['off', 'track', 'context']) })) + .mutation(async ({ input }) => { + requireSession(input.sessionId) + try { + await setRepeat(input.sessionId, input.state) + void broadcastRefresh(input.sessionId) + return { success: true } + } catch (err) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: String(err) }) + } + }), + + seek: publicProcedure + .input(z.object({ sessionId: z.string(), positionMs: z.number().min(0) })) + .mutation(async ({ input }) => { + requireSession(input.sessionId) + try { + await seekToPosition(input.sessionId, input.positionMs) + void broadcastRefresh(input.sessionId) + return { success: true } + } catch (err) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: String(err) }) + } + }), }) diff --git a/apps/web/src/components/NowPlaying.tsx b/apps/web/src/components/NowPlaying.tsx index 4aa61d2..ae81c3b 100644 --- a/apps/web/src/components/NowPlaying.tsx +++ b/apps/web/src/components/NowPlaying.tsx @@ -1,22 +1,42 @@ -import { useState, useEffect } from 'react' -import { Music2 } from 'lucide-react' +import { useState, useEffect, useRef } from 'react' +import { Music2, SkipBack, SkipForward, Play, Pause, Shuffle, Repeat, Repeat1, Volume2 } from 'lucide-react' import { formatDuration } from '@/lib/utils' +import { trpc } from '@/lib/trpc' +import { cn } from '@/lib/utils' import type { Track } from '@queued/validators' interface NowPlayingProps { isPlaying: boolean track: Track | null progressMs: number | null + shuffleState: boolean + repeatState: 'off' | 'track' | 'context' + volumePercent: number | null + sessionId: string } -export function NowPlaying({ isPlaying, track, progressMs }: NowPlayingProps) { +export function NowPlaying({ + isPlaying, + track, + progressMs, + shuffleState, + repeatState, + volumePercent, + sessionId, +}: NowPlayingProps) { const [localProgressMs, setLocalProgressMs] = useState(progressMs ?? 0) + const [localVolume, setLocalVolume] = useState(volumePercent ?? 50) + const progressBarRef = useRef(null) // Sync with server-pushed value useEffect(() => { setLocalProgressMs(progressMs ?? 0) }, [progressMs]) + useEffect(() => { + if (volumePercent !== null) setLocalVolume(volumePercent) + }, [volumePercent]) + // Animate locally when playing useEffect(() => { if (!isPlaying || !track) return @@ -27,6 +47,46 @@ export function NowPlaying({ isPlaying, track, progressMs }: NowPlayingProps) { return () => clearInterval(id) }, [isPlaying, track?.uri, track?.duration_ms]) + const skipNextMutation = trpc.spotify.skipNext.useMutation() + const skipPreviousMutation = trpc.spotify.skipPrevious.useMutation() + const pauseMutation = trpc.spotify.pause.useMutation() + const resumeMutation = trpc.spotify.resume.useMutation() + const seekMutation = trpc.spotify.seek.useMutation() + const setVolumeMutation = trpc.spotify.setVolume.useMutation() + const setShuffleMutation = trpc.spotify.setShuffle.useMutation() + const setRepeatMutation = trpc.spotify.setRepeat.useMutation() + + const handleSeek = (e: React.MouseEvent) => { + if (!track || !progressBarRef.current) return + const rect = progressBarRef.current.getBoundingClientRect() + const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) + const positionMs = Math.round(pct * track.duration_ms) + setLocalProgressMs(positionMs) + seekMutation.mutate({ sessionId, positionMs }) + } + + const handleVolumeChange = (e: React.ChangeEvent) => { + setLocalVolume(Number(e.target.value)) + } + + const handleVolumeCommit = (e: React.PointerEvent) => { + const val = Number((e.target as HTMLInputElement).value) + setVolumeMutation.mutate({ sessionId, volumePercent: val }) + } + + const handlePlayPause = () => { + if (isPlaying) { + pauseMutation.mutate({ sessionId }) + } else { + resumeMutation.mutate({ sessionId }) + } + } + + const handleRepeat = () => { + const next = repeatState === 'off' ? 'context' : repeatState === 'context' ? 'track' : 'off' + setRepeatMutation.mutate({ sessionId, state: next }) + } + if (!track) { return (
@@ -46,8 +106,9 @@ export function NowPlaying({ isPlaying, track, progressMs }: NowPlayingProps) { const artists = track.artists.map((a) => a.name).join(', ') return ( -
-
+
+ {/* Track info */} +
{albumArt ? (

{track.name}

{artists}

-

{isPlaying ? 'Playing' : 'Paused'}

- {/* Progress bar */} + + {/* Progress bar (clickable) */}
-
+
{formatDuration(track.duration_ms)}
+ + {/* Main controls */} +
+ + + +
+ + {/* Shuffle / Repeat */} +
+ + +
+ + {/* Volume */} + {volumePercent !== null && ( +
+ + +
+ )}
) } diff --git a/apps/web/src/routes/session/$sessionId/index.tsx b/apps/web/src/routes/session/$sessionId/index.tsx index a644bfc..3e3389c 100644 --- a/apps/web/src/routes/session/$sessionId/index.tsx +++ b/apps/web/src/routes/session/$sessionId/index.tsx @@ -106,6 +106,10 @@ function SessionPage() { isPlaying={nowPlaying?.isPlaying ?? false} track={(nowPlaying?.track as Track) ?? null} progressMs={nowPlaying?.progressMs ?? null} + shuffleState={nowPlaying?.shuffleState ?? false} + repeatState={nowPlaying?.repeatState ?? 'off'} + volumePercent={nowPlaying?.volumePercent ?? null} + sessionId={sessionId} /> diff --git a/packages/validators/src/spotify.ts b/packages/validators/src/spotify.ts index bf3c874..40f0192 100644 --- a/packages/validators/src/spotify.ts +++ b/packages/validators/src/spotify.ts @@ -28,6 +28,9 @@ export const NowPlayingSchema = z.object({ isPlaying: z.boolean(), track: TrackSchema.nullable(), progressMs: z.number().nullable(), + shuffleState: z.boolean(), + repeatState: z.enum(['off', 'track', 'context']), + volumePercent: z.number().nullable(), }) export type NowPlaying = z.infer