Public: anyone can read this; the crew can change it.
Spotify Player #8
Check · Review · Land · Live — It conflicts with its base.
feat/spotify-on-vercel → main 8 commits
A guest
This pull comes from a guest: its preview runs without the app's variables or a forge token until a person who can write here approves it.
This pull cannot land as it is, whatever its review says, because it changes guarded paths: .github/workflows/azure-static-web-apps-lemon-glacier-01d38e310.yml (release machinery), package.json (release machinery).
22 files
changed +1678
−101
.github/workflows/azure-static-web-apps-lemon-glacier-01d38e310.yml +3
| @@ -18,6 +18,9 @@ jobs: | ||
| 18 | 18 | env: |
| 19 | 19 | NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ vars.NEXT_PUBLIC_SUPABASE_ANON_KEY }} |
| 20 | 20 | NEXT_PUBLIC_SUPABASE_URL: ${{ vars.NEXT_PUBLIC_SUPABSE_URL }} |
| 21 | NEXT_PUBLIC_SPOTIFY_CLIENT_ID: $ {{ vars.NEXT_PUBLIC_SPOTIFY_CLIENT_ID }} | |
| 22 | SPOTIFY_CLIENT_SECRET: $ {{ vars.SPOTIFY_CLIENT_SECRET }} | |
| 23 | SPOTIFY_REFRESH_TOKEN: $ {{ vars.SPOTIFY_REFRESH_TOKEN }} | |
| 21 | 24 | steps: |
| 22 | 25 | - uses: actions/checkout@v3 |
| 23 | 26 | with: |
bun.lockb added
Binary file not shown.
next.config.mjs +22 −2
| @@ -1,4 +1,24 @@ | ||
| 1 | 1 | /** @type {import('next').NextConfig} */ |
| 2 | const nextConfig = {}; | |
| 2 | const nextConfig = { | |
| 3 | images: { | |
| 4 | remotePatterns: [ | |
| 5 | { | |
| 6 | protocol: 'https', | |
| 7 | hostname: 'i.scdn.co', | |
| 8 | pathname: '/**', | |
| 9 | }, | |
| 10 | { | |
| 11 | protocol: 'https', | |
| 12 | hostname: 'p.scdn.co', | |
| 13 | pathname: '/**', | |
| 14 | }, | |
| 15 | { | |
| 16 | protocol: 'https', | |
| 17 | hostname: 'mosaic.scdn.co', | |
| 18 | pathname: '/**', | |
| 19 | }, | |
| 20 | ], | |
| 21 | }, | |
| 22 | }; | |
| 3 | 23 | |
| 4 | export default nextConfig; | |
| 24 | export default nextConfig;↵ |
package.json +2 −1
| @@ -39,7 +39,8 @@ | ||
| 39 | 39 | "remark-gfm": "^4.0.0", |
| 40 | 40 | "tailwind-merge": "^2.5.2", |
| 41 | 41 | "tailwindcss-animate": "^1.0.7", |
| 42 | "three": "^0.168.0" | |
| 42 | "three": "^0.168.0", | |
| 43 | "zustand": "^5.0.2" | |
| 43 | 44 | }, |
| 44 | 45 | "devDependencies": { |
| 45 | 46 | "@types/node": "^20", |
src/app/api/spotify/token/route.ts added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | import { NextResponse } from 'next/server'; | |
| 2 | ||
| 3 | const TOKEN_ENDPOINT = 'https://accounts.spotify.com/api/token'; | |
| 4 | ||
| 5 | // Create an error response helper to ensure consistent error handling | |
| 6 | function createErrorResponse(message: string, status: number = 500) { | |
| 7 | // Log error details for debugging but send a sanitized response to the client | |
| 8 | console.error('Spotify token error:', { message, status, timestamp: new Date().toISOString() }); | |
| 9 | return NextResponse.json({ error: message }, { status }); | |
| 10 | } | |
| 11 | ||
| 12 | export async function GET() { | |
| 13 | try { | |
| 14 | // Validate environment variables at request time | |
| 15 | const clientId = process.env.NEXT_PUBLIC_SPOTIFY_CLIENT_ID; | |
| 16 | const clientSecret = process.env.SPOTIFY_CLIENT_SECRET; | |
| 17 | const refreshToken = process.env.SPOTIFY_REFRESH_TOKEN; | |
| 18 | ||
| 19 | // Check for missing configuration | |
| 20 | if (!clientId || !clientSecret || !refreshToken) { | |
| 21 | return createErrorResponse( | |
| 22 | 'Spotify configuration is incomplete. Please check your environment variables.', | |
| 23 | 500 | |
| 24 | ); | |
| 25 | } | |
| 26 | ||
| 27 | // Create authorization header | |
| 28 | const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); | |
| 29 | ||
| 30 | // Make the token refresh request | |
| 31 | const response = await fetch(TOKEN_ENDPOINT, { | |
| 32 | method: 'POST', | |
| 33 | headers: { | |
| 34 | Authorization: `Basic ${basic}`, | |
| 35 | 'Content-Type': 'application/x-www-form-urlencoded', | |
| 36 | }, | |
| 37 | body: new URLSearchParams({ | |
| 38 | grant_type: 'refresh_token', | |
| 39 | refresh_token: refreshToken, | |
| 40 | }).toString(), | |
| 41 | // Ensure we're not getting cached responses | |
| 42 | cache: 'no-store', | |
| 43 | }); | |
| 44 | ||
| 45 | // If Spotify returns an error, parse it | |
| 46 | if (!response.ok) { | |
| 47 | const errorData = await response.text(); | |
| 48 | return createErrorResponse( | |
| 49 | 'Failed to refresh Spotify token', | |
| 50 | response.status | |
| 51 | ); | |
| 52 | } | |
| 53 | ||
| 54 | const data = await response.json(); | |
| 55 | ||
| 56 | // Validate the response has the required fields | |
| 57 | if (!data.access_token) { | |
| 58 | return createErrorResponse('Invalid token response from Spotify'); | |
| 59 | } | |
| 60 | ||
| 61 | // Log success without exposing sensitive data | |
| 62 | console.log('Token refresh successful', { | |
| 63 | timestamp: new Date().toISOString(), | |
| 64 | expiresIn: data.expires_in, | |
| 65 | }); | |
| 66 | ||
| 67 | return NextResponse.json(data); | |
| 68 | } catch (error) { | |
| 69 | // Handle unexpected errors | |
| 70 | return createErrorResponse( | |
| 71 | 'An unexpected error occurred while refreshing the token' | |
| 72 | ); | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | // Ensure this route is never cached | |
| 77 | export const dynamic = 'force-dynamic'; | |
| 78 | export const revalidate = 0;↵ |
src/app/layout.tsx +60 −54
| @@ -6,66 +6,72 @@ import { FloatingNavigation } from "@/components/FloatingNavigation"; | ||
| 6 | 6 | import { InteractiveBackground } from "@/components/InteractiveBackground"; |
| 7 | 7 | import { Toaster } from '@/components/ui/toaster' |
| 8 | 8 | import { Trevato } from '@/components/Trevato' |
| 9 | import { SpotifyProvider } from "@/components/providers/spotify-provider"; | |
| 9 | 10 | |
| 10 | 11 | const inter = Inter({ subsets: ["latin"] }); |
| 11 | 12 | |
| 12 | 13 | export const metadata: Metadata = { |
| 13 | title: { | |
| 14 | default: "trevato.dev", | |
| 15 | template: "%s | trevato.dev", | |
| 16 | }, | |
| 17 | description: "Builder of things", | |
| 18 | openGraph: { | |
| 19 | title: "trevato.dev", | |
| 20 | description: "Builder of things.", | |
| 21 | url: "https://trevato.dev", | |
| 22 | siteName: "trevato.dev", | |
| 23 | images: [ | |
| 24 | { | |
| 25 | url: "https://trevato.dev/og.gif", | |
| 26 | width: 1024, | |
| 27 | height: 512, | |
| 28 | }, | |
| 29 | ], | |
| 30 | locale: "en-US", | |
| 31 | type: "website", | |
| 32 | }, | |
| 33 | robots: { | |
| 34 | index: true, | |
| 35 | follow: true, | |
| 36 | googleBot: { | |
| 37 | index: true, | |
| 38 | follow: true, | |
| 39 | "max-video-preview": -1, | |
| 40 | "max-image-preview": "large", | |
| 41 | "max-snippet": -1, | |
| 42 | }, | |
| 43 | }, | |
| 44 | twitter: { | |
| 45 | title: "trevordobbertin", | |
| 46 | card: "summary_large_image", | |
| 47 | }, | |
| 14 | title: { | |
| 15 | default: "trevato.dev", | |
| 16 | template: "%s | trevato.dev", | |
| 17 | }, | |
| 18 | description: "Builder of things", | |
| 19 | openGraph: { | |
| 20 | title: "trevato.dev", | |
| 21 | description: "Builder of things.", | |
| 22 | url: "https://trevato.dev", | |
| 23 | siteName: "trevato.dev", | |
| 24 | images: [ | |
| 25 | { | |
| 26 | url: "https://trevato.dev/og.gif", | |
| 27 | width: 1024, | |
| 28 | height: 512, | |
| 29 | }, | |
| 30 | ], | |
| 31 | locale: "en-US", | |
| 32 | type: "website", | |
| 33 | }, | |
| 34 | robots: { | |
| 35 | index: true, | |
| 36 | follow: true, | |
| 37 | googleBot: { | |
| 38 | index: true, | |
| 39 | follow: true, | |
| 40 | "max-video-preview": -1, | |
| 41 | "max-image-preview": "large", | |
| 42 | "max-snippet": -1, | |
| 43 | }, | |
| 44 | }, | |
| 45 | twitter: { | |
| 46 | title: "trevordobbertin", | |
| 47 | card: "summary_large_image", | |
| 48 | }, | |
| 48 | 49 | }; |
| 49 | 50 | |
| 50 | 51 | export default function RootLayout({ |
| 51 | children, | |
| 52 | children, | |
| 52 | 53 | }: Readonly<{ |
| 53 | children: React.ReactNode; | |
| 54 | children: React.ReactNode; | |
| 54 | 55 | }>) { |
| 55 | return ( | |
| 56 | <html lang="en"> | |
| 57 | <head> | |
| 58 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> | |
| 59 | </head> | |
| 60 | <body className={`${inter.className} bg-black text-white`}> | |
| 61 | <SpaceBackground> | |
| 62 | <InteractiveBackground /> | |
| 63 | <FloatingNavigation /> | |
| 64 | <main className="min-h-screen p-4 md:p-8">{children}</main> | |
| 65 | <Trevato /> | |
| 66 | </SpaceBackground> | |
| 67 | <Toaster /> | |
| 68 | </body> | |
| 69 | </html> | |
| 70 | ); | |
| 71 | } | |
| 56 | return ( | |
| 57 | <html lang="en"> | |
| 58 | <head> | |
| 59 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> | |
| 60 | </head> | |
| 61 | <body className={`${inter.className} bg-black text-white`}> | |
| 62 | <SpotifyProvider | |
| 63 | clientId={process.env.NEXT_PUBLIC_SPOTIFY_CLIENT_ID!} | |
| 64 | playlistUri="spotify:playlist:0uiK303E4UEFiNdYjwpwwo" | |
| 65 | > | |
| 66 | <SpaceBackground> | |
| 67 | <InteractiveBackground /> | |
| 68 | <FloatingNavigation /> | |
| 69 | <main className="min-h-screen p-4 md:p-8">{children}</main> | |
| 70 | <Trevato /> | |
| 71 | </SpaceBackground> | |
| 72 | <Toaster /> | |
| 73 | </SpotifyProvider> | |
| 74 | </body> | |
| 75 | </html> | |
| 76 | ); | |
| 77 | }↵ |
src/components/error-boundary.tsx added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | 'use client'; | |
| 2 | ||
| 3 | import React, { Component, ErrorInfo, ReactNode } from 'react'; | |
| 4 | ||
| 5 | interface Props { | |
| 6 | children: ReactNode; | |
| 7 | fallback?: ReactNode; | |
| 8 | } | |
| 9 | ||
| 10 | interface State { | |
| 11 | hasError: boolean; | |
| 12 | } | |
| 13 | ||
| 14 | export class ErrorBoundary extends Component<Props, State> { | |
| 15 | public state: State = { | |
| 16 | hasError: false | |
| 17 | }; | |
| 18 | ||
| 19 | public static getDerivedStateFromError(_: Error): State { | |
| 20 | return { hasError: true }; | |
| 21 | } | |
| 22 | ||
| 23 | public componentDidCatch(error: Error, errorInfo: ErrorInfo) { | |
| 24 | console.error('Uncaught error:', error, errorInfo); | |
| 25 | } | |
| 26 | ||
| 27 | public render() { | |
| 28 | if (this.state.hasError) { | |
| 29 | return this.props.fallback || null; | |
| 30 | } | |
| 31 | ||
| 32 | return this.props.children; | |
| 33 | } | |
| 34 | } |
src/components/providers/spotify-provider.tsx added +134
| @@ -0,0 +1,134 @@ | ||
| 1 | 'use client'; | |
| 2 | ||
| 3 | import { useEffect, useState } from 'react'; | |
| 4 | import { useSpotifyStore } from '@/hooks/use-spotify'; | |
| 5 | import { useSpotifyAuth } from '@/hooks/use-spotify-auth'; | |
| 6 | import { SpotifyPlayer, SpotifyPlayerOptions } from '@/types/spotify'; | |
| 7 | ||
| 8 | interface SpotifyProviderProps { | |
| 9 | clientId: string; | |
| 10 | playlistUri: string; | |
| 11 | children: React.ReactNode; | |
| 12 | } | |
| 13 | ||
| 14 | export function SpotifyProvider({ | |
| 15 | clientId, | |
| 16 | playlistUri, | |
| 17 | children, | |
| 18 | }: SpotifyProviderProps) { | |
| 19 | const { setPlayer, setDeviceId, setCurrentTrack, setIsPaused } = useSpotifyStore(); | |
| 20 | const { accessToken, refreshToken } = useSpotifyAuth(); | |
| 21 | const [isInitializing, setIsInitializing] = useState(true); | |
| 22 | ||
| 23 | useEffect(() => { | |
| 24 | let playerInstance: SpotifyPlayer | null = null; | |
| 25 | ||
| 26 | const initializePlayer = async () => { | |
| 27 | // First, check if we already have the SDK script | |
| 28 | if (!document.querySelector('[src="https://sdk.scdn.co/spotify-player.js"]')) { | |
| 29 | const script = document.createElement("script"); | |
| 30 | script.src = "https://sdk.scdn.co/spotify-player.js"; | |
| 31 | script.async = true; | |
| 32 | document.body.appendChild(script); | |
| 33 | } | |
| 34 | ||
| 35 | // Wait for the SDK to be ready | |
| 36 | return new Promise<void>((resolve) => { | |
| 37 | window.onSpotifyWebPlaybackSDKReady = () => { | |
| 38 | console.log('Spotify SDK Ready - Creating player instance'); | |
| 39 | ||
| 40 | const playerOptions: SpotifyPlayerOptions = { | |
| 41 | name: 'Trevato.dev Web Player', | |
| 42 | getOAuthToken: (callback: (token: string) => void) => { | |
| 43 | // Try to refresh the token before returning it | |
| 44 | refreshToken().then(freshToken => { | |
| 45 | callback(freshToken || accessToken); | |
| 46 | }); | |
| 47 | }, | |
| 48 | volume: 0.5 | |
| 49 | }; | |
| 50 | ||
| 51 | const player = new window.Spotify.Player(playerOptions); | |
| 52 | ||
| 53 | // Error handling with token refresh logic | |
| 54 | player.addListener('authentication_error', async ({ message }) => { | |
| 55 | console.log('Authentication error, attempting token refresh...'); | |
| 56 | const newToken = await refreshToken(); | |
| 57 | if (newToken) { | |
| 58 | console.log('Token refreshed, reconnecting player...'); | |
| 59 | player.disconnect(); | |
| 60 | await player.connect(); | |
| 61 | } | |
| 62 | }); | |
| 63 | ||
| 64 | player.addListener('ready', async ({ device_id }) => { | |
| 65 | console.log('Player ready with device ID:', device_id); | |
| 66 | setDeviceId(device_id); | |
| 67 | setIsInitializing(false); | |
| 68 | ||
| 69 | try { | |
| 70 | await fetch('https://api.spotify.com/v1/me/player', { | |
| 71 | method: 'PUT', | |
| 72 | headers: { | |
| 73 | 'Content-Type': 'application/json', | |
| 74 | 'Authorization': `Bearer ${accessToken}` | |
| 75 | }, | |
| 76 | body: JSON.stringify({ | |
| 77 | device_ids: [device_id], | |
| 78 | play: false | |
| 79 | }) | |
| 80 | }); | |
| 81 | console.log('Playback transferred successfully'); | |
| 82 | } catch (error) { | |
| 83 | console.error('Error transferring playback:', error); | |
| 84 | } | |
| 85 | }); | |
| 86 | ||
| 87 | player.addListener('not_ready', ({ device_id }) => { | |
| 88 | console.log('Device ID has gone offline:', device_id); | |
| 89 | setDeviceId(null); | |
| 90 | }); | |
| 91 | ||
| 92 | player.addListener('player_state_changed', (state) => { | |
| 93 | if (!state) return; | |
| 94 | setCurrentTrack(state.track_window.current_track); | |
| 95 | setIsPaused(state.paused); | |
| 96 | console.log('Player state updated:', { | |
| 97 | track: state.track_window.current_track.name, | |
| 98 | isPaused: state.paused | |
| 99 | }); | |
| 100 | }); | |
| 101 | ||
| 102 | playerInstance = player; | |
| 103 | setPlayer(player); | |
| 104 | ||
| 105 | player.connect().then(success => { | |
| 106 | if (success) { | |
| 107 | console.log('Player connected successfully'); | |
| 108 | } else { | |
| 109 | console.log('Player failed to connect'); | |
| 110 | } | |
| 111 | resolve(); | |
| 112 | }); | |
| 113 | }; | |
| 114 | }); | |
| 115 | }; | |
| 116 | ||
| 117 | if (accessToken) { | |
| 118 | initializePlayer().catch(error => { | |
| 119 | console.error('Error initializing Spotify player:', error); | |
| 120 | setIsInitializing(false); | |
| 121 | }); | |
| 122 | } | |
| 123 | ||
| 124 | // Cleanup function | |
| 125 | return () => { | |
| 126 | if (playerInstance) { | |
| 127 | console.log('Disconnecting player'); | |
| 128 | playerInstance.disconnect(); | |
| 129 | } | |
| 130 | }; | |
| 131 | }, [accessToken, refreshToken, setCurrentTrack, setDeviceId, setIsPaused, setPlayer]); | |
| 132 | ||
| 133 | return children; | |
| 134 | }↵ |
src/components/spotify/expanded-view.tsx added +148
| @@ -0,0 +1,148 @@ | ||
| 1 | "use client"; | |
| 2 | ||
| 3 | import { useSpotifyStore } from '@/hooks/use-spotify'; | |
| 4 | import { motion } from 'framer-motion'; | |
| 5 | import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music2 } from 'lucide-react'; | |
| 6 | import { ProgressBar } from './progress-bar'; | |
| 7 | import { QueueView } from './queue-view'; | |
| 8 | import Image from 'next/image'; | |
| 9 | ||
| 10 | interface ExpandedSpotifyViewProps { | |
| 11 | className?: string; | |
| 12 | } | |
| 13 | ||
| 14 | export function ExpandedSpotifyView({ className }: ExpandedSpotifyViewProps) { | |
| 15 | const { player, currentTrack, isPaused } = useSpotifyStore(); | |
| 16 | ||
| 17 | const container = { | |
| 18 | hidden: { opacity: 0 }, | |
| 19 | show: { | |
| 20 | opacity: 1, | |
| 21 | transition: { | |
| 22 | staggerChildren: 0.1, | |
| 23 | }, | |
| 24 | }, | |
| 25 | }; | |
| 26 | ||
| 27 | const item = { | |
| 28 | hidden: { opacity: 0, y: 20 }, | |
| 29 | show: { opacity: 1, y: 0 }, | |
| 30 | }; | |
| 31 | ||
| 32 | return ( | |
| 33 | <motion.div | |
| 34 | className="space-y-8 pt-4" | |
| 35 | variants={container} | |
| 36 | initial="hidden" | |
| 37 | animate="show" | |
| 38 | > | |
| 39 | {/* Now Playing Section */} | |
| 40 | <motion.div | |
| 41 | className="flex items-start gap-6" | |
| 42 | variants={item} | |
| 43 | > | |
| 44 | <div className="relative w-16 h-16 bg-white/5 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden"> | |
| 45 | {currentTrack?.album?.images?.[0]?.url ? ( | |
| 46 | <Image | |
| 47 | src={currentTrack.album.images[0].url} | |
| 48 | alt={currentTrack.album.name || "Album artwork"} | |
| 49 | fill | |
| 50 | className="object-cover" | |
| 51 | sizes="64px" | |
| 52 | /> | |
| 53 | ) : ( | |
| 54 | <Music2 className="w-8 h-8 text-white/40" /> | |
| 55 | )} | |
| 56 | </div> | |
| 57 | ||
| 58 | <div className="space-y-1 flex-1 min-w-0"> | |
| 59 | <motion.h3 | |
| 60 | className="text-lg font-medium truncate" | |
| 61 | initial={{ opacity: 0 }} | |
| 62 | animate={{ opacity: 1 }} | |
| 63 | key={currentTrack?.name} | |
| 64 | > | |
| 65 | {currentTrack?.name || "Nothing playing"} | |
| 66 | </motion.h3> | |
| 67 | {currentTrack && ( | |
| 68 | <> | |
| 69 | <motion.p | |
| 70 | className="text-sm text-white/60 truncate" | |
| 71 | initial={{ opacity: 0 }} | |
| 72 | animate={{ opacity: 1 }} | |
| 73 | transition={{ delay: 0.1 }} | |
| 74 | > | |
| 75 | {currentTrack.artists[0].name} | |
| 76 | </motion.p> | |
| 77 | {currentTrack.album && ( | |
| 78 | <motion.p | |
| 79 | className="text-sm text-white/40 truncate" | |
| 80 | initial={{ opacity: 0 }} | |
| 81 | animate={{ opacity: 1 }} | |
| 82 | transition={{ delay: 0.2 }} | |
| 83 | > | |
| 84 | {currentTrack.album.name} | |
| 85 | </motion.p> | |
| 86 | )} | |
| 87 | </> | |
| 88 | )} | |
| 89 | </div> | |
| 90 | </motion.div> | |
| 91 | ||
| 92 | {/* Progress Bar */} | |
| 93 | <motion.div variants={item}> | |
| 94 | <ProgressBar /> | |
| 95 | </motion.div> | |
| 96 | ||
| 97 | {/* Player Controls */} | |
| 98 | <motion.div | |
| 99 | variants={item} | |
| 100 | className="flex items-center justify-center gap-6" | |
| 101 | > | |
| 102 | <motion.button | |
| 103 | whileHover={{ scale: 1.1 }} | |
| 104 | whileTap={{ scale: 0.95 }} | |
| 105 | onClick={() => player?.previousTrack()} | |
| 106 | className="p-2 text-white/60 hover:text-white/80 transition-colors" | |
| 107 | > | |
| 108 | <SkipBack className="w-6 h-6" /> | |
| 109 | </motion.button> | |
| 110 | ||
| 111 | <motion.button | |
| 112 | whileHover={{ scale: 1.1 }} | |
| 113 | whileTap={{ scale: 0.95 }} | |
| 114 | onClick={() => player?.togglePlay()} | |
| 115 | className="p-4 bg-white/10 rounded-full hover:bg-white/20 transition-colors" | |
| 116 | > | |
| 117 | {isPaused ? ( | |
| 118 | <Play className="w-8 h-8" /> | |
| 119 | ) : ( | |
| 120 | <Pause className="w-8 h-8" /> | |
| 121 | )} | |
| 122 | </motion.button> | |
| 123 | ||
| 124 | <motion.button | |
| 125 | whileHover={{ scale: 1.1 }} | |
| 126 | whileTap={{ scale: 0.95 }} | |
| 127 | onClick={() => player?.nextTrack()} | |
| 128 | className="p-2 text-white/60 hover:text-white/80 transition-colors" | |
| 129 | > | |
| 130 | <SkipForward className="w-6 h-6" /> | |
| 131 | </motion.button> | |
| 132 | </motion.div> | |
| 133 | ||
| 134 | {/* Queue View */} | |
| 135 | <motion.div variants={item}> | |
| 136 | <QueueView /> | |
| 137 | </motion.div> | |
| 138 | ||
| 139 | {/* Playlist Info */} | |
| 140 | <motion.div variants={item} className="pt-4 border-t border-white/5"> | |
| 141 | <p className="text-sm text-white/40 text-center"> | |
| 142 | Playing from <span className="text-white/60">Trevor's Coding Mix</span> | |
| 143 | </p> | |
| 144 | </motion.div> | |
| 145 | </motion.div> | |
| 146 | ); | |
| 147 | } | |
| 148 |
src/components/spotify/progress-bar.tsx added +133
| @@ -0,0 +1,133 @@ | ||
| 1 | 'use client'; | |
| 2 | ||
| 3 | import { useEffect, useState, useCallback, useRef } from 'react'; | |
| 4 | import { motion } from 'framer-motion'; | |
| 5 | import { useSpotifyStore } from '@/hooks/use-spotify'; | |
| 6 | import { SpotifyPlaybackState, SpotifyPlayer } from '@/types/spotify'; | |
| 7 | ||
| 8 | function formatTime(ms: number): string { | |
| 9 | const minutes = Math.floor(ms / 60000); | |
| 10 | const seconds = Math.floor((ms % 60000) / 1000); | |
| 11 | return `${minutes}:${seconds.toString().padStart(2, '0')}`; | |
| 12 | } | |
| 13 | ||
| 14 | export function ProgressBar() { | |
| 15 | const { player } = useSpotifyStore(); | |
| 16 | const [position, setPosition] = useState(0); | |
| 17 | const [duration, setDuration] = useState(0); | |
| 18 | const [isDragging, setIsDragging] = useState(false); | |
| 19 | const [isInitialized, setIsInitialized] = useState(false); | |
| 20 | const dragStartPosition = useRef<number>(0); | |
| 21 | ||
| 22 | // Function to update the playback position and duration | |
| 23 | const updatePlaybackState = useCallback(async (player: SpotifyPlayer) => { | |
| 24 | const state = await player.getCurrentState(); | |
| 25 | if (state) { | |
| 26 | setPosition(state.position); | |
| 27 | setDuration(state.duration); | |
| 28 | if (!isInitialized) { | |
| 29 | setIsInitialized(true); | |
| 30 | } | |
| 31 | } | |
| 32 | }, [isInitialized]); | |
| 33 | ||
| 34 | // Initialize and maintain progress updates | |
| 35 | useEffect(() => { | |
| 36 | if (!player) return; | |
| 37 | ||
| 38 | // Initial state update | |
| 39 | updatePlaybackState(player); | |
| 40 | ||
| 41 | // Set up interval for progress updates | |
| 42 | const interval = setInterval(() => { | |
| 43 | if (!isDragging) { | |
| 44 | updatePlaybackState(player); | |
| 45 | } | |
| 46 | }, 1000); | |
| 47 | ||
| 48 | return () => { | |
| 49 | clearInterval(interval); | |
| 50 | }; | |
| 51 | }, [player, isDragging, updatePlaybackState]); | |
| 52 | ||
| 53 | // Handle seeking | |
| 54 | const handleSeek = useCallback(async (event: React.MouseEvent<HTMLDivElement>) => { | |
| 55 | if (!player || !duration) return; | |
| 56 | ||
| 57 | const rect = event.currentTarget.getBoundingClientRect(); | |
| 58 | const clickPosition = event.clientX - rect.left; | |
| 59 | const percentage = clickPosition / rect.width; | |
| 60 | const position_ms = Math.floor(duration * percentage); | |
| 61 | ||
| 62 | try { | |
| 63 | await player.seek(position_ms); | |
| 64 | setPosition(position_ms); | |
| 65 | } catch (error) { | |
| 66 | console.error('Failed to seek:', error); | |
| 67 | } | |
| 68 | }, [player, duration]); | |
| 69 | ||
| 70 | // Handle drag start | |
| 71 | const handleDragStart = useCallback((event: React.MouseEvent<HTMLDivElement>) => { | |
| 72 | setIsDragging(true); | |
| 73 | dragStartPosition.current = position; | |
| 74 | }, [position]); | |
| 75 | ||
| 76 | // Handle drag | |
| 77 | const handleDrag = useCallback((event: React.MouseEvent<HTMLDivElement>) => { | |
| 78 | if (!isDragging || !duration) return; | |
| 79 | ||
| 80 | const rect = event.currentTarget.getBoundingClientRect(); | |
| 81 | const mousePosition = event.clientX - rect.left; | |
| 82 | const percentage = Math.max(0, Math.min(1, mousePosition / rect.width)); | |
| 83 | const newPosition = Math.floor(duration * percentage); | |
| 84 | setPosition(newPosition); | |
| 85 | }, [isDragging, duration]); | |
| 86 | ||
| 87 | // Handle drag end | |
| 88 | const handleDragEnd = useCallback(async () => { | |
| 89 | if (!player || !isDragging) return; | |
| 90 | ||
| 91 | try { | |
| 92 | await player.seek(position); | |
| 93 | } catch (error) { | |
| 94 | console.error('Failed to seek after drag:', error); | |
| 95 | setPosition(dragStartPosition.current); | |
| 96 | } finally { | |
| 97 | setIsDragging(false); | |
| 98 | } | |
| 99 | }, [player, isDragging, position]); | |
| 100 | ||
| 101 | // Don't render until we have duration information | |
| 102 | if (!duration || !isInitialized) return null; | |
| 103 | ||
| 104 | const progress = (position / duration) * 100; | |
| 105 | ||
| 106 | return ( | |
| 107 | <div className="space-y-2 w-full select-none"> | |
| 108 | <div | |
| 109 | className="h-1 bg-white/10 rounded-full relative cursor-pointer group" | |
| 110 | onClick={handleSeek} | |
| 111 | onMouseDown={handleDragStart} | |
| 112 | onMouseMove={handleDrag} | |
| 113 | onMouseUp={handleDragEnd} | |
| 114 | onMouseLeave={handleDragEnd} | |
| 115 | > | |
| 116 | <div | |
| 117 | className="absolute h-full bg-white/60 rounded-full group-hover:bg-white/80 transition-colors" | |
| 118 | style={{ width: `${progress}%` }} | |
| 119 | /> | |
| 120 | <div className="absolute -top-2 -bottom-2 left-0 right-0 opacity-0 group-hover:opacity-100 transition-opacity"> | |
| 121 | <div | |
| 122 | className="absolute h-4 w-4 bg-white rounded-full shadow-lg -mt-1.5 -ml-2 opacity-0 group-hover:opacity-100 transition-opacity" | |
| 123 | style={{ left: `${progress}%` }} | |
| 124 | /> | |
| 125 | </div> | |
| 126 | </div> | |
| 127 | <div className="flex justify-between text-xs text-white/40"> | |
| 128 | <span>{formatTime(position)}</span> | |
| 129 | <span>{formatTime(duration)}</span> | |
| 130 | </div> | |
| 131 | </div> | |
| 132 | ); | |
| 133 | }↵ |
src/components/spotify/queue-view.tsx added +180
| @@ -0,0 +1,180 @@ | ||
| 1 | "use client"; | |
| 2 | ||
| 3 | import { useState, useEffect, useCallback } from 'react'; | |
| 4 | import { useSpotifyStore } from '@/hooks/use-spotify'; | |
| 5 | import { motion } from 'framer-motion'; | |
| 6 | import { Music2, ListMusic } from 'lucide-react'; | |
| 7 | import { cn } from '@/lib/utils'; | |
| 8 | import { useSpotifyAuth } from '@/hooks/use-spotify-auth'; | |
| 9 | import Image from 'next/image'; | |
| 10 | ||
| 11 | interface Track { | |
| 12 | name: string; | |
| 13 | artists: { name: string }[]; | |
| 14 | duration_ms: number; | |
| 15 | uri: string; | |
| 16 | album?: { | |
| 17 | name: string; | |
| 18 | images: { url: string }[]; | |
| 19 | }; | |
| 20 | } | |
| 21 | ||
| 22 | export function QueueView() { | |
| 23 | const [upcomingTracks, setUpcomingTracks] = useState<Track[]>([]); | |
| 24 | const [isLoading, setIsLoading] = useState(false); | |
| 25 | const { player, currentTrack } = useSpotifyStore(); | |
| 26 | const { accessToken } = useSpotifyAuth(); | |
| 27 | ||
| 28 | const fetchQueue = useCallback(async () => { | |
| 29 | if (!accessToken) return []; | |
| 30 | ||
| 31 | try { | |
| 32 | setIsLoading(true); | |
| 33 | const response = await fetch( | |
| 34 | "https://api.spotify.com/v1/me/player/queue", | |
| 35 | { | |
| 36 | headers: { | |
| 37 | Authorization: `Bearer ${accessToken}`, | |
| 38 | }, | |
| 39 | }, | |
| 40 | ); | |
| 41 | ||
| 42 | if (response.ok) { | |
| 43 | const data = await response.json(); | |
| 44 | return data.queue || []; | |
| 45 | } | |
| 46 | return []; | |
| 47 | } catch (error) { | |
| 48 | console.error("Failed to fetch queue:", error); | |
| 49 | return []; | |
| 50 | } finally { | |
| 51 | setIsLoading(false); | |
| 52 | } | |
| 53 | }, [accessToken]); | |
| 54 | ||
| 55 | useEffect(() => { | |
| 56 | if (!accessToken || !player) return; | |
| 57 | ||
| 58 | fetchQueue().then((tracks) => { | |
| 59 | setUpcomingTracks(tracks); | |
| 60 | }); | |
| 61 | }, [fetchQueue, accessToken, player, currentTrack?.uri]); | |
| 62 | ||
| 63 | const skipToTrack = useCallback( | |
| 64 | async (trackUri: string, index: number) => { | |
| 65 | if (!player || !accessToken) return; | |
| 66 | ||
| 67 | try { | |
| 68 | for (let i = 0; i < index + 1; i++) { | |
| 69 | await player.nextTrack(); | |
| 70 | await new Promise((resolve) => setTimeout(resolve, 200)); | |
| 71 | } | |
| 72 | } catch (error) { | |
| 73 | console.error("Error skipping to track:", error); | |
| 74 | } | |
| 75 | }, | |
| 76 | [player, accessToken], | |
| 77 | ); | |
| 78 | ||
| 79 | const formatDuration = useCallback((ms: number) => { | |
| 80 | const minutes = Math.floor(ms / 60000); | |
| 81 | const seconds = Math.floor((ms % 60000) / 1000); | |
| 82 | return `${minutes}:${seconds.toString().padStart(2, "0")}`; | |
| 83 | }, []); | |
| 84 | ||
| 85 | const containerVariants = { | |
| 86 | hidden: { opacity: 0 }, | |
| 87 | visible: { | |
| 88 | opacity: 1, | |
| 89 | transition: { | |
| 90 | staggerChildren: 0.05, | |
| 91 | when: "beforeChildren", | |
| 92 | }, | |
| 93 | }, | |
| 94 | }; | |
| 95 | ||
| 96 | const itemVariants = { | |
| 97 | hidden: { opacity: 0, y: 10 }, | |
| 98 | visible: { | |
| 99 | opacity: 1, | |
| 100 | y: 0, | |
| 101 | transition: { duration: 0.2 }, | |
| 102 | }, | |
| 103 | }; | |
| 104 | ||
| 105 | if (!accessToken || !player) return null; | |
| 106 | ||
| 107 | return ( | |
| 108 | <div className="space-y-4"> | |
| 109 | <div className="flex items-center gap-2 text-white/60"> | |
| 110 | <ListMusic className="w-4 h-4" /> | |
| 111 | <h3 className="text-sm font-medium">Up Next</h3> | |
| 112 | </div> | |
| 113 | ||
| 114 | <motion.div | |
| 115 | variants={containerVariants} | |
| 116 | initial="hidden" | |
| 117 | animate="visible" | |
| 118 | className="space-y-2 min-h-[120px]" | |
| 119 | > | |
| 120 | {isLoading ? ( | |
| 121 | <motion.div | |
| 122 | variants={itemVariants} | |
| 123 | className="py-4 text-center text-white/40" | |
| 124 | > | |
| 125 | Loading queue... | |
| 126 | </motion.div> | |
| 127 | ) : upcomingTracks.length > 0 ? ( | |
| 128 | upcomingTracks.slice(0, 5).map((track, index) => ( | |
| 129 | <motion.button | |
| 130 | key={track.uri} | |
| 131 | variants={itemVariants} | |
| 132 | onClick={() => skipToTrack(track.uri, index)} | |
| 133 | className={cn( | |
| 134 | "w-full flex items-center gap-3 p-2 rounded-lg", | |
| 135 | "hover:bg-white/10 active:bg-white/5", | |
| 136 | "transition-colors duration-200", | |
| 137 | "group", | |
| 138 | )} | |
| 139 | > | |
| 140 | <div className="relative w-10 h-10 bg-white/5 rounded overflow-hidden flex items-center justify-center flex-shrink-0"> | |
| 141 | {track.album?.images?.[0] ? ( | |
| 142 | <Image | |
| 143 | src={track.album.images[0].url} | |
| 144 | alt={track.album.name || "Album artwork"} | |
| 145 | fill | |
| 146 | className="object-cover" | |
| 147 | sizes="40px" | |
| 148 | /> | |
| 149 | ) : ( | |
| 150 | <Music2 className="w-5 h-5 text-white/40" /> | |
| 151 | )} | |
| 152 | </div> | |
| 153 | ||
| 154 | <div className="flex-1 min-w-0 text-left"> | |
| 155 | <p className="text-sm font-medium truncate text-white/80 group-hover:text-white transition-colors duration-200"> | |
| 156 | {track.name} | |
| 157 | </p> | |
| 158 | <p className="text-xs text-white/40 truncate group-hover:text-white/60 transition-colors duration-200"> | |
| 159 | {track.artists[0].name} | |
| 160 | </p> | |
| 161 | </div> | |
| 162 | ||
| 163 | <div className="text-xs text-white/40 group-hover:text-white/60 transition-colors duration-200"> | |
| 164 | {formatDuration(track.duration_ms)} | |
| 165 | </div> | |
| 166 | </motion.button> | |
| 167 | )) | |
| 168 | ) : ( | |
| 169 | <motion.div | |
| 170 | variants={itemVariants} | |
| 171 | className="py-4 text-center text-white/40" | |
| 172 | > | |
| 173 | No upcoming tracks | |
| 174 | </motion.div> | |
| 175 | )} | |
| 176 | </motion.div> | |
| 177 | </div> | |
| 178 | ); | |
| 179 | } | |
| 180 |
src/components/spotify/volume-control.tsx added +111
| @@ -0,0 +1,111 @@ | ||
| 1 | 'use client'; | |
| 2 | ||
| 3 | import { useState, useEffect, useCallback } from 'react'; | |
| 4 | import { motion, useMotionValue, useTransform } from 'framer-motion'; | |
| 5 | import { useSpotifyStore } from '@/hooks/use-spotify'; | |
| 6 | import { Volume2, VolumeX } from 'lucide-react'; | |
| 7 | ||
| 8 | // We create a special slider that responds to both click and drag gestures | |
| 9 | export function VolumeControl() { | |
| 10 | const { player } = useSpotifyStore(); | |
| 11 | const [volume, setVolume] = useState(0.5); | |
| 12 | const [isVisible, setIsVisible] = useState(false); | |
| 13 | const [isMuted, setIsMuted] = useState(false); | |
| 14 | ||
| 15 | // We use motion values for smooth interpolation | |
| 16 | const dragX = useMotionValue(50); | |
| 17 | const volumeX = useTransform(dragX, [0, 100], [0, 1]); | |
| 18 | ||
| 19 | useEffect(() => { | |
| 20 | // Sync with initial player volume | |
| 21 | player?.getVolume().then((vol: number) => { | |
| 22 | setVolume(vol); | |
| 23 | dragX.set(vol * 100); | |
| 24 | }); | |
| 25 | }, [player, dragX]); | |
| 26 | ||
| 27 | // Handle volume changes with debouncing for performance | |
| 28 | const handleVolumeChange = useCallback((newVolume: number) => { | |
| 29 | setVolume(newVolume); | |
| 30 | player?.setVolume(newVolume); | |
| 31 | setIsMuted(newVolume === 0); | |
| 32 | }, [player]); | |
| 33 | ||
| 34 | // When the user drags the slider | |
| 35 | const handleDrag = useCallback((event: any, info: any) => { | |
| 36 | const newVolume = Math.max(0, Math.min(1, volumeX.get())); | |
| 37 | handleVolumeChange(newVolume); | |
| 38 | }, [volumeX, handleVolumeChange]); | |
| 39 | ||
| 40 | // Toggle mute state | |
| 41 | const toggleMute = useCallback(() => { | |
| 42 | if (isMuted) { | |
| 43 | handleVolumeChange(volume || 0.5); | |
| 44 | } else { | |
| 45 | handleVolumeChange(0); | |
| 46 | } | |
| 47 | }, [isMuted, volume, handleVolumeChange]); | |
| 48 | ||
| 49 | return ( | |
| 50 | <motion.div | |
| 51 | className="relative group" | |
| 52 | onHoverStart={() => setIsVisible(true)} | |
| 53 | onHoverEnd={() => setIsVisible(false)} | |
| 54 | > | |
| 55 | {/* Volume Icon Button */} | |
| 56 | <motion.button | |
| 57 | onClick={toggleMute} | |
| 58 | className="p-2 text-white/60 hover:text-white/80 transition-colors" | |
| 59 | whileTap={{ scale: 0.95 }} | |
| 60 | > | |
| 61 | {isMuted ? ( | |
| 62 | <VolumeX className="w-5 h-5" /> | |
| 63 | ) : ( | |
| 64 | <Volume2 className="w-5 h-5" /> | |
| 65 | )} | |
| 66 | </motion.button> | |
| 67 | ||
| 68 | {/* Slider that appears on hover */} | |
| 69 | <motion.div | |
| 70 | className="absolute left-full top-1/2 ml-2 -translate-y-1/2" | |
| 71 | initial={{ opacity: 0, x: -10, pointerEvents: 'none' }} | |
| 72 | animate={{ | |
| 73 | opacity: isVisible ? 1 : 0, | |
| 74 | x: isVisible ? 0 : -10, | |
| 75 | pointerEvents: isVisible ? 'auto' : 'none', | |
| 76 | }} | |
| 77 | transition={{ duration: 0.2 }} | |
| 78 | > | |
| 79 | <div className="bg-black/90 backdrop-blur-lg rounded-full p-2 flex items-center gap-2"> | |
| 80 | {/* Main slider track */} | |
| 81 | <div className="relative w-24 h-1 bg-white/10 rounded-full"> | |
| 82 | {/* Active volume fill */} | |
| 83 | <motion.div | |
| 84 | className="absolute inset-y-0 left-0 bg-white/60 rounded-full" | |
| 85 | style={{ width: volumeX.get() * 100 + '%' }} | |
| 86 | /> | |
| 87 | ||
| 88 | {/* Draggable handle */} | |
| 89 | <motion.button | |
| 90 | drag="x" | |
| 91 | dragConstraints={{ left: 0, right: 100 }} | |
| 92 | dragElastic={0} | |
| 93 | dragMomentum={false} | |
| 94 | onDrag={handleDrag} | |
| 95 | className="absolute top-1/2 -translate-y-1/2 -ml-2 w-4 h-4 rounded-full bg-white shadow-lg" | |
| 96 | style={{ x: dragX }} | |
| 97 | whileDrag={{ scale: 1.1 }} | |
| 98 | whileHover={{ scale: 1.1 }} | |
| 99 | transition={{ duration: 0.2 }} | |
| 100 | /> | |
| 101 | </div> | |
| 102 | ||
| 103 | {/* Current volume percentage */} | |
| 104 | <span className="text-xs text-white/40 w-8"> | |
| 105 | {Math.round(volume * 100)}% | |
| 106 | </span> | |
| 107 | </div> | |
| 108 | </motion.div> | |
| 109 | </motion.div> | |
| 110 | ); | |
| 111 | }↵ |
src/components/ui/spotify-player.tsx added +148
| @@ -0,0 +1,148 @@ | ||
| 1 | 'use client'; | |
| 2 | ||
| 3 | import { useEffect, useState, useCallback } from 'react'; | |
| 4 | import { cn } from '@/lib/utils'; | |
| 5 | import { motion } from 'framer-motion'; | |
| 6 | import { Play, Pause } from 'lucide-react'; | |
| 7 | import { SpotifyPlayer as SpotifyPlayerType, SpotifyPlayerOptions, SpotifyError } from '@/types/spotify'; | |
| 8 | ||
| 9 | export function SpotifyPlayer({ | |
| 10 | clientId, | |
| 11 | playlistUri, | |
| 12 | className, | |
| 13 | }: { | |
| 14 | clientId: string; | |
| 15 | playlistUri: string; | |
| 16 | className?: string; | |
| 17 | }) { | |
| 18 | const [player, setPlayer] = useState<SpotifyPlayerType | null>(null); | |
| 19 | const [isPaused, setIsPaused] = useState(true); | |
| 20 | const [currentTrack, setCurrentTrack] = useState<any>(null); | |
| 21 | const [deviceId, setDeviceId] = useState<string | null>(null); | |
| 22 | const [isInitialized, setIsInitialized] = useState(false); | |
| 23 | ||
| 24 | const handleError = useCallback((type: string, { message }: SpotifyError) => { | |
| 25 | console.error(`Spotify ${type}:`, message); | |
| 26 | if (type === 'authentication_error') { | |
| 27 | setIsInitialized(false); | |
| 28 | } | |
| 29 | }, []); | |
| 30 | ||
| 31 | const initializePlayer = useCallback(() => { | |
| 32 | if (!window.Spotify?.Player) return; | |
| 33 | ||
| 34 | const player = new window.Spotify.Player({ | |
| 35 | name: 'Trevato.dev Web Player', | |
| 36 | getOAuthToken: async (cb: (token: string) => void) => { | |
| 37 | // Here we'd normally refresh the token, but for now we'll use a placeholder | |
| 38 | cb('your-access-token'); | |
| 39 | }, | |
| 40 | volume: 0.5 | |
| 41 | }); | |
| 42 | ||
| 43 | // Add error listeners with proper type safety | |
| 44 | player.addListener('initialization_error', (error) => handleError('initialization_error', error)); | |
| 45 | player.addListener('authentication_error', (error) => handleError('authentication_error', error)); | |
| 46 | player.addListener('account_error', (error) => handleError('account_error', error)); | |
| 47 | player.addListener('playback_error', (error) => handleError('playback_error', error)); | |
| 48 | ||
| 49 | player.addListener('ready', ({ device_id }) => { | |
| 50 | console.log('Player ready with device ID:', device_id); | |
| 51 | setDeviceId(device_id); | |
| 52 | setIsInitialized(true); | |
| 53 | }); | |
| 54 | ||
| 55 | player.addListener('not_ready', ({ device_id }) => { | |
| 56 | console.log('Device ID has gone offline:', device_id); | |
| 57 | setDeviceId(null); | |
| 58 | setIsInitialized(false); | |
| 59 | }); | |
| 60 | ||
| 61 | player.addListener('player_state_changed', (state) => { | |
| 62 | if (!state) return; | |
| 63 | setCurrentTrack(state.track_window.current_track); | |
| 64 | setIsPaused(state.paused); | |
| 65 | }); | |
| 66 | ||
| 67 | player.connect().then(success => { | |
| 68 | if (success) { | |
| 69 | setPlayer(player); | |
| 70 | } | |
| 71 | }); | |
| 72 | ||
| 73 | return () => { | |
| 74 | player.disconnect(); | |
| 75 | }; | |
| 76 | }, [handleError]); | |
| 77 | ||
| 78 | useEffect(() => { | |
| 79 | if (window.Spotify?.Player) { | |
| 80 | return initializePlayer(); | |
| 81 | } | |
| 82 | ||
| 83 | const script = document.createElement("script"); | |
| 84 | script.src = "https://sdk.scdn.co/spotify-player.js"; | |
| 85 | script.async = true; | |
| 86 | ||
| 87 | document.body.appendChild(script); | |
| 88 | ||
| 89 | window.onSpotifyWebPlaybackSDKReady = () => { | |
| 90 | const cleanup = initializePlayer(); | |
| 91 | return () => cleanup?.(); | |
| 92 | }; | |
| 93 | ||
| 94 | return () => { | |
| 95 | script.remove(); | |
| 96 | }; | |
| 97 | }, [initializePlayer]); | |
| 98 | ||
| 99 | const togglePlay = useCallback(async () => { | |
| 100 | if (!player || !deviceId || !isInitialized) return; | |
| 101 | await player.togglePlay(); | |
| 102 | }, [player, deviceId, isInitialized]); | |
| 103 | ||
| 104 | if (!isInitialized) return null; | |
| 105 | ||
| 106 | return ( | |
| 107 | <motion.div | |
| 108 | className={cn( | |
| 109 | "flex items-center gap-2", | |
| 110 | "text-white/60 hover:text-white/80 transition-colors", | |
| 111 | className | |
| 112 | )} | |
| 113 | initial={false} | |
| 114 | > | |
| 115 | <motion.button | |
| 116 | onClick={togglePlay} | |
| 117 | className="flex items-center justify-center w-8 h-8 bg-white/5 rounded-full hover:bg-white/10" | |
| 118 | whileTap={{ scale: 0.95 }} | |
| 119 | transition={{ duration: 0.1 }} | |
| 120 | aria-label={isPaused ? "Play" : "Pause"} | |
| 121 | > | |
| 122 | <motion.div | |
| 123 | key={isPaused ? 'play' : 'pause'} | |
| 124 | initial={false} | |
| 125 | animate={{ scale: 1, opacity: 1 }} | |
| 126 | transition={{ duration: 0.1 }} | |
| 127 | > | |
| 128 | {isPaused ? ( | |
| 129 | <Play className="w-4 h-4" /> | |
| 130 | ) : ( | |
| 131 | <Pause className="w-4 h-4" /> | |
| 132 | )} | |
| 133 | </motion.div> | |
| 134 | </motion.button> | |
| 135 | ||
| 136 | {currentTrack && ( | |
| 137 | <motion.div | |
| 138 | initial={{ opacity: 0 }} | |
| 139 | animate={{ opacity: 0.6 }} | |
| 140 | transition={{ duration: 0.2 }} | |
| 141 | className="text-xs sm:text-sm truncate max-w-[120px]" | |
| 142 | > | |
| 143 | {currentTrack.name} | |
| 144 | </motion.div> | |
| 145 | )} | |
| 146 | </motion.div> | |
| 147 | ); | |
| 148 | }↵ |
src/config/spotify.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | // We define our configuration type to ensure consistency | |
| 2 | interface SpotifyServerConfig { | |
| 3 | clientId: string; | |
| 4 | clientSecret: string; | |
| 5 | refreshToken: string; | |
| 6 | } | |
| 7 | ||
| 8 | // This error class provides clear, actionable messages when configuration is missing | |
| 9 | class ConfigurationError extends Error { | |
| 10 | constructor(missingVars: string[]) { | |
| 11 | const message = [ | |
| 12 | 'Missing required Spotify configuration variables:', | |
| 13 | ...missingVars.map(name => ` - ${name}`), | |
| 14 | '', | |
| 15 | 'Please ensure these are set in your environment configuration.', | |
| 16 | ].join('\n'); | |
| 17 | ||
| 18 | super(message); | |
| 19 | this.name = 'ConfigurationError'; | |
| 20 | } | |
| 21 | } | |
| 22 | ||
| 23 | // This function validates our environment variables and provides type safety | |
| 24 | function validateServerConfiguration(): SpotifyServerConfig { | |
| 25 | // First, we get all our environment variables | |
| 26 | const clientId = process.env.NEXT_PUBLIC_SPOTIFY_CLIENT_ID; | |
| 27 | const clientSecret = process.env.SPOTIFY_CLIENT_SECRET; | |
| 28 | const refreshToken = process.env.SPOTIFY_REFRESH_TOKEN; | |
| 29 | ||
| 30 | // Then we check which ones are missing | |
| 31 | const missingVars: string[] = []; | |
| 32 | if (!clientId) missingVars.push('NEXT_PUBLIC_SPOTIFY_CLIENT_ID'); | |
| 33 | if (!clientSecret) missingVars.push('SPOTIFY_CLIENT_SECRET'); | |
| 34 | if (!refreshToken) missingVars.push('SPOTIFY_REFRESH_TOKEN'); | |
| 35 | ||
| 36 | // If anything is missing, we throw an error with a helpful message | |
| 37 | if (missingVars.length > 0) { | |
| 38 | throw new ConfigurationError(missingVars); | |
| 39 | } | |
| 40 | ||
| 41 | // At this point, we know all variables exist, so we can safely assert their types | |
| 42 | // The "as string" tells TypeScript to trust us that these values are defined | |
| 43 | return { | |
| 44 | clientId: clientId as string, | |
| 45 | clientSecret: clientSecret as string, | |
| 46 | refreshToken: refreshToken as string, | |
| 47 | }; | |
| 48 | } | |
| 49 | ||
| 50 | // We validate the configuration immediately and export the result | |
| 51 | export const spotifyServerConfig = validateServerConfiguration(); | |
| 52 | ||
| 53 | // These API endpoints never change, so we mark them as constant | |
| 54 | export const SPOTIFY_API = { | |
| 55 | TOKEN_ENDPOINT: 'https://accounts.spotify.com/api/token', | |
| 56 | PLAYER_ENDPOINT: 'https://api.spotify.com/v1/me/player', | |
| 57 | // Using 'as const' makes this object and its properties immutable | |
| 58 | } as const;↵ |
src/hooks/use-spotify-auth.ts added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | 'use client'; | |
| 2 | ||
| 3 | import { useState, useEffect, useCallback, useRef } from 'react'; | |
| 4 | ||
| 5 | export function useSpotifyAuth() { | |
| 6 | const [accessToken, setAccessToken] = useState<string | null>(null); | |
| 7 | const [isLoading, setIsLoading] = useState(true); | |
| 8 | const retryTimeoutRef = useRef<NodeJS.Timeout>(); | |
| 9 | const retryCountRef = useRef(0); | |
| 10 | const maxRetries = 3; | |
| 11 | ||
| 12 | const refreshToken = useCallback(async () => { | |
| 13 | try { | |
| 14 | const response = await fetch('/api/spotify/token'); | |
| 15 | const data = await response.json(); | |
| 16 | ||
| 17 | if (!response.ok) { | |
| 18 | throw new Error(data.error || 'Failed to fetch token'); | |
| 19 | } | |
| 20 | ||
| 21 | if (!data.access_token) { | |
| 22 | throw new Error('Invalid token response'); | |
| 23 | } | |
| 24 | ||
| 25 | // Reset retry count on success | |
| 26 | retryCountRef.current = 0; | |
| 27 | setAccessToken(data.access_token); | |
| 28 | return data.access_token; | |
| 29 | } catch (error) { | |
| 30 | console.error('Error fetching access token:', error); | |
| 31 | ||
| 32 | // Implement exponential backoff for retries | |
| 33 | if (retryCountRef.current < maxRetries) { | |
| 34 | const retryDelay = Math.min(1000 * Math.pow(2, retryCountRef.current), 10000); | |
| 35 | retryCountRef.current++; | |
| 36 | ||
| 37 | // Clear any existing retry timeout | |
| 38 | if (retryTimeoutRef.current) { | |
| 39 | clearTimeout(retryTimeoutRef.current); | |
| 40 | } | |
| 41 | ||
| 42 | // Schedule a retry | |
| 43 | retryTimeoutRef.current = setTimeout(refreshToken, retryDelay); | |
| 44 | } | |
| 45 | ||
| 46 | return null; | |
| 47 | } finally { | |
| 48 | setIsLoading(false); | |
| 49 | } | |
| 50 | }, []); | |
| 51 | ||
| 52 | // Set up the refresh interval | |
| 53 | useEffect(() => { | |
| 54 | refreshToken(); | |
| 55 | ||
| 56 | // Refresh token every 50 minutes | |
| 57 | const interval = setInterval(refreshToken, 50 * 60 * 1000); | |
| 58 | ||
| 59 | // Cleanup function | |
| 60 | return () => { | |
| 61 | clearInterval(interval); | |
| 62 | if (retryTimeoutRef.current) { | |
| 63 | clearTimeout(retryTimeoutRef.current); | |
| 64 | } | |
| 65 | }; | |
| 66 | }, [refreshToken]); | |
| 67 | ||
| 68 | return { | |
| 69 | accessToken, | |
| 70 | isLoading, | |
| 71 | refreshToken, | |
| 72 | }; | |
| 73 | }↵ |
src/hooks/use-spotify.tsx added +25
| @@ -0,0 +1,25 @@ | ||
| 1 | 'use client'; | |
| 2 | ||
| 3 | import { create } from 'zustand'; | |
| 4 | ||
| 5 | interface SpotifyState { | |
| 6 | player: any | null; | |
| 7 | deviceId: string | null; | |
| 8 | currentTrack: any | null; | |
| 9 | isPaused: boolean; | |
| 10 | setPlayer: (player: any) => void; | |
| 11 | setDeviceId: (deviceId: string | null) => void; | |
| 12 | setCurrentTrack: (track: any) => void; | |
| 13 | setIsPaused: (isPaused: boolean) => void; | |
| 14 | } | |
| 15 | ||
| 16 | export const useSpotifyStore = create<SpotifyState>((set) => ({ | |
| 17 | player: null, | |
| 18 | deviceId: null, | |
| 19 | currentTrack: null, | |
| 20 | isPaused: true, | |
| 21 | setPlayer: (player) => set({ player }), | |
| 22 | setDeviceId: (deviceId) => set({ deviceId }), | |
| 23 | setCurrentTrack: (currentTrack) => set({ currentTrack }), | |
| 24 | setIsPaused: (isPaused) => set({ isPaused }), | |
| 25 | }));↵ |
src/lib/api.ts added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | import { isSpotifyError, SpotifyErrorResponse, SpotifyTokenResponse } from '@/types/api'; | |
| 2 | ||
| 3 | // We create a custom error class for API errors | |
| 4 | export class APIError extends Error { | |
| 5 | constructor( | |
| 6 | public status: number, | |
| 7 | message: string, | |
| 8 | public data?: unknown | |
| 9 | ) { | |
| 10 | super(message); | |
| 11 | this.name = 'APIError'; | |
| 12 | } | |
| 13 | } | |
| 14 | ||
| 15 | // Type-safe function to safely parse JSON responses | |
| 16 | async function parseJSON<T>(response: Response): Promise<T> { | |
| 17 | const text = await response.text(); | |
| 18 | try { | |
| 19 | return JSON.parse(text) as T; | |
| 20 | } catch (e) { | |
| 21 | throw new APIError( | |
| 22 | response.status, | |
| 23 | 'Invalid JSON response', | |
| 24 | { text, error: e } | |
| 25 | ); | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | // A utility function to create URLSearchParams with type safety | |
| 30 | export function createURLSearchParams( | |
| 31 | params: Record<string, string> | |
| 32 | ): URLSearchParams { | |
| 33 | return new URLSearchParams( | |
| 34 | Object.entries(params).filter(([_, value]) => value != null) | |
| 35 | ); | |
| 36 | } | |
| 37 | ||
| 38 | // Function to handle Spotify token refresh requests | |
| 39 | export async function refreshSpotifyToken( | |
| 40 | basic: string, | |
| 41 | refreshToken: string | |
| 42 | ): Promise<SpotifyTokenResponse> { | |
| 43 | const response = await fetch('https://accounts.spotify.com/api/token', { | |
| 44 | method: 'POST', | |
| 45 | headers: { | |
| 46 | Authorization: `Basic ${basic}`, | |
| 47 | 'Content-Type': 'application/x-www-form-urlencoded', | |
| 48 | }, | |
| 49 | body: createURLSearchParams({ | |
| 50 | grant_type: 'refresh_token', | |
| 51 | refresh_token: refreshToken, | |
| 52 | }), | |
| 53 | cache: 'no-store', | |
| 54 | }); | |
| 55 | ||
| 56 | const data = await parseJSON<SpotifyTokenResponse | SpotifyErrorResponse>(response); | |
| 57 | ||
| 58 | if (!response.ok || isSpotifyError(data)) { | |
| 59 | throw new APIError( | |
| 60 | response.status, | |
| 61 | isSpotifyError(data) ? data.error_description || data.error : 'Unknown error', | |
| 62 | data | |
| 63 | ); | |
| 64 | } | |
| 65 | ||
| 66 | return data; | |
| 67 | }↵ |
src/lib/spotify.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | import { spotifyServerConfig, SPOTIFY_API } from '@/config/spotify'; | |
| 2 | ||
| 3 | // Spotify API interfaces that define our expected responses | |
| 4 | interface SpotifyTokenResponse { | |
| 5 | access_token: string; | |
| 6 | token_type: string; | |
| 7 | expires_in: number; | |
| 8 | scope: string; | |
| 9 | } | |
| 10 | ||
| 11 | // Create authorization header once since these values never change | |
| 12 | const basic = Buffer.from( | |
| 13 | `${spotifyServerConfig.clientId}:${spotifyServerConfig.clientSecret}` | |
| 14 | ).toString('base64'); | |
| 15 | ||
| 16 | export async function getAccessToken(): Promise<SpotifyTokenResponse> { | |
| 17 | try { | |
| 18 | const response = await fetch(SPOTIFY_API.TOKEN_ENDPOINT, { | |
| 19 | method: 'POST', | |
| 20 | headers: { | |
| 21 | Authorization: `Basic ${basic}`, | |
| 22 | 'Content-Type': 'application/x-www-form-urlencoded', | |
| 23 | }, | |
| 24 | body: new URLSearchParams({ | |
| 25 | grant_type: 'refresh_token', | |
| 26 | refresh_token: spotifyServerConfig.refreshToken, | |
| 27 | }).toString(), | |
| 28 | cache: 'no-store', | |
| 29 | }); | |
| 30 | ||
| 31 | if (!response.ok) { | |
| 32 | const text = await response.text(); | |
| 33 | throw new Error(`Failed to refresh token: ${response.status} ${text}`); | |
| 34 | } | |
| 35 | ||
| 36 | const data: SpotifyTokenResponse = await response.json(); | |
| 37 | ||
| 38 | // Log success without exposing sensitive data | |
| 39 | console.log('Token refresh successful', { | |
| 40 | tokenType: data.token_type, | |
| 41 | expiresIn: data.expires_in, | |
| 42 | timestamp: new Date().toISOString(), | |
| 43 | }); | |
| 44 | ||
| 45 | return data; | |
| 46 | } catch (error) { | |
| 47 | // Enhanced error logging with structured data | |
| 48 | console.error('Token refresh failed', { | |
| 49 | error: error instanceof Error ? { | |
| 50 | name: error.name, | |
| 51 | message: error.message, | |
| 52 | // Only include stack trace in development | |
| 53 | stack: process.env.NODE_ENV === 'development' ? error.stack : undefined, | |
| 54 | } : 'Unknown error', | |
| 55 | timestamp: new Date().toISOString(), | |
| 56 | }); | |
| 57 | throw error; | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | export async function getCurrentlyPlaying() { | |
| 62 | const { access_token } = await getAccessToken(); | |
| 63 | ||
| 64 | const response = await fetch(`${SPOTIFY_API.PLAYER_ENDPOINT}/currently-playing`, { | |
| 65 | headers: { | |
| 66 | Authorization: `Bearer ${access_token}`, | |
| 67 | }, | |
| 68 | cache: 'no-store', | |
| 69 | }); | |
| 70 | ||
| 71 | if (response.status === 204) { | |
| 72 | return null; | |
| 73 | } | |
| 74 | ||
| 75 | if (!response.ok) { | |
| 76 | throw new Error(`Failed to get currently playing: ${response.status}`); | |
| 77 | } | |
| 78 | ||
| 79 | return response.json(); | |
| 80 | }↵ |
src/types/api.ts added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | // We define all our API-related types in one place for consistency | |
| 2 | export interface SpotifyTokenResponse { | |
| 3 | access_token: string; | |
| 4 | token_type: string; | |
| 5 | expires_in: number; | |
| 6 | scope: string; | |
| 7 | } | |
| 8 | ||
| 9 | export interface SpotifyErrorResponse { | |
| 10 | error: string; | |
| 11 | error_description?: string; | |
| 12 | } | |
| 13 | ||
| 14 | // Type guard to check if a response is an error | |
| 15 | export function isSpotifyError( | |
| 16 | response: unknown | |
| 17 | ): response is SpotifyErrorResponse { | |
| 18 | return ( | |
| 19 | typeof response === 'object' && | |
| 20 | response !== null && | |
| 21 | 'error' in response && | |
| 22 | typeof (response as SpotifyErrorResponse).error === 'string' | |
| 23 | ); | |
| 24 | }↵ |
src/types/spotify.ts added +107
| @@ -0,0 +1,107 @@ | ||
| 1 | // Type definitions for Spotify Web Playback SDK | |
| 2 | ||
| 3 | export interface SpotifyTrack { | |
| 4 | id: string; | |
| 5 | uri: string; | |
| 6 | type: string; | |
| 7 | media_type: string; | |
| 8 | name: string; | |
| 9 | is_playable: boolean; | |
| 10 | album: { | |
| 11 | uri: string; | |
| 12 | name: string; | |
| 13 | images: Array<{ url: string }>; | |
| 14 | }; | |
| 15 | artists: Array<{ | |
| 16 | uri: string; | |
| 17 | name: string; | |
| 18 | }>; | |
| 19 | } | |
| 20 | ||
| 21 | export interface SpotifyPlaybackState { | |
| 22 | context: { | |
| 23 | uri: string | null; | |
| 24 | metadata: Record<string, unknown>; | |
| 25 | }; | |
| 26 | disallows: { | |
| 27 | pausing: boolean; | |
| 28 | peeking_next: boolean; | |
| 29 | peeking_prev: boolean; | |
| 30 | resuming: boolean; | |
| 31 | seeking: boolean; | |
| 32 | skipping_next: boolean; | |
| 33 | skipping_prev: boolean; | |
| 34 | }; | |
| 35 | duration: number; | |
| 36 | paused: boolean; | |
| 37 | position: number; | |
| 38 | repeat_mode: number; | |
| 39 | shuffle: boolean; | |
| 40 | track_window: { | |
| 41 | current_track: SpotifyTrack; | |
| 42 | previous_tracks: SpotifyTrack[]; | |
| 43 | next_tracks: SpotifyTrack[]; | |
| 44 | }; | |
| 45 | } | |
| 46 | ||
| 47 | export interface SpotifyError { | |
| 48 | message: string; | |
| 49 | } | |
| 50 | ||
| 51 | export type SpotifyPlayerCallback = (token: string) => void; | |
| 52 | ||
| 53 | type SpotifyPlayerEventCallback = { | |
| 54 | ready: (data: { device_id: string }) => void; | |
| 55 | not_ready: (data: { device_id: string }) => void; | |
| 56 | player_state_changed: (state: SpotifyPlaybackState | null) => void; | |
| 57 | initialization_error: (error: SpotifyError) => void; | |
| 58 | authentication_error: (error: SpotifyError) => void; | |
| 59 | account_error: (error: SpotifyError) => void; | |
| 60 | playback_error: (error: SpotifyError) => void; | |
| 61 | }; | |
| 62 | ||
| 63 | export interface SpotifyPlayer { | |
| 64 | _options: { | |
| 65 | getOAuthToken: (callback: SpotifyPlayerCallback) => void; | |
| 66 | name: string; | |
| 67 | id?: string; | |
| 68 | volume?: number; | |
| 69 | }; | |
| 70 | device_id: string; | |
| 71 | connect: () => Promise<boolean>; | |
| 72 | disconnect: () => void; | |
| 73 | addListener: { | |
| 74 | (event: 'ready', callback: SpotifyPlayerEventCallback['ready']): void; | |
| 75 | (event: 'not_ready', callback: SpotifyPlayerEventCallback['not_ready']): void; | |
| 76 | (event: 'player_state_changed', callback: SpotifyPlayerEventCallback['player_state_changed']): void; | |
| 77 | (event: 'initialization_error', callback: SpotifyPlayerEventCallback['initialization_error']): void; | |
| 78 | (event: 'authentication_error', callback: SpotifyPlayerEventCallback['authentication_error']): void; | |
| 79 | (event: 'account_error', callback: SpotifyPlayerEventCallback['account_error']): void; | |
| 80 | (event: 'playback_error', callback: SpotifyPlayerEventCallback['playback_error']): void; | |
| 81 | }; | |
| 82 | removeListener: <K extends keyof SpotifyPlayerEventCallback>( | |
| 83 | event: K, | |
| 84 | callback: SpotifyPlayerEventCallback[K] | |
| 85 | ) => void; | |
| 86 | getCurrentState: () => Promise<SpotifyPlaybackState | null>; | |
| 87 | setVolume: (volume: number) => Promise<void>; | |
| 88 | togglePlay: () => Promise<void>; | |
| 89 | seek: (position_ms: number) => Promise<void>; | |
| 90 | previousTrack: () => Promise<void>; | |
| 91 | nextTrack: () => Promise<void>; | |
| 92 | } | |
| 93 | ||
| 94 | export interface SpotifyPlayerOptions { | |
| 95 | name: string; | |
| 96 | getOAuthToken: (callback: SpotifyPlayerCallback) => void; | |
| 97 | volume?: number; | |
| 98 | } | |
| 99 | ||
| 100 | declare global { | |
| 101 | interface Window { | |
| 102 | onSpotifyWebPlaybackSDKReady: () => void; | |
| 103 | Spotify: { | |
| 104 | Player: new (options: SpotifyPlayerOptions) => SpotifyPlayer; | |
| 105 | }; | |
| 106 | } | |
| 107 | }↵ |