commit d3285bb1f31befbc48e4d152c804cb4e60150676 Author: bckelley Date: Tue Jul 1 15:17:12 2025 -0500 init commit - calling this project finished as of now, may revisit if in the future spotify lifts the premium requirement. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbe9c82 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.vscode/ \ No newline at end of file diff --git a/auth.js b/auth.js new file mode 100644 index 0000000..65568b0 --- /dev/null +++ b/auth.js @@ -0,0 +1,51 @@ +// Spotify OAuth logic +const client_id = '5ed07e2a23384be495efff4d463aba54'; +const redirect_uri = window.location.origin + window.location.pathname; +const scope = 'user-read-private user-read-email user-read-playback-state user-modify-playback-state user-read-currently-playing'; + +function loginWithSpotify() { + const code_verifier = generateCodeVerifier(); + localStorage.setItem('code_verifier', code_verifier); + generateCodeChallenge(code_verifier).then(code_challenge => { + const params = new URLSearchParams({ + response_type: 'code', + client_id, + scope, + redirect_uri, + code_challenge_method: 'S256', + code_challenge + }); + window.location = 'https://accounts.spotify.com/authorize?' + params.toString(); + }); +} + +async function handleRedirect() { + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + if (!code) return null; + + const code_verifier = localStorage.getItem('code_verifier'); + const body = new URLSearchParams({ + client_id, + grant_type: 'authorization_code', + code, + redirect_uri, + code_verifier + }); + + const response = await fetch('https://accounts.spotify.com/api/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body + }); + const data = await response.json(); + if (data.access_token) { + window.history.replaceState({}, document.title, window.location.pathname); + localStorage.removeItem('code_verifier'); + localStorage.setItem('access_token', data.access_token); + return data.access_token; + } else { + showError('Error: ' + JSON.stringify(data, null, 2)); + return null; + } +} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..00ead1c --- /dev/null +++ b/index.html @@ -0,0 +1,58 @@ + + + + + + Spotify Now Playing - YourName + + + + + +
+ +

+ + Spotify Now Playing +

