Public: anyone can read this; the crew can change it.

Spotify Player #8

opened this view on origin

Check · Review · Land · Live — It conflicts with its base. Next: builder, it conflicts with its base.

Conflicts with main

feat/spotify-on-vercel → main 8 commits

  • Can't merge. Conflicts in 4 files: .github/workflows/azure-static-web-apps-lemon-glacier-01d38e310.yml, bun.lockb, src/app/layout.tsx…. Merge main into the branch and push again.

    Check faileddeploy 6d5e5fc

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).

Review

Sign in to review this change.
22 files changed +1678 −101
.github/workflows/azure-static-web-apps-lemon-glacier-01d38e310.yml +3
@@ -18,6 +18,9 @@ jobs:
1818 env:
1919 NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ vars.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
2020 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 }}
2124 steps:
2225 - uses: actions/checkout@v3
2326 with:
bun.lockb added
Binary file not shown.
next.config.mjs +22 −2
@@ -1,4 +1,24 @@
11 /** @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 };
323
4 export default nextConfig;
24 export default nextConfig;↵
package.json +2 −1
@@ -39,7 +39,8 @@
3939 "remark-gfm": "^4.0.0",
4040 "tailwind-merge": "^2.5.2",
4141 "tailwindcss-animate": "^1.0.7",
42 "three": "^0.168.0"
42 "three": "^0.168.0",
43 "zustand": "^5.0.2"
4344 },
4445 "devDependencies": {
4546 "@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";
66 import { InteractiveBackground } from "@/components/InteractiveBackground";
77 import { Toaster } from '@/components/ui/toaster'
88 import { Trevato } from '@/components/Trevato'
9 import { SpotifyProvider } from "@/components/providers/spotify-provider";
910
1011 const inter = Inter({ subsets: ["latin"] });
1112
1213 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 },
4849 };
4950
5051 export default function RootLayout({
51 children,
52 children,
5253 }: Readonly<{
53 children: React.ReactNode;
54 children: React.ReactNode;
5455 }>) {
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/FloatingNavigation.tsx +51 −44
@@ -1,57 +1,64 @@
1 'use client'
1 'use client';
22
3 import React, { useState } from 'react'
4 import { Button } from "@/components/ui/button"
5 import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
6 import Link from 'next/link'
7 import { Menu } from 'lucide-react'
3 import React, { useState } from 'react';
4 import { Button } from "@/components/ui/button";
5 import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
6 import Link from 'next/link';
7 import { Menu } from 'lucide-react';
8 import { SpotifyControl } from './navigation/spotify-control';
89
910 const navItems = [
1011 { href: '/', label: 'Home' },
1112 { href: '/playground', label: 'Playground' },
1213 { href: '/projects', label: 'Side Projects' },
1314 { href: 'https://codewear.dev', label: 'CodeWear' },
14 ]
15 ];
1516
1617 export const FloatingNavigation: React.FC = () => {
17 const [open, setOpen] = useState(false)
18 const [open, setOpen] = useState(false);
1819
1920 return (
2021 <div className="fixed top-4 right-4 z-50">
21 <Sheet open={open} onOpenChange={setOpen}>
22 <SheetTrigger asChild>
23 <Button variant="outline" size="icon" className="bg-black/50 backdrop-blur-md">
24 <Menu className="h-6 w-6" />
25 </Button>
26 </SheetTrigger>
27 <SheetContent className="bg-black/80 backdrop-blur-md text-white border-blue-500">
28 <nav className="flex flex-col space-y-4 mt-8">
29 {navItems.map((item) => (
30 item.href === 'https://codewear.dev' ? (
31 <a
32 key={item.href}
33 href={item.href}
34 target="_blank"
35 rel="noopener noreferrer"
36 className="text-lg hover:text-blue-400 transition-colors"
37 onClick={() => setOpen(false)}
38 >
39 {item.label}
40 </a>
41 ) : (
42 <Link
43 key={item.href}
44 href={item.href}
45 className="text-lg hover:text-blue-400 transition-colors"
46 onClick={() => setOpen(false)}
47 >
48 {item.label}
49 </Link>
50 )
51 ))}
52 </nav>
53 </SheetContent>
54 </Sheet>
22 <div className="flex items-center gap-4">
23 <SpotifyControl
24 playlistUri="spotify:playlist:0uiK303E4UEFiNdYjwpwwo"
25 className="bg-black/50 backdrop-blur-md px-3 py-2 rounded-lg"
26 />
27 <Sheet open={open} onOpenChange={setOpen}>
28 <SheetTrigger asChild>
29 <Button variant="outline" size="icon" className="bg-black/50 backdrop-blur-md">
30 <Menu className="h-6 w-6" />
31 </Button>
32 </SheetTrigger>
33 <SheetContent className="bg-black/80 backdrop-blur-md text-white border-blue-500">
34 <nav className="flex flex-col space-y-4 mt-8">
35 {navItems.map((item) => (
36 item.href === 'https://codewear.dev' ? (
37 <a
38 key={item.href}
39 href={item.href}
40 target="_blank"
41 rel="noopener noreferrer"
42 className="text-lg hover:text-blue-400 transition-colors"
43 onClick={() => setOpen(false)}
44 >
45 {item.label}
46 </a>
47 ) : (
48 <Link
49 key={item.href}
50 href={item.href}
51 className="text-lg hover:text-blue-400 transition-colors"
52 onClick={() => setOpen(false)}
53 >
54 {item.label}
55 </Link>
56 )
57 ))}
58 </nav>
59 </SheetContent>
60 </Sheet>
61 </div>
5562 </div>
56 )
57 }
63 );
64 }↵
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/navigation/spotify-control.tsx added +140
@@ -0,0 +1,140 @@
1 "use client";
2
3 import { useSpotifyStore } from "@/hooks/use-spotify";
4 import { useSpotifyAuth } from "@/hooks/use-spotify-auth";
5 import { Play, Pause, ChevronUp } from "lucide-react";
6 import { cn } from "@/lib/utils";
7 import { useCallback, useState, useRef, useEffect } from "react";
8 import { motion } from "framer-motion";
9 import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
10 import { ExpandedSpotifyView } from "../spotify/expanded-view";
11
12 interface SpotifyControlProps {
13 playlistUri: string;
14 className?: string;
15 }
16
17 export function SpotifyControl({
18 playlistUri,
19 className,
20 }: SpotifyControlProps) {
21 const { accessToken, refreshToken } = useSpotifyAuth();
22 const { player, deviceId, currentTrack, isPaused } = useSpotifyStore();
23 const dialogOpenRef = useRef(false);
24 const [showExpanded, setShowExpanded] = useState(false);
25
26 const handleDialogChange = useCallback((open: boolean) => {
27 dialogOpenRef.current = open;
28 setShowExpanded(open);
29 }, []);
30
31 // Ensure proper cleanup
32 useEffect(() => {
33 return () => {
34 dialogOpenRef.current = false;
35 };
36 }, []);
37
38 const togglePlay = useCallback(async () => {
39 if (!player || !deviceId || !accessToken) return;
40
41 try {
42 const token = (await refreshToken()) || accessToken;
43 if (!token) return;
44
45 if (isPaused && !currentTrack) {
46 const response = await fetch(
47 `https://api.spotify.com/v1/me/player/play?device_id=${deviceId}`,
48 {
49 method: "PUT",
50 headers: {
51 "Content-Type": "application/json",
52 Authorization: `Bearer ${token}`,
53 },
54 body: JSON.stringify({
55 context_uri: playlistUri,
56 position_ms: 0,
57 }),
58 },
59 );
60
61 if (!response.ok) {
62 throw new Error("Failed to start playback");
63 }
64 } else {
65 await player.togglePlay();
66 }
67 } catch (error) {
68 console.error("Error controlling playback:", error);
69 }
70 }, [
71 player,
72 deviceId,
73 currentTrack,
74 isPaused,
75 accessToken,
76 refreshToken,
77 playlistUri,
78 ]);
79
80 if (!player || !accessToken) return null;
81
82 return (
83 <Dialog open={showExpanded} onOpenChange={handleDialogChange}>
84 <motion.div
85 className={cn(
86 "flex items-center gap-2",
87 "text-white/60 hover:text-white/80 transition-colors",
88 className,
89 )}
90 initial={false}
91 >
92 <motion.button
93 onClick={togglePlay}
94 className="flex items-center justify-center w-8 h-8 bg-white/5 rounded-full hover:bg-white/10"
95 whileTap={{ scale: 0.95 }}
96 transition={{ duration: 0.1 }}
97 aria-label={isPaused ? "Play" : "Pause"}
98 >
99 <motion.div
100 key={isPaused ? "play" : "pause"}
101 initial={false}
102 animate={{ scale: 1, opacity: 1 }}
103 transition={{ duration: 0.1 }}
104 >
105 {isPaused ? (
106 <Play className="w-4 h-4" />
107 ) : (
108 <Pause className="w-4 h-4" />
109 )}
110 </motion.div>
111 </motion.button>
112
113 {currentTrack && (
114 <DialogTrigger asChild>
115 <motion.button
116 initial={{ opacity: 0 }}
117 animate={{ opacity: 0.6 }}
118 whileHover={{ opacity: 1 }}
119 transition={{ duration: 0.2 }}
120 className="text-xs sm:text-sm truncate max-w-[120px] text-left flex items-center gap-2 group"
121 >
122 <span>{currentTrack.name}</span>
123 <ChevronUp className="w-3 h-3 opacity-0 group-hover:opacity-60 transition-opacity" />
124 </motion.button>
125 </DialogTrigger>
126 )}
127 </motion.div>
128
129 <DialogContent
130 className="bg-black/40 backdrop-blur-xl border-t border-white/10 sm:max-w-[600px] p-6"
131 onOpenAutoFocus={(e) => e.preventDefault()}
132 onEscapeKeyDown={() => handleDialogChange(false)}
133 onPointerDownOutside={() => handleDialogChange(false)}
134 >
135 <ExpandedSpotifyView />
136 </DialogContent>
137 </Dialog>
138 );
139 }
140
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&apos;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 }↵

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs