mirror of
https://github.com/bckelley/spotify-player.git
synced 2026-08-24 03:24:12 -05:00
init commit - calling this project finished as of now, may revisit if in the future spotify lifts the premium requirement.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.vscode/
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Spotify Now Playing - YourName</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link rel="icon" href="https://spotify.com/favicon.ico"/>
|
||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.7.0/css/all.css" integrity="sha384-lZN37f5QGtY3VHgisS14W3ExzMWZxybE1SJSEsQp9S+oqd12jhcu+A56Ebc1zFSJ" crossorigin="anonymous">
|
||||
</head>
|
||||
<body class="bg-gradient-to-br from-green-400 via-black to-gray-900 min-h-screen flex flex-col items-center justify-center">
|
||||
<div class="bg-white bg-opacity-10 rounded-xl shadow-xl p-8 flex flex-col items-center gap-4 w-full max-w-md">
|
||||
<img id="profile-pic" class="rounded-full w-16 h-16 border-4 border-green-500" src="" alt="Profile" style="display:none;">
|
||||
<h1 class="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<svg fill="#1DB954" viewport="0 0 40 40" class="w-6 h-6"><path d="M12 0C5.371 0 0 5.373 0 12c0 6.627 5.371 12 12 12 6.627 0 12-5.373 12-12C24 5.373 18.627 0 12 0zm5.41 17.817a.905.905 0 0 1-1.246.273c-3.408-2.087-7.705-2.556-12.76-1.395a.908.908 0 0 1-.418-1.77c5.497-1.298 10.219-.77 14.039 1.543a.905.905 0 0 1 .385 1.349zm1.715-3.193a1.13 1.13 0 0 1-1.559.342c-3.899-2.397-9.835-3.09-14.426-1.69a1.131 1.131 0 1 1-.655-2.166c5.135-1.553 11.573-.782 15.99 2.03a1.128 1.128 0 0 1 .35 1.484zm1.789-3.37C16.395 8.765 7.591 8.563 4.768 9.396a1.354 1.354 0 0 1-.727-2.613c3.249-.902 12.808-.67 16.824 2.083a1.353 1.353 0 0 1-1.67 2.388z"/></svg>
|
||||
Spotify Now Playing
|
||||
</h1>
|
||||
<button id="login-btn" class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600">Login with Spotify</button>
|
||||
<div id="now-playing" class="w-full flex flex-col items-center" style="display:none;">
|
||||
<img id="album-art" class="rounded-lg shadow w-40 h-40" src="" alt="Album Art"/>
|
||||
<div class="mt-4 text-center">
|
||||
<div id="track-title" class="text-xl font-bold text-white"></div>
|
||||
<div id="track-artist" class="text-md text-gray-200"></div>
|
||||
<div id="track-album" class="text-sm text-gray-400"></div>
|
||||
</div>
|
||||
<div class="w-full mt-4">
|
||||
<div class="w-full bg-gray-700 rounded-full h-2.5">
|
||||
<div id="progress-bar" class="bg-green-400 h-2.5 rounded-full" style="width:0%"></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-300 mt-1">
|
||||
<span id="progress-current">0:00</span>
|
||||
<span id="progress-duration">0:00</span>
|
||||
</div>
|
||||
<div id="player-controls" class="flex gap-2 mt-4 justify-center items-center">
|
||||
<button id="prev-btn"
|
||||
class="btn border border-green-500 text-green-500 bg-transparent rounded-full px-3 py-1 text-base w-16 transition hover:bg-green-500 hover:text-white"
|
||||
title="Previous"> <i class="fas fa-backward"></i> </button>
|
||||
|
||||
<button id="play-btn"
|
||||
class="btn bg-green-500 text-white w-28 h-28 rounded-full text-2xl flex items-center justify-center transition"
|
||||
title="Play"><i class="fas fa-play"></i></button>
|
||||
|
||||
<button id="pause-btn" style="display:none;"
|
||||
class="btn bg-green-500 text-white w-28 h-28 rounded-full text-2xl flex items-center justify-center transition"
|
||||
title="Pause"><i class="fas fa-pause"></i></button>
|
||||
|
||||
<button id="next-btn"
|
||||
class="btn border border-green-500 text-green-500 bg-transparent rounded-full px-3 py-1 text-base w-16 transition hover:bg-green-500 hover:text-white"
|
||||
title="Next"><i class="fas fa-forward"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="error-msg" class="text-red-500 font-bold"></div>
|
||||
<footer class="mt-4 text-xs text-gray-400">Built by bckelley · <a href="https://github.com/bckelley/spotify-player" class="underline hover:text-green-300">Source</a></footer>
|
||||
</div>
|
||||
<script type="module" src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
@@ -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(/=+$/, '');
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user