+ + +
+ +
+ + + \ No newline at end of file diff --git a/main.js b/main.js new file mode 100644 index 0000000..51f02e4 --- /dev/null +++ b/main.js @@ -0,0 +1,89 @@ +import { loginWithSpotify, handleRedirect, getProfile, getCurrentTrack } from './spotify-api.js'; + +let isPremium = false; + +const loginBtn = document.getElementById('login-btn'); +const nowPlayingDiv = document.getElementById('now-playing'); +const errorMsg = document.getElementById('error-msg'); +const profilePic = document.getElementById('profile-pic'); + +loginBtn.onclick = loginWithSpotify; + +async function main() { + const access_token = await handleRedirect() || localStorage.getItem('access_token'); + if (!access_token) return; + + loginBtn.style.display = 'none'; + + // Show user profile + const profile = await getProfile(access_token); + if (profile && profile.images && profile.images.length) { + profilePic.src = profile.images[0].url; + profilePic.style.display = ''; + } + + const isPremium = profile?.product === 'premium'; + + ['play-btn', 'pause-btn', 'next-btn', 'prev-btn'].forEach(id => { + const btn = document.getElementById(id); + if (!isPremium) { + btn.disabled = true; + btn.title = 'Spotify Premium required for playback controls'; + btn.classList.add('opacity-50', 'cursor-not-allowed'); + } + }); + + const playBtn = document.getElementById('play-btn'); + const pauseBtn = document.getElementById('pause-btn'); + + function showPause() { + playBtn.style.display = 'none'; + pauseBtn.style.display = ''; + } + function showPlay() { + playBtn.style.display = ''; + pauseBtn.style.display = 'none'; + } + + function onPlaybackStateChanged(isPlaying) { + if (isPlaying) { + showPause(); + } else { + showPlay(); + } + } + + // Show now playing + async function updateNowPlaying() { + const track = await getCurrentTrack(access_token); + if (!track || !track.item) { + nowPlayingDiv.style.display = 'none'; + errorMsg.textContent = 'Nothing playing or no active device.'; + return; + } + nowPlayingDiv.style.display = ''; + errorMsg.textContent = ''; + document.getElementById('album-art').src = track.item.album.images[0].url; + document.getElementById('track-title').textContent = track.item.name; + document.getElementById('track-artist').textContent = track.item.artists.map(a => a.name).join(', '); + document.getElementById('track-album').textContent = track.item.album.name; + // Progress bar + const progress = (track.progress_ms / track.item.duration_ms) * 100; + document.getElementById('progress-bar').style.width = `${progress}%`; + document.getElementById('progress-current').textContent = msToMinSec(track.progress_ms); + document.getElementById('progress-duration').textContent = msToMinSec(track.item.duration_ms); + + onPlaybackStateChanged(track.is_playing); + + } + setInterval(updateNowPlaying, 2000); + updateNowPlaying(); +} + +function msToMinSec(ms) { + const min = Math.floor(ms / 60000); + const sec = Math.floor((ms % 60000) / 1000); + return `${min}:${sec.toString().padStart(2, '0')}`; +} + +main(); \ No newline at end of file diff --git a/pkce.js b/pkce.js new file mode 100644 index 0000000..15ed436 --- /dev/null +++ b/pkce.js @@ -0,0 +1,14 @@ +// PKCE utilities +function generateCodeVerifier(length = 64) { + const array = new Uint8Array(length); + window.crypto.getRandomValues(array); + return btoa(String.fromCharCode(...array)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +async function generateCodeChallenge(codeVerifier) { + const encoder = new TextEncoder(); + const data = encoder.encode(codeVerifier); + const digest = await window.crypto.subtle.digest('SHA-256', data); + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..0a867e4 --- /dev/null +++ b/readme.md @@ -0,0 +1,71 @@ +# Spotify Now Playing + +A simple web app to display your current Spotify playback, built with vanilla JS, Tailwind CSS, and the Spotify Web API. + +## Features + +- **Spotify OAuth Login** (PKCE flow, no backend required) +- **Displays current track** (title, artist, album, album art) +- **Playback controls** (play, pause, next, previous) for Spotify Premium users +- **Responsive, modern UI** using Tailwind CSS and Font Awesome icons +- **Shows playback progress bar** +- **Profile picture and user info** + +## Requirements + +- Spotify account (Premium required for playback controls) +- Modern browser (uses ES modules and Fetch API) + +## Getting Started + +1. **Clone this repo:** + ```sh + git clone https://github.com/bckelley/spotify-player.git + cd spotify-player + ``` + +2. **Set up a Spotify Developer App:** + - Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/applications) + - Create an app and set the Redirect URI to your local/test URL (e.g. `http://${app url/spotify/`) + - Copy your **Client ID** and update it in `spotify-api.js` and `auth.js` if needed. + +3. **Run locally:** + - Place the project in your local web server root I used laragon (e.g. `c:\laragon\www\spotify`) + - Open `http://${app url}/` in your browser + +4. **Login with Spotify:** + - Click the "Login with Spotify" button and authorize the app. + +## File Structure + +``` +index.html # Main UI +main.js # App logic and UI updates +spotify-api.js # Spotify API and OAuth logic +auth.js # (Optional) Separate auth logic +ui.js # UI helper functions +pkce.js # PKCE utility functions +``` + +## Customization + +- **Styling:** Uses [Tailwind CSS](https://tailwindcss.com/) via CDN for rapid styling. +- **Icons:** Uses [Font Awesome](https://fontawesome.com/) for playback controls. +- **Profile:** Shows your Spotify profile picture if available. + +## Notes + +- Playback controls (play, pause, next, previous) require a Spotify Premium account. +- The app polls the Spotify API every 2 seconds for updates. +- No backend server is required; all logic runs in the browser. + +## Credits + +- [Spotify Web API](https://developer.spotify.com/documentation/web-api/) +- [Tailwind CSS](https://tailwindcss.com/) +- [Font Awesome](https://fontawesome.com/) +- Inspired by [bckelley/spotify-player](https://github.com/bckelley/spotify-player) + +--- + +**Built by bckelley** · [Source](https://github.com/bckelley/spotify-player) \ No newline at end of file diff --git a/spotify-api.js b/spotify-api.js new file mode 100644 index 0000000..1619837 --- /dev/null +++ b/spotify-api.js @@ -0,0 +1,95 @@ +// Spotify Web API actions +function generateCodeVerifier(length = 128) { + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + let code_verifier = ''; + for (let i = 0; i < length; i++) { + code_verifier += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return code_verifier; +} + +async function generateCodeChallenge(code_verifier) { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(code_verifier)); + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +const client_id = '5ed07e2a23384be495efff4d463aba54'; +const redirect_uri = window.location.origin + window.location.pathname; +const scope = 'user-read-private user-read-email user-read-playback-state user-read-currently-playing'; + +export function loginWithSpotify() { + const code_verifier = generateCodeVerifier(); + localStorage.setItem('code_verifier', code_verifier); + generateCodeChallenge(code_verifier).then(code_challenge => { + const params = new URLSearchParams({ + response_type: 'code', + client_id, + scope, + redirect_uri, + code_challenge_method: 'S256', + code_challenge + }); + window.location = 'https://accounts.spotify.com/authorize?' + params.toString(); + }); +} + +export async function handleRedirect() { + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + if (!code) return null; + + const code_verifier = localStorage.getItem('code_verifier'); + const body = new URLSearchParams({ + client_id, + grant_type: 'authorization_code', + code, + redirect_uri, + code_verifier + }); + + const response = await fetch('https://accounts.spotify.com/api/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body + }); + const data = await response.json(); + if (data.access_token) { + window.history.replaceState({}, document.title, window.location.pathname); + localStorage.removeItem('code_verifier'); + localStorage.setItem('access_token', data.access_token); + return data.access_token; + } else { + document.getElementById('error-msg').textContent = 'Error: ' + JSON.stringify(data, null, 2); + return null; + } +} + +// --- Profile and Current Track --- +export async function getProfile(access_token) { + const res = await fetch('https://api.spotify.com/v1/me', { + headers: { Authorization: 'Bearer ' + access_token } + }); + if (!res.ok) return null; + return await res.json(); +} + +export async function getCurrentTrack(access_token) { + const res = await fetch('https://api.spotify.com/v1/me/player/currently-playing', { + headers: { Authorization: 'Bearer ' + access_token } + }); + if (res.status === 204 || !res.ok) return null; + return await res.json(); +} + +async function playerCommand(access_token, endpoint, method='POST') { + const response = await fetch(`https://api.spotify.com/v1/me/player/${endpoint}`, { + method, + headers: { Authorization: 'Bearer ' + access_token } + }); + if (!response.ok) { + const text = await response.text(); + document.getElementById('data').innerText = `Spotify API Error (${endpoint}): ${response.status} ${text}`; + console.error(`Spotify API Error (${endpoint}):`, response.status, text); + } +} \ No newline at end of file diff --git a/ui.js b/ui.js new file mode 100644 index 0000000..b618c28 --- /dev/null +++ b/ui.js @@ -0,0 +1,19 @@ +function showError(msg) { + document.getElementById('data').innerText = msg; +} +function updateTrackInfo(track) { + if (!track) { + document.getElementById('track-info').innerText = 'Nothing playing or no active device.'; + return; + } + if (track.item) { + document.getElementById('track-info').innerText = + `Now Playing: ${track.item.name} by ${track.item.artists.map(a => a.name).join(", ")}`; + } else { + document.getElementById('track-info').innerText = 'Nothing playing.'; + } +} +function showControls() { + document.getElementById('controls').style.display = ''; + document.getElementById('login-btn').style.display = 'none'; +} \ No newline at end of file