Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions apps/server/src/lib/spotify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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}`)
Expand Down
112 changes: 112 additions & 0 deletions apps/server/src/trpc/routers/spotify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import {
getNowPlaying,
getQueue,
getDevices,
skipNext,
skipPrevious,
pausePlayback,
resumePlayback,
setVolume,
setShuffle,
setRepeat,
seekToPosition,
} from '../../lib/spotify'
import { broadcastRefresh } from '../../lib/broadcaster'

Expand Down Expand Up @@ -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) })
}
}),
})
Loading