From c47a9f8df623b8a46a706598311e1537541f24b9 Mon Sep 17 00:00:00 2001 From: Brandon Saldan <26472557+brandonsaldan@users.noreply.github.com> Date: Sat, 23 Nov 2024 23:28:29 -0500 Subject: [PATCH 01/11] chore: revert to e972b1e --- src/components/AuthSelection.jsx | 220 +----- src/components/DonationQRModal.jsx | 66 -- src/components/ResetTimerOverlay.jsx | 76 -- src/components/Settings.jsx | 62 +- src/constants/errorCodes.js | 40 -- src/lib/colorUtils.js | 103 --- src/lib/supabaseClient.js | 19 +- src/pages/_app.jsx | 655 ++++++++---------- src/pages/album/[albumId].jsx | 4 +- src/pages/api/v1/auth/token.js | 31 +- src/pages/api/v1/auth/validate-credentials.js | 74 +- src/pages/collection/tracks.jsx | 49 +- src/pages/index.jsx | 10 +- src/pages/playlist/[playlistId].jsx | 4 +- 14 files changed, 384 insertions(+), 1029 deletions(-) delete mode 100644 src/components/DonationQRModal.jsx delete mode 100644 src/components/ResetTimerOverlay.jsx delete mode 100644 src/constants/errorCodes.js delete mode 100644 src/lib/colorUtils.js diff --git a/src/components/AuthSelection.jsx b/src/components/AuthSelection.jsx index 6f076fb..7c6bd79 100644 --- a/src/components/AuthSelection.jsx +++ b/src/components/AuthSelection.jsx @@ -1,34 +1,14 @@ import { useState, useEffect } from "react"; -import { supabase } from "../lib/supabaseClient"; -import { Eye, EyeOff } from "lucide-react"; -import ErrorAlert from "./ErrorAlert"; import QRAuthFlow from "./QRAuthFlow"; import packageInfo from "../../package.json"; const AuthMethodSelector = ({ onSelect }) => { - const [showCustomForm, setShowCustomForm] = useState(false); const [showQRFlow, setShowQRFlow] = useState(false); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [showClientSecret, setShowClientSecret] = useState(false); - const [alert, setAlert] = useState(null); const [buttonsVisible, setButtonsVisible] = useState(true); - const [formVisible, setFormVisible] = useState(false); - const [isValidating, setIsValidating] = useState(false); - const [showDefaultButton, setShowDefaultButton] = useState(false); const [defaultButtonVisible, setDefaultButtonVisible] = useState(false); + const [showDefaultButton, setShowDefaultButton] = useState(false); const [escapeKeyTimer, setEscapeKeyTimer] = useState(null); - useEffect(() => { - if (showCustomForm) { - setButtonsVisible(false); - setTimeout(() => setFormVisible(true), 250); - } else { - setFormVisible(false); - setTimeout(() => setButtonsVisible(true), 250); - } - }, [showCustomForm]); - useEffect(() => { if (showDefaultButton) { setTimeout(() => setDefaultButtonVisible(true), 50); @@ -64,105 +44,12 @@ const AuthMethodSelector = ({ onSelect }) => { }; }, [escapeKeyTimer]); - useEffect(() => { - if (showCustomForm) { - setButtonsVisible(false); - setTimeout(() => setFormVisible(true), 250); - } else { - setFormVisible(false); - setTimeout(() => setButtonsVisible(true), 250); - } - }, [showCustomForm]); - - const validateSpotifyCredentials = async (clientId, clientSecret, tempId) => { - try { - const response = await fetch("/api/v1/auth/validate-credentials", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - clientId, - clientSecret, - tempId, - }), - }); - - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || "Failed to validate credentials"); - } - - return true; - } catch (error) { - console.error("Validation error:", error); - setAlert({ - message: - error.message || - "Invalid credentials. Please check your Client ID and Client Secret.", - }); - return false; - } - }; - - const generateSecureTempId = () => { - const array = new Uint8Array(16); - crypto.getRandomValues(array); - return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join( - "" - ); - }; - - const handleCustomSubmit = async (e) => { - e.preventDefault(); - if (!clientId.trim() || !clientSecret.trim()) { - setAlert({ - message: "Please enter both Client ID and Client Secret", - }); - return; - } - - try { - setIsValidating(true); - - const tempId = generateSecureTempId(); - const isValid = await validateSpotifyCredentials( - clientId.trim(), - clientSecret.trim(), - tempId - ); - - if (!isValid) { - return; - } - - localStorage.setItem("spotifyAuthType", "custom"); - localStorage.setItem("spotifyTempId", tempId); - onSelect({ type: "custom", tempId }); - } catch (err) { - setAlert({ - message: "Failed to store credentials. Please try again.", - }); - localStorage.removeItem("spotifyAuthType"); - localStorage.removeItem("spotifyTempId"); - } finally { - setIsValidating(false); - } - }; - const handleDefaultSubmit = (e) => { e.preventDefault(); localStorage.setItem("spotifyAuthType", "default"); onSelect({ type: "default" }); }; - const handleBackClick = () => { - setShowCustomForm(false); - setClientId(""); - setClientSecret(""); - setAlert(null); - }; - const NocturneIcon = ({ className }) => ( {
+
+ +
{ > - -
-
-
-

+

{packageInfo.version}

- -
-
-
- setClientId(e.target.value)} - required - placeholder="Client ID" - disabled={isValidating} - className="block w-full rounded-2xl border-0 bg-black/10 py-4 px-6 text-white shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-white/20 ring-white/10 text-[32px] sm:leading-6" - /> -
-
- -
-
- setClientSecret(e.target.value)} - required - placeholder="Client Secret" - disabled={isValidating} - className="block w-full rounded-2xl border-0 bg-black/10 py-4 px-6 text-white shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-white/20 ring-white/10 text-[32px] sm:leading-6" - /> - -
- setAlert(null)} /> -
- -
- - -
-
{showQRFlow && ( { - const [isVisible, setIsVisible] = useState(false); - const [isExiting, setIsExiting] = useState(false); - - React.useEffect(() => { - setIsVisible(true); - }, []); - - const handleClose = () => { - setIsExiting(true); - setTimeout(() => { - onClose(); - }, 300); - }; - - return ( -
-
-
-
- -
-
- -
-
-

Support Nocturne

-

- This QR code will open a link to the Nocturne donation page on - your phone. -

-
-
-
-
-
- ); -}; - -export default DonationQRModal; diff --git a/src/components/ResetTimerOverlay.jsx b/src/components/ResetTimerOverlay.jsx deleted file mode 100644 index 5b800bb..0000000 --- a/src/components/ResetTimerOverlay.jsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { X } from "lucide-react"; - -const ResetTimerOverlay = ({ duration, startTime, onCancel }) => { - const [progress, setProgress] = useState(0); - const [isVisible, setIsVisible] = useState(false); - const [isExiting, setIsExiting] = useState(false); - - useEffect(() => { - setIsVisible(true); - const interval = setInterval(() => { - const elapsed = Date.now() - startTime; - const newProgress = Math.min((elapsed / duration) * 100, 100); - setProgress(newProgress); - }, 16); - - return () => clearInterval(interval); - }, [duration, startTime]); - - const secondsLeft = Math.ceil( - (duration - (progress / 100) * duration) / 1000 - ); - - const handleClose = () => { - setIsExiting(true); - setTimeout(() => { - onCancel?.(); - }, 300); - }; - - return ( -
-
-
-
- -
-
-

- Reset Application -

-

- Hold the buttons for {secondsLeft} seconds to reset -

-
-
-
-
-
-
-
-
- ); -}; - -export default ResetTimerOverlay; diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx index 18cbd2c..b49541f 100644 --- a/src/components/Settings.jsx +++ b/src/components/Settings.jsx @@ -3,9 +3,8 @@ import { useState, useEffect } from "react"; import { Field, Label, Switch } from "@headlessui/react"; import { useRouter } from "next/router"; -import { supabase } from "../lib/supabaseClient"; -export default function Settings({ onOpenDonationModal }) { +export default function Settings() { const router = useRouter(); const [trackNameScrollingEnabled, setTrackNameScrollingEnabled] = useState( () => { @@ -39,42 +38,12 @@ export default function Settings({ onOpenDonationModal }) { } }, []); - const handleSignOut = async () => { - try { - const refreshToken = localStorage.getItem("spotifyRefreshToken"); - const tempId = localStorage.getItem("spotifyTempId"); - const authType = localStorage.getItem("spotifyAuthType"); - - if (authType === "custom" && refreshToken && tempId) { - const { error } = await supabase - .from("spotify_credentials") - .delete() - .match({ - temp_id: tempId, - refresh_token: refreshToken, - }); - - if (error) { - console.error("Error removing credentials from database:", error); - } - } - - localStorage.removeItem("spotifyAccessToken"); - localStorage.removeItem("spotifyRefreshToken"); - localStorage.removeItem("spotifyTokenExpiry"); - localStorage.removeItem("spotifyAuthType"); - localStorage.removeItem("spotifyTempId"); - - router.push("/").then(() => { - window.location.reload(); - }); - } catch (error) { - console.error("Error during sign out:", error); - localStorage.clear(); - router.push("/").then(() => { - window.location.reload(); - }); - } + const handleSignOut = () => { + localStorage.removeItem("spotifyAuthType"); + localStorage.removeItem("spotifyTempId"); + router.push("/").then(() => { + window.location.reload(); + }); }; return ( @@ -133,23 +102,10 @@ export default function Settings({ onOpenDonationModal }) {
-
- -

- Support Nocturne by donating to the project. Thank you! -

-
-
+

Authentication Successful

You can close this window and return to Nocturne.

+
This window will close automatically...
`; @@ -1153,8 +1091,13 @@ export default function App({ Component, pageProps }) { if (accessToken) { const checkTokenExpiry = async () => { const tokenExpiry = localStorage.getItem("spotifyTokenExpiry"); + const currentTime = new Date(); + const expiryTime = new Date(tokenExpiry); - if (tokenExpiry && new Date(tokenExpiry) <= new Date()) { + if ( + !tokenExpiry || + expiryTime <= new Date(currentTime.getTime() + 5 * 60000) + ) { try { const refreshData = await refreshAccessToken(); if (refreshData.access_token) { @@ -1180,17 +1123,22 @@ export default function App({ Component, pageProps }) { } } catch (error) { console.error("Token refresh failed:", error); - clearSession(); - redirectToSpotify(); + if (error.message.includes("invalid_grant")) { + clearSession(); + redirectToSpotify(); + } } } }; - const tokenRefreshInterval = setInterval(checkTokenExpiry, 3000 * 1000); + checkTokenExpiry(); + const tokenRefreshInterval = setInterval(checkTokenExpiry, 5 * 60 * 1000); + const playbackInterval = setInterval(() => { + fetchCurrentPlayback(); + }, 1000); setLoading(false); - checkTokenExpiry(); fetchRecentlyPlayedAlbums( accessToken, setAlbums, @@ -1211,10 +1159,6 @@ export default function App({ Component, pageProps }) { ); fetchUserRadio(accessToken, setRadio, updateGradientColors, handleError); - const playbackInterval = setInterval(() => { - fetchCurrentPlayback(); - }, 1000); - const recentlyPlayedInterval = setInterval(() => { fetchRecentlyPlayedAlbums( accessToken, @@ -1253,130 +1197,101 @@ export default function App({ Component, pageProps }) { } }, [router.pathname, currentPlayback]); - const clearSession = async () => { - try { - const refreshToken = localStorage.getItem("spotifyRefreshToken"); - const tempId = localStorage.getItem("spotifyTempId"); - const authType = localStorage.getItem("spotifyAuthType"); - - if (authType === "custom" && refreshToken && tempId) { - const supabaseInstance = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY - ); - - let { error } = await supabaseInstance - .from("spotify_credentials") - .delete() - .match({ - temp_id: tempId, - refresh_token: refreshToken, - }); + const clearSession = () => { + localStorage.removeItem("spotifyAccessToken"); + localStorage.removeItem("spotifyRefreshToken"); + localStorage.removeItem("spotifyTokenExpiry"); + localStorage.removeItem("spotifyAuthType"); + setAccessToken(null); + setRefreshToken(null); + setAuthState({ + authSelectionMade: false, + authType: null, + }); + }; - if (!data && localStorage.getItem("refreshToken")) { - ({ data, error } = await supabaseInstance - .from("spotify_credentials") - .select("client_id, temp_id") - .eq("refresh_token", localStorage.getItem("refreshToken")) - .order("created_at", { ascending: false }) - .limit(1) - .single()); - - if (data?.temp_id) { - setTempId(data.temp_id); - localStorage.setItem("spotifyTempId", data.temp_id); + const updateGradientColors = useCallback( + (imageUrl, section = null) => { + if (!imageUrl) { + if (section === "radio") { + const radioColors = ["#223466", "#1f2d57", "#be54a6", "#1e2644"]; + setSectionGradients((prev) => ({ ...prev, [section]: radioColors })); + if (activeSection === "radio" || activeSection === "nowPlaying") { + setTargetColor1(radioColors[0]); + setTargetColor2(radioColors[1]); + setTargetColor3(radioColors[2]); + setTargetColor4(radioColors[3]); + } + } else if (section === "library") { + const libraryColors = ["#7662e9", "#a9c1de", "#8f90e3", "#5b30ef"]; + setSectionGradients((prev) => ({ + ...prev, + [section]: libraryColors, + })); + if (activeSection === "library") { + setTargetColor1(libraryColors[0]); + setTargetColor2(libraryColors[1]); + setTargetColor3(libraryColors[2]); + setTargetColor4(libraryColors[3]); + } + } else if ( + section === "settings" || + router.pathname === "/now-playing" + ) { + const settingsColors = ["#191414", "#191414", "#191414", "#191414"]; + setSectionGradients((prev) => ({ + ...prev, + [section]: settingsColors, + })); + if ( + activeSection === "settings" || + router.pathname === "/now-playing" + ) { + setTargetColor1(settingsColors[0]); + setTargetColor2(settingsColors[1]); + setTargetColor3(settingsColors[2]); + setTargetColor4(settingsColors[3]); } } - - if (error) { - console.error("Error removing credentials from database:", error); - } + return; } - localStorage.removeItem("spotifyAccessToken"); - localStorage.removeItem("spotifyRefreshToken"); - localStorage.removeItem("spotifyTokenExpiry"); - localStorage.removeItem("spotifyAuthType"); - localStorage.removeItem("spotifyTempId"); - - setAccessToken(null); - setRefreshToken(null); - setAuthState({ - authSelectionMade: false, - authType: null, - }); - } catch (error) { - console.error("Error during session cleanup:", error); - localStorage.clear(); - setAccessToken(null); - setRefreshToken(null); - setAuthState({ - authSelectionMade: false, - authType: null, - }); - } - }; + const colorThief = new ColorThief(); + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = imageUrl; + img.onload = () => { + const dominantColors = colorThief.getPalette(img, 4); + const hexColors = dominantColors.map((color) => + rgbToHex({ r: color[0], g: color[1], b: color[2] }) + ); - const updateGradientColors = useCallback((imageUrl, section = null) => { - if (!imageUrl) { - if (section === "radio") { - const radioColors = ["#223466", "#1f2d57", "#be54a6", "#1e2644"]; - setSectionGradients((prev) => ({ ...prev, [section]: radioColors })); - if (activeSection === "radio" || activeSection === "nowPlaying") { - setTargetColor1(radioColors[0]); - setTargetColor2(radioColors[1]); - setTargetColor3(radioColors[2]); - setTargetColor4(radioColors[3]); - } - } else if (section === "library") { - const libraryColors = ["#7662e9", "#a9c1de", "#8f90e3", "#5b30ef"]; setSectionGradients((prev) => ({ ...prev, - [section]: libraryColors, - })); - if (activeSection === "library") { - setTargetColor1(libraryColors[0]); - setTargetColor2(libraryColors[1]); - setTargetColor3(libraryColors[2]); - setTargetColor4(libraryColors[3]); - } - } else if (section === "settings" || router.pathname === "/now-playing") { - const settingsColors = ["#191414", "#191414", "#191414", "#191414"]; - setSectionGradients((prev) => ({ - ...prev, - [section]: settingsColors, + [section]: hexColors, })); + if ( - activeSection === "settings" || - router.pathname === "/now-playing" + section === activeSection || + section === "nowPlaying" || + activeSection === "nowPlaying" ) { - setTargetColor1(settingsColors[0]); - setTargetColor2(settingsColors[1]); - setTargetColor3(settingsColors[2]); - setTargetColor4(settingsColors[3]); + setTargetColor1(hexColors[0]); + setTargetColor2(hexColors[1]); + setTargetColor3(hexColors[2]); + setTargetColor4(hexColors[3]); } - } - return; - } - - extractPaletteFromImage(imageUrl).then((colors) => { - setSectionGradients((prev) => ({ - ...prev, - [section]: colors, - })); - - if ( - section === activeSection || - section === "nowPlaying" || - activeSection === "nowPlaying" - ) { - setTargetColor1(colors[0]); - setTargetColor2(colors[1]); - setTargetColor3(colors[2]); - setTargetColor4(colors[3]); - } - }); - }, [activeSection, router.pathname, setTargetColor1, setTargetColor2, setTargetColor3, setTargetColor4][(activeSection, router.pathname)]); + }; + }, + [ + activeSection, + router.pathname, + setTargetColor1, + setTargetColor2, + setTargetColor3, + setTargetColor4, + ] + ); useEffect(() => { const current1 = hexToRgb(currentColor1); @@ -1627,12 +1542,6 @@ export default function App({ Component, pageProps }) { onClose={() => setShowMappingOverlay(false)} activeButton={pressedButton} /> - {showResetTimer && ( - - )} )} diff --git a/src/pages/album/[albumId].jsx b/src/pages/album/[albumId].jsx index db45f2e..4700d27 100644 --- a/src/pages/album/[albumId].jsx +++ b/src/pages/album/[albumId].jsx @@ -345,7 +345,9 @@ const AlbumPage = ({
))} - {isLoading &&
} + {isLoading && ( +

Loading more tracks...

+ )}
); diff --git a/src/pages/api/v1/auth/token.js b/src/pages/api/v1/auth/token.js index 9ba6230..16bf40d 100644 --- a/src/pages/api/v1/auth/token.js +++ b/src/pages/api/v1/auth/token.js @@ -3,7 +3,6 @@ import { encrypt, decrypt } from '@/lib/cryptoUtils'; export const runtime = 'experimental-edge'; export default async function handler(req) { - if (req.method !== 'POST') { return new Response('Method Not Allowed', { status: 405, @@ -13,8 +12,7 @@ export default async function handler(req) { try { const body = await req.json(); - - const { code, tempId, isCustomAuth, isPhoneAuth, sessionId } = body; + const { code, isPhoneAuth, sessionId } = body; if (!code) { console.error('Missing authorization code'); @@ -30,22 +28,12 @@ export default async function handler(req) { process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ); - - const query = supabase - .from('spotify_credentials') - .select('client_id, encrypted_client_secret, temp_id'); - if (isPhoneAuth && sessionId) { - query.eq('session_id', sessionId); - } else if ((isCustomAuth || isPhoneAuth) && tempId) { - query.eq('temp_id', tempId); - } else { - useClientId = process.env.NEXT_PUBLIC_SPOTIFY_CLIENT_ID; - useClientSecret = process.env.SPOTIFY_CLIENT_SECRET; - } - - if (!useClientId || !useClientSecret) { - const { data: credentials, error: credentialsError } = await query.maybeSingle(); + const { data: credentials, error: credentialsError } = await supabase + .from('spotify_credentials') + .select('client_id, encrypted_client_secret') + .eq('session_id', sessionId) + .maybeSingle(); if (credentialsError) { console.error('Database query error:', credentialsError); @@ -73,6 +61,9 @@ export default async function handler(req) { { status: 500, headers: { 'Content-Type': 'application/json' } } ); } + } else { + useClientId = process.env.NEXT_PUBLIC_SPOTIFY_CLIENT_ID; + useClientSecret = process.env.SPOTIFY_CLIENT_SECRET; } if (!useClientId || !useClientSecret) { @@ -132,9 +123,6 @@ export default async function handler(req) { ); } - } - - if ((isCustomAuth || isPhoneAuth) && data.refresh_token) { try { const { error: cleanupError } = await supabase .from('spotify_credentials') @@ -157,7 +145,6 @@ export default async function handler(req) { expires_in: data.expires_in, token_type: data.token_type, scope: data.scope, - isCustomAuth, isPhoneAuth }), { diff --git a/src/pages/api/v1/auth/validate-credentials.js b/src/pages/api/v1/auth/validate-credentials.js index 203a37c..31af072 100644 --- a/src/pages/api/v1/auth/validate-credentials.js +++ b/src/pages/api/v1/auth/validate-credentials.js @@ -1,5 +1,3 @@ -import { encrypt } from '@/lib/cryptoUtils'; -import { createClient } from '@supabase/supabase-js'; export const runtime = 'experimental-edge'; export default async function handler(req) { @@ -11,9 +9,7 @@ export default async function handler(req) { } try { - - const body = await req.json(); - const { clientId, clientSecret, tempId, isPhoneAuth } = body; + const { clientId, clientSecret, isPhoneAuth } = await req.json(); if (!clientId || !clientSecret) { return new Response( @@ -25,9 +21,9 @@ export default async function handler(req) { ); } - if (!isPhoneAuth && !tempId) { + if (!isPhoneAuth) { return new Response( - JSON.stringify({ error: 'Temp ID is required for this authentication method' }), + JSON.stringify({ error: 'Only phone authentication is supported' }), { status: 400, headers: { 'Content-Type': 'application/json' } @@ -65,70 +61,6 @@ export default async function handler(req) { ); } - if (isPhoneAuth) { - return new Response( - JSON.stringify({ success: true }), - { - status: 200, - headers: { 'Content-Type': 'application/json' } - } - ); - } - - const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY - ); - - let encryptedSecret; - try { - encryptedSecret = await encrypt(clientSecret); - } catch (encryptError) { - console.error('Encryption failed:', { - errorType: encryptError.constructor.name, - }); - throw new Error('Failed to encrypt credentials'); - } - - const { data: existingRecord, error: checkError } = await supabase - .from('spotify_credentials') - .select('id') - .eq('temp_id', tempId) - .single(); - - if (checkError) { - console.error('Database check error:', { - code: checkError.code, - }); - } - - if (existingRecord) { - return new Response( - JSON.stringify({ error: 'This ID is already in use' }), - { - status: 400, - headers: { 'Content-Type': 'application/json' } - } - ); - } - - const { error: insertError } = await supabase - .from('spotify_credentials') - .insert({ - client_id: clientId, - encrypted_client_secret: encryptedSecret, - temp_id: tempId, - created_at: new Date().toISOString(), - expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() - }); - - if (insertError) { - console.error('Database insert error:', { - code: insertError.code, - }); - throw new Error('Failed to store credentials'); - } - return new Response( JSON.stringify({ success: true }), { diff --git a/src/pages/collection/tracks.jsx b/src/pages/collection/tracks.jsx index 173ec86..6c7e3d5 100644 --- a/src/pages/collection/tracks.jsx +++ b/src/pages/collection/tracks.jsx @@ -4,6 +4,7 @@ import LongPressLink from "../../components/LongPressLink"; import Image from "next/image"; import SuccessAlert from "../../components/SuccessAlert"; export const runtime = "experimental-edge"; + const LikedSongsPage = ({ initialTracks, currentlyPlayingTrackUri, @@ -23,17 +24,22 @@ const LikedSongsPage = ({ const observer = useRef(); const [showSuccess, setShowSuccess] = useState(false); const [pressedButton, setPressedButton] = useState(null); + useEffect(() => { updateGradientColors(null, "library"); }, [updateGradientColors]); + useEffect(() => { const validKeys = ["1", "2", "3", "4"]; const holdDuration = 2000; const holdTimeouts = {}; const pressStartTimes = {}; + const handleKeyDown = (event) => { if (!validKeys.includes(event.key) || event.repeat) return; + pressStartTimes[event.key] = Date.now(); + holdTimeouts[event.key] = setTimeout(() => { const currentUrl = window.location.pathname; localStorage.setItem(`button${event.key}Map`, currentUrl); @@ -41,20 +47,26 @@ const LikedSongsPage = ({ `button${event.key}Image`, "https://misc.scdn.co/liked-songs/liked-songs-640.png" ); + setPressedButton(event.key); setShowSuccess(true); }, holdDuration); }; + const handleKeyUp = (event) => { if (!validKeys.includes(event.key)) return; + if (holdTimeouts[event.key]) { clearTimeout(holdTimeouts[event.key]); delete holdTimeouts[event.key]; } + delete pressStartTimes[event.key]; }; + window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); + return () => { Object.values(holdTimeouts).forEach( (timeout) => timeout && clearTimeout(timeout) @@ -63,11 +75,13 @@ const LikedSongsPage = ({ window.removeEventListener("keyup", handleKeyUp); }; }, []); + useEffect(() => { if (error) { handleError(error.type, error.message); } }, [error, handleError]); + const lastTrackElementRef = useCallback( (node) => { if (isLoading) return; @@ -81,6 +95,7 @@ const LikedSongsPage = ({ }, [isLoading, hasMore] ); + useEffect(() => { const fetchPlaybackState = async () => { try { @@ -97,6 +112,7 @@ const LikedSongsPage = ({ handleError("FETCH_PLAYBACK_STATE_ERROR", error.message); } }; + fetchPlaybackState(); }, [accessToken]); @@ -117,6 +133,7 @@ const LikedSongsPage = ({ setIsLoading(true); const offset = tracks.length; const limit = 25; + try { const response = await fetch( `https://api.spotify.com/v1/me/tracks?offset=${offset}&limit=${limit}`, @@ -126,9 +143,11 @@ const LikedSongsPage = ({ }, } ); + if (!response.ok) { throw new Error("Failed to fetch more tracks"); } + const data = await response.json(); if (data.items.length === 0) { setHasMore(false); @@ -142,6 +161,7 @@ const LikedSongsPage = ({ setIsLoading(false); } }; + const playLikedSongs = async () => { try { const devicesResponse = await fetch( @@ -152,7 +172,9 @@ const LikedSongsPage = ({ }, } ); + const devicesData = await devicesResponse.json(); + if (devicesData.devices.length === 0) { handleError( "NO_DEVICES_AVAILABLE", @@ -160,8 +182,10 @@ const LikedSongsPage = ({ ); return; } + const device = devicesData.devices[0]; const activeDeviceId = device.id; + if (!device.is_active) { await fetch("https://api.spotify.com/v1/me/player", { method: "PUT", @@ -175,6 +199,7 @@ const LikedSongsPage = ({ }), }); } + await fetch( `https://api.spotify.com/v1/me/player/shuffle?state=${isShuffleEnabled}`, { @@ -184,6 +209,7 @@ const LikedSongsPage = ({ }, } ); + let offset; if (isShuffleEnabled) { const randomPosition = Math.floor(Math.random() * tracks.length); @@ -191,6 +217,7 @@ const LikedSongsPage = ({ } else { offset = { position: 0 }; } + await fetch("https://api.spotify.com/v1/me/player/play", { method: "PUT", headers: { @@ -203,11 +230,13 @@ const LikedSongsPage = ({ device_id: activeDeviceId, }), }); + router.push("/now-playing"); } catch (error) { handleError("PLAY_LIKED_SONGS_ERROR", error.message); } }; + const playTrack = async (trackUri, trackIndex) => { try { const devicesResponse = await fetch( @@ -218,7 +247,9 @@ const LikedSongsPage = ({ }, } ); + const devicesData = await devicesResponse.json(); + if (devicesData.devices.length === 0) { handleError( "NO_DEVICES_AVAILABLE", @@ -226,8 +257,10 @@ const LikedSongsPage = ({ ); return; } + const device = devicesData.devices[0]; const activeDeviceId = device.id; + if (!device.is_active) { await fetch("https://api.spotify.com/v1/me/player", { method: "PUT", @@ -241,6 +274,7 @@ const LikedSongsPage = ({ }), }); } + await fetch( `https://api.spotify.com/v1/me/player/shuffle?state=${isShuffleEnabled}`, { @@ -250,6 +284,7 @@ const LikedSongsPage = ({ }, } ); + await fetch("https://api.spotify.com/v1/me/player/play", { method: "PUT", headers: { @@ -262,11 +297,13 @@ const LikedSongsPage = ({ device_id: activeDeviceId, }), }); + router.push("/now-playing"); } catch (error) { handleError("PLAY_TRACK_ERROR", error.message); } }; + return (
@@ -292,6 +329,7 @@ const LikedSongsPage = ({
+
{tracks.map((item, index) => (
{index + 1}

)}
+
))} - {isLoading &&
} + {isLoading && ( +

Loading more tracks...

+ )}
); }; + export async function getServerSideProps(context) { const accessToken = context.query.accessToken; + try { const res = await fetch(`https://api.spotify.com/v1/me/tracks?limit=25`, { headers: { Authorization: `Bearer ${accessToken}`, }, }); + if (!res.ok) { throw new Error("Failed to fetch liked songs"); } + const tracksData = await res.json(); + return { props: { initialTracks: tracksData, @@ -392,4 +438,5 @@ export async function getServerSideProps(context) { }; } } + export default LikedSongsPage; diff --git a/src/pages/index.jsx b/src/pages/index.jsx index 2b73c5a..d49a549 100644 --- a/src/pages/index.jsx +++ b/src/pages/index.jsx @@ -4,7 +4,6 @@ import LongPressLink from "../components/LongPressLink"; import { useEffect, useRef, useState } from "react"; import Image from "next/image"; import { fetchLikedSongs } from "../services/playlistService"; -import DonationQRModal from "../components/DonationQRModal"; export default function Home({ accessToken, @@ -20,7 +19,6 @@ export default function Home({ showBrightnessOverlay, handleError, }) { - const [showDonationModal, setShowDonationModal] = useState(false); useEffect(() => { if (activeSection === "radio") { updateGradientColors(null, "radio"); @@ -360,19 +358,13 @@ export default function Home({ ))} {activeSection === "settings" && (
- setShowDonationModal(true)} - /> +
)}
)} - {showDonationModal && ( - setShowDonationModal(false)} /> - )} ); } diff --git a/src/pages/playlist/[playlistId].jsx b/src/pages/playlist/[playlistId].jsx index 9c9326f..4d40dd5 100644 --- a/src/pages/playlist/[playlistId].jsx +++ b/src/pages/playlist/[playlistId].jsx @@ -465,7 +465,9 @@ const PlaylistPage = ({ ))} - {isLoading &&
} + {isLoading && ( +

Loading more tracks...

+ )}
Date: Sat, 23 Nov 2024 23:36:08 -0500 Subject: [PATCH 02/11] feat(app): add hard reset button combo --- src/pages/_app.jsx | 197 +++++++++++++++---------- src/pages/api/v1/auth/refresh-token.js | 146 +++++++----------- 2 files changed, 167 insertions(+), 176 deletions(-) diff --git a/src/pages/_app.jsx b/src/pages/_app.jsx index d10a677..f19aac9 100644 --- a/src/pages/_app.jsx +++ b/src/pages/_app.jsx @@ -133,6 +133,12 @@ export default function App({ Component, pageProps }) { const [brightness, setBrightness] = useState(160); const [showBrightnessOverlay, setShowBrightnessOverlay] = useState(false); const { authSelectionMade, authType, tempId } = authState; + const keyStatesRef = useRef({ + 4: false, + Escape: false, + }); + const resetTimerRef = useRef(null); + const startTimeRef = useRef(null); useEffect(() => { if (accessToken) { @@ -430,47 +436,28 @@ export default function App({ Component, pageProps }) { const refreshAccessToken = async () => { try { - const currentRefreshToken = localStorage.getItem("spotifyRefreshToken"); - const currentAuthType = localStorage.getItem("spotifyAuthType"); - const currentTempId = localStorage.getItem("spotifyTempId"); - const response = await fetch("/api/v1/auth/refresh-token", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - refresh_token: currentRefreshToken, - isCustomAuth: currentAuthType === "custom", - tempId: currentTempId, + refresh_token: refreshToken, + isCustomAuth: authType === "custom", }), }); if (!response.ok) { - const errorData = await response.json(); - console.error("Refresh token error:", { - status: response.status, - data: errorData, - }); throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); - setAccessToken(data.access_token); if (data.refresh_token) { setRefreshToken(data.refresh_token); - localStorage.setItem("spotifyRefreshToken", data.refresh_token); } - - const newExpiry = new Date( - Date.now() + data.expires_in * 1000 - ).toISOString(); - localStorage.setItem("spotifyTokenExpiry", newExpiry); - return data; } catch (error) { - console.error("Token refresh failed:", error); handleError("REFRESH_ACCESS_TOKEN_ERROR", error.message); throw error; } @@ -555,87 +542,138 @@ export default function App({ Component, pageProps }) { }, [showBrightnessOverlay]); useEffect(() => { - const holdDuration = 2000; - const quickPressDuration = 200; - let holdTimer = null; - let hasTriggered = false; - let lastMPressTime = 0; - let mPressCount = 0; - let brightnessOverlayTimer = null; - let keyPressStartTime = null; + const handleWheel = (event) => { + if (showBrightnessOverlay) { + event.stopPropagation(); + event.preventDefault(); + setBrightness((prev) => { + const newValue = prev + (event.deltaX > 0 ? 5 : -5); + return Math.max(5, Math.min(250, newValue)); + }); + } + }; const handleKeyDown = (event) => { - if (event.key === "m" || event.key === "M") { - if (!keyPressStartTime) { - keyPressStartTime = Date.now(); + if ( + showBrightnessOverlay && + ["1", "2", "3", "4", "Escape", "Enter"].includes(event.key) + ) { + event.stopPropagation(); + event.preventDefault(); + + const existingTimeout = window.brightnessOverlayTimer; + if (existingTimeout) { + clearTimeout(existingTimeout); } + setShowBrightnessOverlay(false); + } + }; - const now = Date.now(); + const handleTouchMove = (event) => { + if (showBrightnessOverlay) { + event.preventDefault(); + event.stopPropagation(); + } + }; - if ( - now - lastMPressTime < 500 && - now - keyPressStartTime < quickPressDuration - ) { - mPressCount++; - if (mPressCount === 3) { - if (brightnessOverlayTimer) { - clearTimeout(brightnessOverlayTimer); - } + const handleTouchStart = (event) => { + if (showBrightnessOverlay) { + event.preventDefault(); + event.stopPropagation(); + } + }; - setShowBrightnessOverlay(true); + document.addEventListener("wheel", handleWheel, { + passive: false, + capture: true, + }); + document.addEventListener("keydown", handleKeyDown, { capture: true }); + document.addEventListener("touchmove", handleTouchMove, { passive: false }); + document.addEventListener("touchstart", handleTouchStart, { + passive: false, + }); - brightnessOverlayTimer = setTimeout(() => { - setShowBrightnessOverlay(false); - }, 300000); + return () => { + document.removeEventListener("wheel", handleWheel, { capture: true }); + document.removeEventListener("keydown", handleKeyDown, { capture: true }); + document.removeEventListener("touchmove", handleTouchMove); + document.removeEventListener("touchstart", handleTouchStart); + }; + }, [showBrightnessOverlay]); - mPressCount = 0; - lastMPressTime = 0; - return; - } - } else { - mPressCount = 1; + useEffect(() => { + const resetDuration = 5000; + + const performReset = () => { + localStorage.removeItem("spotifyAuthType"); + localStorage.removeItem("spotifyTempId"); + localStorage.removeItem("spotifyAccessToken"); + localStorage.removeItem("spotifyRefreshToken"); + localStorage.removeItem("spotifyTokenExpiry"); + + Object.keys(localStorage).forEach((key) => { + if (key.startsWith("button") && key.endsWith("Map")) { + localStorage.removeItem(key); } - lastMPressTime = now; - - if (!hasTriggered && mPressCount < 2) { - holdTimer = setTimeout(() => { - if (router.pathname !== "/") { - router.push("/").then(() => { - setActiveSection("settings"); - }); - } else { - setActiveSection("settings"); + }); + + router.push("/").then(() => { + window.location.reload(); + }); + }; + + const handleKeyDown = (event) => { + if (event.key === "4" || event.key === "Escape") { + keyStatesRef.current[event.key] = true; + + if (keyStatesRef.current["4"] && keyStatesRef.current["Escape"]) { + if (!startTimeRef.current) { + startTimeRef.current = Date.now(); + + if (resetTimerRef.current) { + clearInterval(resetTimerRef.current); } - hasTriggered = true; - }, holdDuration); + + resetTimerRef.current = setInterval(() => { + const elapsedTime = Date.now() - startTimeRef.current; + + if ( + keyStatesRef.current["4"] && + keyStatesRef.current["Escape"] && + elapsedTime >= resetDuration + ) { + clearInterval(resetTimerRef.current); + performReset(); + } + }, 100); + } } } }; const handleKeyUp = (event) => { - if (event.key === "m" || event.key === "M") { - keyPressStartTime = null; - if (holdTimer) { - clearTimeout(holdTimer); + if (event.key === "4" || event.key === "Escape") { + keyStatesRef.current[event.key] = false; + + if (resetTimerRef.current) { + clearInterval(resetTimerRef.current); + resetTimerRef.current = null; } - hasTriggered = false; + startTimeRef.current = null; } }; - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("keyup", handleKeyUp); + window.addEventListener("keydown", handleKeyDown, { capture: true }); + window.addEventListener("keyup", handleKeyUp, { capture: true }); return () => { - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("keyup", handleKeyUp); - if (holdTimer) { - clearTimeout(holdTimer); - } - if (brightnessOverlayTimer) { - clearTimeout(brightnessOverlayTimer); + window.removeEventListener("keydown", handleKeyDown, { capture: true }); + window.removeEventListener("keyup", handleKeyUp, { capture: true }); + if (resetTimerRef.current) { + clearInterval(resetTimerRef.current); } }; - }, [router, setShowBrightnessOverlay, setActiveSection]); + }, [router]); useEffect(() => { const validKeys = ["1", "2", "3", "4"]; @@ -864,7 +902,6 @@ export default function App({ Component, pageProps }) { try { const stateData = JSON.parse(decodeURIComponent(state)); if (stateData.phoneAuth) { - localStorage.setItem("spotifySessionId", stateData.sessionId); const exchangeTokens = async () => { try { const tokenResponse = await fetch("/api/v1/auth/token", { diff --git a/src/pages/api/v1/auth/refresh-token.js b/src/pages/api/v1/auth/refresh-token.js index 59c36d7..5d4f8f9 100644 --- a/src/pages/api/v1/auth/refresh-token.js +++ b/src/pages/api/v1/auth/refresh-token.js @@ -14,7 +14,6 @@ export default async function handler(req) { const { refresh_token, isCustomAuth } = await req.json(); if (!refresh_token) { - console.error('Missing refresh token'); return new Response( JSON.stringify({ error: 'Refresh token is required' }), { @@ -24,79 +23,54 @@ export default async function handler(req) { ); } - let clientId, clientSecret, tempId; + let clientId, clientSecret; if (isCustomAuth) { - if (!supabase) { - console.error('Supabase client not initialized'); + const { error: cleanupError } = await supabase + .from('spotify_credentials') + .delete() + .eq('refresh_token', refresh_token) + .lt('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()); + + if (cleanupError) { + console.error('Error cleaning up old records:', cleanupError); + } + + const { data: credentials, error: fetchError } = await supabase + .from('spotify_credentials') + .select('client_id, encrypted_client_secret, temp_id') + .eq('refresh_token', refresh_token) + .order('created_at', { ascending: false }) + .limit(1) + .single(); + + if (fetchError || !credentials) { + console.error('Error fetching custom credentials:', fetchError); return new Response( - JSON.stringify({ error: 'Database connection error' }), + JSON.stringify({ error: 'Custom credentials not found' }), { - status: 500, + status: 400, headers: { 'Content-Type': 'application/json' } } ); } + clientId = credentials.client_id; try { - const { error: cleanupError } = await supabase - .from('spotify_credentials') - .delete() - .eq('refresh_token', refresh_token) - .lt('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()); - - if (cleanupError) { - console.error('Error cleaning up old records:', cleanupError); - } - - const { data: credentials, error: fetchError } = await supabase - .from('spotify_credentials') - .select('*') - .eq('refresh_token', refresh_token) - .order('created_at', { ascending: false }) - .limit(1) - .single(); - - if (fetchError || !credentials) { - console.error('Error fetching credentials:', { - error: fetchError, - hasCredentials: !!credentials, - refreshToken: refresh_token.substring(0, 10) + '...' - }); - return new Response( - JSON.stringify({ error: 'Custom credentials not found' }), - { - status: 400, - headers: { 'Content-Type': 'application/json' } - } - ); - } - - clientId = credentials.client_id; - tempId = credentials.temp_id; - - try { - clientSecret = await decrypt(credentials.encrypted_client_secret); - } catch (decryptError) { - console.error('Decryption error:', decryptError); - return new Response( - JSON.stringify({ error: 'Failed to decrypt credentials' }), - { - status: 500, - headers: { 'Content-Type': 'application/json' } - } - ); - } - } catch (dbError) { - console.error('Database operation error:', dbError); + clientSecret = await decrypt(credentials.encrypted_client_secret); + } catch (decryptError) { + console.error('Decryption error:', decryptError); return new Response( - JSON.stringify({ error: 'Database operation failed' }), + JSON.stringify({ error: 'Failed to decrypt credentials' }), { status: 500, headers: { 'Content-Type': 'application/json' } } ); } + + const tempId = credentials.temp_id; + req.tempId = tempId; } else { clientId = process.env.NEXT_PUBLIC_SPOTIFY_CLIENT_ID; clientSecret = process.env.SPOTIFY_CLIENT_SECRET; @@ -118,55 +92,39 @@ export default async function handler(req) { const data = await response.json(); if (!response.ok) { - console.error('Spotify token refresh failed:', { - status: response.status, - error: data, - clientIdPrefix: clientId.substring(0, 10) + '...' - }); return new Response(JSON.stringify(data), { status: response.status, headers: { 'Content-Type': 'application/json' } }); } - if (isCustomAuth) { + if (isCustomAuth && data.refresh_token) { const { data: oldRecord } = await supabase .from('spotify_credentials') .select('token_refresh_count, first_used_at') .eq('refresh_token', refresh_token) + .order('created_at', { ascending: false }) + .limit(1) .single(); - + const encryptedSecret = await encrypt(clientSecret); - const now = new Date(); - const tokenExpiry = new Date(now.getTime() + (data.expires_in * 1000)); - const expiresAt = new Date(now.getTime() + (7 * 24 * 60 * 60 * 1000)); - const updatePayload = { - access_token: data.access_token, - refresh_token: data.refresh_token || refresh_token, - encrypted_client_secret: encryptedSecret, - expires_at: expiresAt.toISOString(), - token_expiry: tokenExpiry.toISOString(), - last_used: now.toISOString(), - first_used_at: oldRecord?.first_used_at || now.toISOString(), - token_refresh_count: (oldRecord?.token_refresh_count || 0) + 1, - user_agent: req.headers.get('user-agent') || null - }; - - const { data: updatedRecord, error: updateError } = await supabase + const { error: updateError } = await supabase .from('spotify_credentials') - .update(updatePayload) - .eq('temp_id', tempId) - .eq('refresh_token', refresh_token) - .select() - .single(); + .update({ + refresh_token: data.refresh_token, + encrypted_client_secret: encryptedSecret, + expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + last_used: new Date().toISOString(), + first_used_at: oldRecord?.first_used_at || new Date().toISOString(), + token_refresh_count: (oldRecord?.token_refresh_count || 0) + 1, + user_agent: req.headers.get('user-agent') || null + }) + .eq('temp_id', req.tempId) + .eq('refresh_token', refresh_token); if (updateError) { - console.error('Failed to update Supabase:', { - error: updateError, - tempId, - refreshToken: refresh_token.substring(0, 10) + '...' - }); + console.error('Error updating credentials:', updateError); } } @@ -183,13 +141,9 @@ export default async function handler(req) { ); } catch (error) { - console.error('Unhandled error:', error); + console.error('Token refresh error:', error); return new Response( - JSON.stringify({ - error: 'Failed to refresh access token', - details: error.message, - stack: error.stack - }), + JSON.stringify({ error: 'Failed to refresh access token' }), { status: 500, headers: { 'Content-Type': 'application/json' } From 9ad74b049f5b53d315cea3dfc1f1dd761020e928 Mon Sep 17 00:00:00 2001 From: Brandon Saldan <26472557+brandonsaldan@users.noreply.github.com> Date: Sat, 23 Nov 2024 23:39:43 -0500 Subject: [PATCH 03/11] fix(playlists): update loading state --- src/pages/album/[albumId].jsx | 4 +--- src/pages/collection/tracks.jsx | 4 +--- src/pages/playlist/[playlistId].jsx | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/pages/album/[albumId].jsx b/src/pages/album/[albumId].jsx index 4700d27..db45f2e 100644 --- a/src/pages/album/[albumId].jsx +++ b/src/pages/album/[albumId].jsx @@ -345,9 +345,7 @@ const AlbumPage = ({ ))} - {isLoading && ( -

Loading more tracks...

- )} + {isLoading &&
}
); diff --git a/src/pages/collection/tracks.jsx b/src/pages/collection/tracks.jsx index 6c7e3d5..057a445 100644 --- a/src/pages/collection/tracks.jsx +++ b/src/pages/collection/tracks.jsx @@ -386,9 +386,7 @@ const LikedSongsPage = ({ ))} - {isLoading && ( -

Loading more tracks...

- )} + {isLoading &&
}
))} - {isLoading && ( -

Loading more tracks...

- )} + {isLoading &&
}
Date: Sat, 23 Nov 2024 23:48:52 -0500 Subject: [PATCH 04/11] fix(auth): update access token --- src/pages/_app.jsx | 20 ++++ src/pages/api/v1/auth/refresh-token.js | 146 ++++++++++++++++--------- 2 files changed, 116 insertions(+), 50 deletions(-) diff --git a/src/pages/_app.jsx b/src/pages/_app.jsx index f19aac9..0225fbf 100644 --- a/src/pages/_app.jsx +++ b/src/pages/_app.jsx @@ -436,6 +436,10 @@ export default function App({ Component, pageProps }) { const refreshAccessToken = async () => { try { + const currentRefreshToken = localStorage.getItem("spotifyRefreshToken"); + const currentAuthType = localStorage.getItem("spotifyAuthType"); + const currentTempId = localStorage.getItem("spotifyTempId"); + const response = await fetch("/api/v1/auth/refresh-token", { method: "POST", headers: { @@ -444,20 +448,35 @@ export default function App({ Component, pageProps }) { body: JSON.stringify({ refresh_token: refreshToken, isCustomAuth: authType === "custom", + tempId: currentTempId, }), }); if (!response.ok) { + const errorData = await response.json(); + console.error("Refresh token error:", { + status: response.status, + data: errorData, + }); throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); + setAccessToken(data.access_token); if (data.refresh_token) { setRefreshToken(data.refresh_token); + localStorage.setItem("spotifyRefreshToken", data.refresh_token); } + + const newExpiry = new Date( + Date.now() + data.expires_in * 1000 + ).toISOString(); + localStorage.setItem("spotifyTokenExpiry", newExpiry); + return data; } catch (error) { + console.error("Token refresh error:", error); handleError("REFRESH_ACCESS_TOKEN_ERROR", error.message); throw error; } @@ -902,6 +921,7 @@ export default function App({ Component, pageProps }) { try { const stateData = JSON.parse(decodeURIComponent(state)); if (stateData.phoneAuth) { + localStorage.setItem("spotifySessionId", stateData.sessionId); const exchangeTokens = async () => { try { const tokenResponse = await fetch("/api/v1/auth/token", { diff --git a/src/pages/api/v1/auth/refresh-token.js b/src/pages/api/v1/auth/refresh-token.js index 5d4f8f9..59c36d7 100644 --- a/src/pages/api/v1/auth/refresh-token.js +++ b/src/pages/api/v1/auth/refresh-token.js @@ -14,6 +14,7 @@ export default async function handler(req) { const { refresh_token, isCustomAuth } = await req.json(); if (!refresh_token) { + console.error('Missing refresh token'); return new Response( JSON.stringify({ error: 'Refresh token is required' }), { @@ -23,54 +24,79 @@ export default async function handler(req) { ); } - let clientId, clientSecret; + let clientId, clientSecret, tempId; if (isCustomAuth) { - const { error: cleanupError } = await supabase - .from('spotify_credentials') - .delete() - .eq('refresh_token', refresh_token) - .lt('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()); - - if (cleanupError) { - console.error('Error cleaning up old records:', cleanupError); - } - - const { data: credentials, error: fetchError } = await supabase - .from('spotify_credentials') - .select('client_id, encrypted_client_secret, temp_id') - .eq('refresh_token', refresh_token) - .order('created_at', { ascending: false }) - .limit(1) - .single(); - - if (fetchError || !credentials) { - console.error('Error fetching custom credentials:', fetchError); + if (!supabase) { + console.error('Supabase client not initialized'); return new Response( - JSON.stringify({ error: 'Custom credentials not found' }), + JSON.stringify({ error: 'Database connection error' }), { - status: 400, + status: 500, headers: { 'Content-Type': 'application/json' } } ); } - clientId = credentials.client_id; try { - clientSecret = await decrypt(credentials.encrypted_client_secret); - } catch (decryptError) { - console.error('Decryption error:', decryptError); + const { error: cleanupError } = await supabase + .from('spotify_credentials') + .delete() + .eq('refresh_token', refresh_token) + .lt('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()); + + if (cleanupError) { + console.error('Error cleaning up old records:', cleanupError); + } + + const { data: credentials, error: fetchError } = await supabase + .from('spotify_credentials') + .select('*') + .eq('refresh_token', refresh_token) + .order('created_at', { ascending: false }) + .limit(1) + .single(); + + if (fetchError || !credentials) { + console.error('Error fetching credentials:', { + error: fetchError, + hasCredentials: !!credentials, + refreshToken: refresh_token.substring(0, 10) + '...' + }); + return new Response( + JSON.stringify({ error: 'Custom credentials not found' }), + { + status: 400, + headers: { 'Content-Type': 'application/json' } + } + ); + } + + clientId = credentials.client_id; + tempId = credentials.temp_id; + + try { + clientSecret = await decrypt(credentials.encrypted_client_secret); + } catch (decryptError) { + console.error('Decryption error:', decryptError); + return new Response( + JSON.stringify({ error: 'Failed to decrypt credentials' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' } + } + ); + } + } catch (dbError) { + console.error('Database operation error:', dbError); return new Response( - JSON.stringify({ error: 'Failed to decrypt credentials' }), + JSON.stringify({ error: 'Database operation failed' }), { status: 500, headers: { 'Content-Type': 'application/json' } } ); } - - const tempId = credentials.temp_id; - req.tempId = tempId; } else { clientId = process.env.NEXT_PUBLIC_SPOTIFY_CLIENT_ID; clientSecret = process.env.SPOTIFY_CLIENT_SECRET; @@ -92,39 +118,55 @@ export default async function handler(req) { const data = await response.json(); if (!response.ok) { + console.error('Spotify token refresh failed:', { + status: response.status, + error: data, + clientIdPrefix: clientId.substring(0, 10) + '...' + }); return new Response(JSON.stringify(data), { status: response.status, headers: { 'Content-Type': 'application/json' } }); } - if (isCustomAuth && data.refresh_token) { + if (isCustomAuth) { const { data: oldRecord } = await supabase .from('spotify_credentials') .select('token_refresh_count, first_used_at') .eq('refresh_token', refresh_token) - .order('created_at', { ascending: false }) - .limit(1) .single(); - + const encryptedSecret = await encrypt(clientSecret); + const now = new Date(); + const tokenExpiry = new Date(now.getTime() + (data.expires_in * 1000)); + const expiresAt = new Date(now.getTime() + (7 * 24 * 60 * 60 * 1000)); - const { error: updateError } = await supabase + const updatePayload = { + access_token: data.access_token, + refresh_token: data.refresh_token || refresh_token, + encrypted_client_secret: encryptedSecret, + expires_at: expiresAt.toISOString(), + token_expiry: tokenExpiry.toISOString(), + last_used: now.toISOString(), + first_used_at: oldRecord?.first_used_at || now.toISOString(), + token_refresh_count: (oldRecord?.token_refresh_count || 0) + 1, + user_agent: req.headers.get('user-agent') || null + }; + + const { data: updatedRecord, error: updateError } = await supabase .from('spotify_credentials') - .update({ - refresh_token: data.refresh_token, - encrypted_client_secret: encryptedSecret, - expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), - last_used: new Date().toISOString(), - first_used_at: oldRecord?.first_used_at || new Date().toISOString(), - token_refresh_count: (oldRecord?.token_refresh_count || 0) + 1, - user_agent: req.headers.get('user-agent') || null - }) - .eq('temp_id', req.tempId) - .eq('refresh_token', refresh_token); + .update(updatePayload) + .eq('temp_id', tempId) + .eq('refresh_token', refresh_token) + .select() + .single(); if (updateError) { - console.error('Error updating credentials:', updateError); + console.error('Failed to update Supabase:', { + error: updateError, + tempId, + refreshToken: refresh_token.substring(0, 10) + '...' + }); } } @@ -141,9 +183,13 @@ export default async function handler(req) { ); } catch (error) { - console.error('Token refresh error:', error); + console.error('Unhandled error:', error); return new Response( - JSON.stringify({ error: 'Failed to refresh access token' }), + JSON.stringify({ + error: 'Failed to refresh access token', + details: error.message, + stack: error.stack + }), { status: 500, headers: { 'Content-Type': 'application/json' } From 02504e03e53c03811146402cb2f7c2cf077fb9ab Mon Sep 17 00:00:00 2001 From: Brandon Saldan <26472557+brandonsaldan@users.noreply.github.com> Date: Sat, 23 Nov 2024 23:53:03 -0500 Subject: [PATCH 05/11] feat(auth): remove rows from supabase on signout --- src/components/Settings.jsx | 39 +++++++++++++--- src/pages/_app.jsx | 93 ++++++++++++++++++++++++++++++++----- 2 files changed, 114 insertions(+), 18 deletions(-) diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx index b49541f..a498827 100644 --- a/src/components/Settings.jsx +++ b/src/components/Settings.jsx @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import { Field, Label, Switch } from "@headlessui/react"; import { useRouter } from "next/router"; +import { supabase } from "../lib/supabaseClient"; export default function Settings() { const router = useRouter(); @@ -38,12 +39,38 @@ export default function Settings() { } }, []); - const handleSignOut = () => { - localStorage.removeItem("spotifyAuthType"); - localStorage.removeItem("spotifyTempId"); - router.push("/").then(() => { - window.location.reload(); - }); + const handleSignOut = async () => { + try { + const refreshToken = localStorage.getItem("spotifyRefreshToken"); + const tempId = localStorage.getItem("spotifyTempId"); + const authType = localStorage.getItem("spotifyAuthType"); + if (authType === "custom" && refreshToken && tempId) { + const { error } = await supabase + .from("spotify_credentials") + .delete() + .match({ + temp_id: tempId, + refresh_token: refreshToken, + }); + if (error) { + console.error("Error removing credentials from database:", error); + } + } + localStorage.removeItem("spotifyAccessToken"); + localStorage.removeItem("spotifyRefreshToken"); + localStorage.removeItem("spotifyTokenExpiry"); + localStorage.removeItem("spotifyAuthType"); + localStorage.removeItem("spotifyTempId"); + router.push("/").then(() => { + window.location.reload(); + }); + } catch (error) { + console.error("Error during sign out:", error); + localStorage.clear(); + router.push("/").then(() => { + window.location.reload(); + }); + } }; return ( diff --git a/src/pages/_app.jsx b/src/pages/_app.jsx index 0225fbf..df0af7c 100644 --- a/src/pages/_app.jsx +++ b/src/pages/_app.jsx @@ -653,8 +653,46 @@ export default function App({ Component, pageProps }) { clearInterval(resetTimerRef.current); } - resetTimerRef.current = setInterval(() => { + resetTimerRef.current = setInterval(async () => { const elapsedTime = Date.now() - startTimeRef.current; + if (elapsedTime >= resetDuration) { + try { + const refreshToken = localStorage.getItem( + "spotifyRefreshToken" + ); + const tempId = localStorage.getItem("spotifyTempId"); + const authType = localStorage.getItem("spotifyAuthType"); + if (authType === "custom" && refreshToken && tempId) { + const supabaseInstance = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY + ); + const { error } = await supabaseInstance + .from("spotify_credentials") + .delete() + .match({ + temp_id: tempId, + refresh_token: refreshToken, + }); + if (error) { + console.error( + "Error removing credentials from database:", + error + ); + } + } + localStorage.clear(); + router.push("/").then(() => { + window.location.reload(); + }); + } catch (error) { + console.error("Error during reset:", error); + localStorage.clear(); + router.push("/").then(() => { + window.location.reload(); + }); + } + } if ( keyStatesRef.current["4"] && @@ -1254,17 +1292,48 @@ export default function App({ Component, pageProps }) { } }, [router.pathname, currentPlayback]); - const clearSession = () => { - localStorage.removeItem("spotifyAccessToken"); - localStorage.removeItem("spotifyRefreshToken"); - localStorage.removeItem("spotifyTokenExpiry"); - localStorage.removeItem("spotifyAuthType"); - setAccessToken(null); - setRefreshToken(null); - setAuthState({ - authSelectionMade: false, - authType: null, - }); + const clearSession = async () => { + try { + const refreshToken = localStorage.getItem("spotifyRefreshToken"); + const tempId = localStorage.getItem("spotifyTempId"); + const authType = localStorage.getItem("spotifyAuthType"); + if (authType === "custom" && refreshToken && tempId) { + const supabaseInstance = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY + ); + const { error } = await supabaseInstance + .from("spotify_credentials") + .delete() + .match({ + temp_id: tempId, + refresh_token: refreshToken, + }); + if (error) { + console.error("Error removing credentials from database:", error); + } + } + localStorage.removeItem("spotifyAccessToken"); + localStorage.removeItem("spotifyRefreshToken"); + localStorage.removeItem("spotifyTokenExpiry"); + localStorage.removeItem("spotifyAuthType"); + localStorage.removeItem("spotifyTempId"); + setAccessToken(null); + setRefreshToken(null); + setAuthState({ + authSelectionMade: false, + authType: null, + }); + } catch (error) { + console.error("Error during session cleanup:", error); + localStorage.clear(); + setAccessToken(null); + setRefreshToken(null); + setAuthState({ + authSelectionMade: false, + authType: null, + }); + } }; const updateGradientColors = useCallback( From 5c2ef0cf454f9e2b688208fc1a65613c9f706f59 Mon Sep 17 00:00:00 2001 From: Brandon Saldan <26472557+brandonsaldan@users.noreply.github.com> Date: Sat, 23 Nov 2024 23:54:57 -0500 Subject: [PATCH 06/11] feat(settings): add donation button --- src/components/DonationQRModal.jsx | 61 ++++++++++++++++++++++++++++++ src/components/Settings.jsx | 23 +++++++++-- src/pages/index.jsx | 10 ++++- 3 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 src/components/DonationQRModal.jsx diff --git a/src/components/DonationQRModal.jsx b/src/components/DonationQRModal.jsx new file mode 100644 index 0000000..52f9cae --- /dev/null +++ b/src/components/DonationQRModal.jsx @@ -0,0 +1,61 @@ +import React, { useState } from "react"; +import { QRCodeSVG } from "qrcode.react"; +import { X } from "lucide-react"; +const DonationQRModal = ({ onClose }) => { + const [isVisible, setIsVisible] = useState(false); + const [isExiting, setIsExiting] = useState(false); + React.useEffect(() => { + setIsVisible(true); + }, []); + const handleClose = () => { + setIsExiting(true); + setTimeout(() => { + onClose(); + }, 300); + }; + return ( +
+
+
+
+ +
+
+ +
+
+

Support Nocturne

+

+ This QR code will open a link to the Nocturne donation page on + your phone. +

+
+
+
+
+
+ ); +}; +export default DonationQRModal; diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx index a498827..18cbd2c 100644 --- a/src/components/Settings.jsx +++ b/src/components/Settings.jsx @@ -5,7 +5,7 @@ import { Field, Label, Switch } from "@headlessui/react"; import { useRouter } from "next/router"; import { supabase } from "../lib/supabaseClient"; -export default function Settings() { +export default function Settings({ onOpenDonationModal }) { const router = useRouter(); const [trackNameScrollingEnabled, setTrackNameScrollingEnabled] = useState( () => { @@ -44,6 +44,7 @@ export default function Settings() { const refreshToken = localStorage.getItem("spotifyRefreshToken"); const tempId = localStorage.getItem("spotifyTempId"); const authType = localStorage.getItem("spotifyAuthType"); + if (authType === "custom" && refreshToken && tempId) { const { error } = await supabase .from("spotify_credentials") @@ -52,15 +53,18 @@ export default function Settings() { temp_id: tempId, refresh_token: refreshToken, }); + if (error) { console.error("Error removing credentials from database:", error); } } + localStorage.removeItem("spotifyAccessToken"); localStorage.removeItem("spotifyRefreshToken"); localStorage.removeItem("spotifyTokenExpiry"); localStorage.removeItem("spotifyAuthType"); localStorage.removeItem("spotifyTempId"); + router.push("/").then(() => { window.location.reload(); }); @@ -129,10 +133,23 @@ export default function Settings() {
-
+
+ +

+ Support Nocturne by donating to the project. Thank you! +

+
+
)} + {showDonationModal && ( + setShowDonationModal(false)} /> + )} ); } From bf03d849cf507bb5266e855e9ace4193c8472c68 Mon Sep 17 00:00:00 2001 From: Brandon Saldan <26472557+brandonsaldan@users.noreply.github.com> Date: Sat, 23 Nov 2024 23:57:52 -0500 Subject: [PATCH 07/11] fix(cf): fix cloudflare deployment --- src/pages/_app.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/_app.jsx b/src/pages/_app.jsx index df0af7c..00097b8 100644 --- a/src/pages/_app.jsx +++ b/src/pages/_app.jsx @@ -1302,7 +1302,7 @@ export default function App({ Component, pageProps }) { process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ); - const { error } = await supabaseInstance + let { error } = await supabaseInstance .from("spotify_credentials") .delete() .match({ From 01600e1de4a2d13bf47ca6d0143399cfd6e04fc9 Mon Sep 17 00:00:00 2001 From: Brandon Saldan <26472557+brandonsaldan@users.noreply.github.com> Date: Sun, 24 Nov 2024 00:03:25 -0500 Subject: [PATCH 08/11] chore: bump version --- package-lock.json | 4 ++-- package.json | 2 +- src/pages/_app.jsx | 18 ------------------ src/pages/index.jsx | 3 ++- 4 files changed, 5 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 96f2daf..7074ecb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nocturne", - "version": "v2.1.0-beta.1", + "version": "v2.1.0-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nocturne", - "version": "v2.0.1-beta.2", + "version": "v2.1.0-beta.2", "dependencies": { "@headlessui/react": "^2.1.8", "@heroicons/react": "^2.1.5", diff --git a/package.json b/package.json index 05d6a6a..0878632 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nocturne", - "version": "v2.1.0-beta.1", + "version": "v2.1.0-beta.2", "private": true, "scripts": { "dev": "node server.js", diff --git a/src/pages/_app.jsx b/src/pages/_app.jsx index 00097b8..701c773 100644 --- a/src/pages/_app.jsx +++ b/src/pages/_app.jsx @@ -1360,24 +1360,6 @@ export default function App({ Component, pageProps }) { setTargetColor3(libraryColors[2]); setTargetColor4(libraryColors[3]); } - } else if ( - section === "settings" || - router.pathname === "/now-playing" - ) { - const settingsColors = ["#191414", "#191414", "#191414", "#191414"]; - setSectionGradients((prev) => ({ - ...prev, - [section]: settingsColors, - })); - if ( - activeSection === "settings" || - router.pathname === "/now-playing" - ) { - setTargetColor1(settingsColors[0]); - setTargetColor2(settingsColors[1]); - setTargetColor3(settingsColors[2]); - setTargetColor4(settingsColors[3]); - } } return; } diff --git a/src/pages/index.jsx b/src/pages/index.jsx index 2b73c5a..f0aaf3c 100644 --- a/src/pages/index.jsx +++ b/src/pages/index.jsx @@ -33,7 +33,8 @@ export default function Home({ const firstAlbumImage = albumsQueue[0]?.images?.[0]?.url; updateGradientColors(firstAlbumImage || null, "recents"); } else if (activeSection === "settings") { - updateGradientColors(null, "settings"); + const firstAlbumImage = albumsQueue[0]?.images?.[0]?.url; + updateGradientColors(firstAlbumImage || null, "recents"); } }, [activeSection, updateGradientColors, playlists, artists, albumsQueue]); From d63a006fa044631a4cfbd1d0f46b0ca615835bec Mon Sep 17 00:00:00 2001 From: Brandon Saldan <26472557+brandonsaldan@users.noreply.github.com> Date: Sun, 24 Nov 2024 00:13:41 -0500 Subject: [PATCH 09/11] fix(lib): update supabase client --- src/lib/supabaseClient.js | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/lib/supabaseClient.js b/src/lib/supabaseClient.js index a7476a3..6d7a35b 100644 --- a/src/lib/supabaseClient.js +++ b/src/lib/supabaseClient.js @@ -1,17 +1,10 @@ import { createClient } from '@supabase/supabase-js'; -export function getSupabaseClient() { - if (typeof window === 'undefined') return null; - - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - - if (!supabaseUrl || !supabaseAnonKey) { - console.error('Supabase credentials not found'); - return null; - } - - return createClient(supabaseUrl, supabaseAnonKey); +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + +if (!supabaseUrl || !supabaseAnonKey) { + throw new Error('Missing Supabase environment variables'); } -export const supabase = getSupabaseClient(); \ No newline at end of file +export const supabase = createClient(supabaseUrl, supabaseAnonKey); \ No newline at end of file From 1e77eb40d90db5a946d9dc7b6868aed22013dc54 Mon Sep 17 00:00:00 2001 From: Brandon Saldan <26472557+brandonsaldan@users.noreply.github.com> Date: Sun, 24 Nov 2024 01:31:00 -0500 Subject: [PATCH 10/11] fix(auth): use proper refresh token const --- src/pages/_app.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/_app.jsx b/src/pages/_app.jsx index 701c773..c718ea5 100644 --- a/src/pages/_app.jsx +++ b/src/pages/_app.jsx @@ -446,8 +446,8 @@ export default function App({ Component, pageProps }) { "Content-Type": "application/json", }, body: JSON.stringify({ - refresh_token: refreshToken, - isCustomAuth: authType === "custom", + refresh_token: currentRefreshToken, + isCustomAuth: currentAuthType === "custom", tempId: currentTempId, }), }); From 4cd18bf27eef5a9f81d52e6bf2265fff6a281761 Mon Sep 17 00:00:00 2001 From: Brandon Saldan <26472557+brandonsaldan@users.noreply.github.com> Date: Sun, 24 Nov 2024 02:47:19 -0500 Subject: [PATCH 11/11] feat(playlists): allow liked songs to be mapped to presets --- src/pages/_app.jsx | 93 ++++++++++++++++++++--------- src/pages/collection/tracks.jsx | 3 +- src/pages/playlist/[playlistId].jsx | 2 +- 3 files changed, 68 insertions(+), 30 deletions(-) diff --git a/src/pages/_app.jsx b/src/pages/_app.jsx index c718ea5..a98a5bf 100644 --- a/src/pages/_app.jsx +++ b/src/pages/_app.jsx @@ -128,6 +128,7 @@ export default function App({ Component, pageProps }) { const [drawerOpen, setDrawerOpen] = useState(false); const [error, setError] = useState(null); const [isShuffleEnabled, setIsShuffleEnabled] = useState(false); + const [currentRepeat, setCurrentRepeat] = useState("off"); const [pressedButton, setPressedButton] = useState(null); const [showMappingOverlay, setShowMappingOverlay] = useState(false); const [brightness, setBrightness] = useState(160); @@ -316,6 +317,7 @@ export default function App({ Component, pageProps }) { }); setIsShuffleEnabled(data.shuffle_state); + setCurrentRepeat(data.repeat_state); if (data && data.item) { const currentAlbum = data.item.album; @@ -351,10 +353,7 @@ export default function App({ Component, pageProps }) { } } } else if (response.status !== 401 && response.status !== 403) { - handleError( - "FETCH_CURRENT_PLAYBACK_ERROR", - `HTTP error! status: ${response.status}` - ); + return; } } catch (error) { if (error.message.includes("Unexpected end of JSON input")) { @@ -754,7 +753,8 @@ export default function App({ Component, pageProps }) { if ( pressDuration < holdDuration && - !pressStartPath?.includes("/playlist/") + !pressStartPath?.includes("/playlist/") && + !pressStartPath?.includes("/collection/") ) { const hasAnyMappings = validKeys.some( (key) => localStorage.getItem(`button${key}Map`) !== null @@ -794,26 +794,6 @@ export default function App({ Component, pageProps }) { throw new Error("Failed to obtain access token"); } - const playlistId = mappedRoute.split("/").pop(); - - const playbackResponse = await fetch( - "https://api.spotify.com/v1/me/player", - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ); - - let currentShuffle = false; - let currentRepeat = "off"; - - if (playbackResponse.ok && playbackResponse.status !== 204) { - const playbackData = await playbackResponse.json(); - currentShuffle = playbackData.shuffle_state; - currentRepeat = playbackData.repeat_state; - } - const devicesResponse = await fetch( "https://api.spotify.com/v1/me/player/devices", { @@ -858,8 +838,67 @@ export default function App({ Component, pageProps }) { await new Promise((resolve) => setTimeout(resolve, 500)); } + if (mappedRoute === "liked-songs") { + const tracksResponse = await fetch( + "https://api.spotify.com/v1/me/tracks?limit=50", + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (!tracksResponse.ok) { + throw new Error("Failed to fetch liked songs"); + } + + const tracksData = await tracksResponse.json(); + const trackUris = tracksData.items.map( + (item) => item.track.uri + ); + + let startPosition = 0; + if (isShuffleEnabled) { + startPosition = Math.floor(Math.random() * trackUris.length); + } + + await fetch( + `https://api.spotify.com/v1/me/player/shuffle?state=${isShuffleEnabled}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + const playResponse = await fetch( + "https://api.spotify.com/v1/me/player/play", + { + method: "PUT", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + uris: trackUris, + offset: { position: startPosition }, + device_id: activeDeviceId, + }), + } + ); + + if (!playResponse.ok) { + throw new Error(`Play error! status: ${playResponse.status}`); + } + + return; + } + + const playlistId = mappedRoute.split("/").pop(); + let startPosition = 0; - if (currentShuffle) { + if (isShuffleEnabled) { const playlistResponse = await fetch( `https://api.spotify.com/v1/playlists/${playlistId}`, { @@ -877,7 +916,7 @@ export default function App({ Component, pageProps }) { } const shuffleResponse = await fetch( - `https://api.spotify.com/v1/me/player/shuffle?state=${currentShuffle}`, + `https://api.spotify.com/v1/me/player/shuffle?state=${isShuffleEnabled}`, { method: "PUT", headers: { diff --git a/src/pages/collection/tracks.jsx b/src/pages/collection/tracks.jsx index 057a445..80c3686 100644 --- a/src/pages/collection/tracks.jsx +++ b/src/pages/collection/tracks.jsx @@ -41,8 +41,7 @@ const LikedSongsPage = ({ pressStartTimes[event.key] = Date.now(); holdTimeouts[event.key] = setTimeout(() => { - const currentUrl = window.location.pathname; - localStorage.setItem(`button${event.key}Map`, currentUrl); + localStorage.setItem(`button${event.key}Map`, "liked-songs"); localStorage.setItem( `button${event.key}Image`, "https://misc.scdn.co/liked-songs/liked-songs-640.png" diff --git a/src/pages/playlist/[playlistId].jsx b/src/pages/playlist/[playlistId].jsx index 9c9326f..ddeeff9 100644 --- a/src/pages/playlist/[playlistId].jsx +++ b/src/pages/playlist/[playlistId].jsx @@ -178,7 +178,7 @@ const PlaylistPage = ({ setIsShuffleEnabled(data.shuffle_state); } } catch (error) { - handleError("FETCH_PLAYBACK_STATE_ERROR", error.message); + return; } };