Spaces:
Sleeping
Sleeping
| class SpotifyPlayer { | |
| constructor() { | |
| this.token = null; | |
| this.player = null; | |
| this.deviceId = null; | |
| this.currentTrack = null; | |
| this.isPlaying = false; | |
| this.currentPosition = 0; | |
| this.duration = 0; | |
| this.volume = 0.5; | |
| this.queue = []; | |
| this.currentQueueIndex = -1; | |
| this.isRadioMode = false; | |
| this.playbackContext = null; | |
| this.init(); | |
| } | |
| init() { | |
| this.checkToken(); | |
| this.setupEventListeners(); | |
| } | |
| checkToken() { | |
| // Check localStorage first | |
| const storedToken = localStorage.getItem('spotify_access_token'); | |
| if (storedToken) { | |
| this.token = storedToken; | |
| this.showMainApp(); | |
| return; | |
| } | |
| // Check URL hash | |
| const hash = window.location.hash.substring(1); | |
| const params = new URLSearchParams(hash); | |
| const token = params.get('access_token'); | |
| if (token) { | |
| this.token = token; | |
| localStorage.setItem('spotify_access_token', token); | |
| window.location.hash = ''; | |
| this.showMainApp(); | |
| } else { | |
| this.showLoginScreen(); | |
| } | |
| } | |
| showLoginScreen() { | |
| document.getElementById('login-screen').classList.remove('hidden'); | |
| document.getElementById('main-app').classList.add('hidden'); | |
| } | |
| async showMainApp() { | |
| document.getElementById('login-screen').classList.add('hidden'); | |
| document.getElementById('main-app').classList.remove('hidden'); | |
| await this.initializeSpotifyPlayer(); | |
| await this.loadUserProfile(); | |
| await this.loadLikedSongs(); | |
| } | |
| async initializeSpotifyPlayer() { | |
| return new Promise((resolve) => { | |
| window.onSpotifyWebPlaybackSDKReady = () => { | |
| this.player = new Spotify.Player({ | |
| name: 'Spotify Web Player', | |
| getOAuthToken: cb => { cb(this.token); }, | |
| volume: this.volume | |
| }); | |
| // Error handling | |
| this.player.addListener('initialization_error', ({ message }) => { | |
| console.error('Failed to initialize:', message); | |
| }); | |
| this.player.addListener('authentication_error', ({ message }) => { | |
| console.error('Failed to authenticate:', message); | |
| this.logout(); | |
| }); | |
| this.player.addListener('account_error', ({ message }) => { | |
| console.error('Failed to validate Spotify account:', message); | |
| }); | |
| this.player.addListener('playback_error', ({ message }) => { | |
| console.error('Failed to perform playback:', message); | |
| }); | |
| // Playback status updates | |
| this.player.addListener('player_state_changed', (state) => { | |
| if (!state) return; | |
| this.currentTrack = state.track_window.current_track; | |
| this.isPlaying = !state.paused; | |
| this.currentPosition = state.position; | |
| this.duration = state.duration; | |
| // Store context for next/previous functionality | |
| if (state.context && state.context.uri) { | |
| this.playbackContext = state.context; | |
| } | |
| this.updateUI(); | |
| // Check if track ended | |
| if (state.paused && state.position === 0 && this.previousPosition > 0) { | |
| this.handleTrackEnd(); | |
| } | |
| this.previousPosition = state.position; | |
| }); | |
| // Ready | |
| this.player.addListener('ready', ({ device_id }) => { | |
| console.log('Ready with Device ID', device_id); | |
| this.deviceId = device_id; | |
| resolve(); | |
| }); | |
| // Connect to the player! | |
| this.player.connect(); | |
| }; | |
| }); | |
| } | |
| async loadUserProfile() { | |
| try { | |
| const response = await fetch('https://api.spotify.com/v1/me', { | |
| headers: { 'Authorization': `Bearer ${this.token}` } | |
| }); | |
| const user = await response.json(); | |
| document.getElementById('user-name').textContent = user.display_name || 'User'; | |
| if (user.images && user.images.length > 0) { | |
| document.getElementById('user-avatar').src = user.images[0].url; | |
| } | |
| } catch (error) { | |
| console.error('Error loading user profile:', error); | |
| } | |
| } | |
| async loadLikedSongs() { | |
| try { | |
| const response = await fetch('https://api.spotify.com/v1/me/tracks?limit=50', { | |
| headers: { 'Authorization': `Bearer ${this.token}` } | |
| }); | |
| const data = await response.json(); | |
| const container = document.getElementById('liked-songs'); | |
| container.innerHTML = ''; | |
| if (data.items && data.items.length > 0) { | |
| // Store liked songs as a playlist | |
| this.likedSongs = data.items.map(item => item.track); | |
| data.items.forEach((item, index) => { | |
| const trackElement = this.createTrackElement(item.track, true, index); | |
| container.appendChild(trackElement); | |
| }); | |
| } else { | |
| container.innerHTML = '<div class="loading">No liked songs found</div>'; | |
| } | |
| } catch (error) { | |
| console.error('Error loading liked songs:', error); | |
| document.getElementById('liked-songs').innerHTML = '<div class="loading">Error loading liked songs</div>'; | |
| } | |
| } | |
| async search(query) { | |
| if (!query.trim()) return; | |
| this.showLoading(); | |
| try { | |
| const response = await fetch(`https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=20`, { | |
| headers: { 'Authorization': `Bearer ${this.token}` } | |
| }); | |
| const data = await response.json(); | |
| const container = document.getElementById('search-results'); | |
| container.innerHTML = ''; | |
| if (data.tracks && data.tracks.items.length > 0) { | |
| data.tracks.items.forEach(track => { | |
| const trackElement = this.createTrackElement(track); | |
| container.appendChild(trackElement); | |
| }); | |
| } else { | |
| container.innerHTML = '<div class="loading">No results found</div>'; | |
| } | |
| } catch (error) { | |
| console.error('Error searching:', error); | |
| document.getElementById('search-results').innerHTML = '<div class="loading">Error searching</div>'; | |
| } | |
| this.hideLoading(); | |
| } | |
| createTrackElement(track, isLiked = false, index = -1) { | |
| const div = document.createElement('div'); | |
| div.className = 'track-item'; | |
| if (this.currentTrack && this.currentTrack.id === track.id) { | |
| div.classList.add('playing'); | |
| } | |
| div.innerHTML = ` | |
| <img src="${track.album.images[2]?.url || track.album.images[0]?.url}" alt="${track.name}" class="track-image"> | |
| <div class="track-info"> | |
| <div class="track-name">${track.name}</div> | |
| <div class="track-artist">${track.artists.map(a => a.name).join(', ')}</div> | |
| </div> | |
| <button class="like-btn ${isLiked ? 'liked' : ''}" data-track-id="${track.id}"> | |
| ${isLiked ? '♥' : '♡'} | |
| </button> | |
| `; | |
| div.addEventListener('click', (e) => { | |
| if (!e.target.classList.contains('like-btn')) { | |
| if (isLiked && index >= 0) { | |
| // Playing from liked songs - set up queue | |
| this.playFromLikedSongs(index); | |
| } else { | |
| // Playing from search - enable radio mode | |
| this.playTrack(track, true); | |
| } | |
| } | |
| }); | |
| div.querySelector('.like-btn').addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| this.toggleLike(track.id, e.target); | |
| }); | |
| return div; | |
| } | |
| async playFromLikedSongs(index) { | |
| if (!this.likedSongs || index >= this.likedSongs.length) return; | |
| try { | |
| // Play all liked songs starting from the selected one | |
| const uris = this.likedSongs.slice(index).map(track => track.uri); | |
| await fetch(`https://api.spotify.com/v1/me/player/play?device_id=${this.deviceId}`, { | |
| method: 'PUT', | |
| body: JSON.stringify({ | |
| uris: uris | |
| }), | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${this.token}` | |
| } | |
| }); | |
| this.isRadioMode = false; | |
| this.queue = this.likedSongs.slice(index); | |
| this.currentQueueIndex = 0; | |
| } catch (error) { | |
| console.error('Error playing from liked songs:', error); | |
| } | |
| } | |
| async playTrack(track, startRadioMode = true) { | |
| try { | |
| if (startRadioMode) { | |
| // Get recommendations first | |
| await this.loadSimilarTracks(track); | |
| // Play the queue if we have recommendations | |
| if (this.queue.length > 0) { | |
| const uris = this.queue.map(t => t.uri); | |
| await fetch(`https://api.spotify.com/v1/me/player/play?device_id=${this.deviceId}`, { | |
| method: 'PUT', | |
| body: JSON.stringify({ | |
| uris: uris | |
| }), | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${this.token}` | |
| } | |
| }); | |
| this.isRadioMode = true; | |
| this.currentQueueIndex = 0; | |
| } else { | |
| // Fallback to single track if recommendations fail | |
| await fetch(`https://api.spotify.com/v1/me/player/play?device_id=${this.deviceId}`, { | |
| method: 'PUT', | |
| body: JSON.stringify({ | |
| uris: [track.uri] | |
| }), | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${this.token}` | |
| } | |
| }); | |
| this.isRadioMode = false; | |
| } | |
| } else { | |
| // Just play single track | |
| await fetch(`https://api.spotify.com/v1/me/player/play?device_id=${this.deviceId}`, { | |
| method: 'PUT', | |
| body: JSON.stringify({ | |
| uris: [track.uri] | |
| }), | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${this.token}` | |
| } | |
| }); | |
| } | |
| this.currentTrack = track; | |
| this.updateUI(); | |
| } catch (error) { | |
| console.error('Error playing track:', error); | |
| } | |
| } | |
| async loadSimilarTracks(seedTrack) { | |
| try { | |
| const response = await fetch('/recommendations', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| access_token: this.token, | |
| seed_tracks: seedTrack.id, | |
| seed_artists: seedTrack.artists[0].id | |
| }) | |
| }); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| if (data.tracks) { | |
| this.queue = [seedTrack, ...data.tracks]; | |
| this.currentQueueIndex = 0; | |
| return; | |
| } | |
| } | |
| // Fallback - just use the seed track | |
| this.queue = [seedTrack]; | |
| this.currentQueueIndex = 0; | |
| } catch (error) { | |
| console.error('Error loading similar tracks:', error); | |
| // Fallback - just use the seed track | |
| this.queue = [seedTrack]; | |
| this.currentQueueIndex = 0; | |
| } | |
| } | |
| async handleTrackEnd() { | |
| // This is handled by the SDK's queue now | |
| } | |
| async togglePlayPause() { | |
| try { | |
| if (this.isPlaying) { | |
| await this.player.pause(); | |
| } else { | |
| await this.player.resume(); | |
| } | |
| } catch (error) { | |
| console.error('Error toggling play/pause:', error); | |
| } | |
| } | |
| async nextTrack() { | |
| try { | |
| await this.player.nextTrack(); | |
| } catch (error) { | |
| console.error('Error skipping to next track:', error); | |
| } | |
| } | |
| async previousTrack() { | |
| try { | |
| await this.player.previousTrack(); | |
| } catch (error) { | |
| console.error('Error skipping to previous track:', error); | |
| } | |
| } | |
| async setVolume(volume) { | |
| this.volume = volume / 100; | |
| try { | |
| await this.player.setVolume(this.volume); | |
| } catch (error) { | |
| console.error('Error setting volume:', error); | |
| } | |
| } | |
| async toggleLike(trackId, button) { | |
| const isLiked = button.classList.contains('liked'); | |
| try { | |
| const method = isLiked ? 'DELETE' : 'PUT'; | |
| await fetch(`https://api.spotify.com/v1/me/tracks?ids=${trackId}`, { | |
| method: method, | |
| headers: { 'Authorization': `Bearer ${this.token}` } | |
| }); | |
| button.classList.toggle('liked'); | |
| button.textContent = button.classList.contains('liked') ? '♥' : '♡'; | |
| // Update current track like button if it's the same track | |
| if (this.currentTrack && this.currentTrack.id === trackId) { | |
| const currentLikeBtn = document.getElementById('like-current-btn'); | |
| currentLikeBtn.classList.toggle('liked'); | |
| currentLikeBtn.textContent = currentLikeBtn.classList.contains('liked') ? '♥' : '♡'; | |
| } | |
| // Reload liked songs if we're on that tab | |
| const likedTab = document.getElementById('liked-tab'); | |
| if (likedTab.classList.contains('active')) { | |
| await this.loadLikedSongs(); | |
| } | |
| } catch (error) { | |
| console.error('Error toggling like:', error); | |
| } | |
| } | |
| updateUI() { | |
| // Update current track info | |
| if (this.currentTrack) { | |
| document.getElementById('current-track-info').classList.remove('hidden'); | |
| document.getElementById('current-track-image').src = this.currentTrack.album.images[2]?.url || this.currentTrack.album.images[0]?.url; | |
| document.getElementById('current-track-name').textContent = this.currentTrack.name; | |
| document.getElementById('current-track-artist').textContent = this.currentTrack.artists.map(a => a.name).join(', '); | |
| // Check if current track is liked | |
| this.checkIfTrackIsLiked(this.currentTrack.id); | |
| } | |
| // Update play button | |
| document.getElementById('play-btn').textContent = this.isPlaying ? '⏸️' : '▶️'; | |
| // Update progress | |
| const progressPercent = this.duration ? (this.currentPosition / this.duration) * 100 : 0; | |
| document.getElementById('progress').style.width = progressPercent + '%'; | |
| // Update time display | |
| document.getElementById('current-time').textContent = this.formatTime(this.currentPosition); | |
| document.getElementById('total-time').textContent = this.formatTime(this.duration); | |
| // Update playing indicators | |
| document.querySelectorAll('.track-item').forEach(item => { | |
| item.classList.remove('playing'); | |
| }); | |
| if (this.currentTrack) { | |
| document.querySelectorAll('.track-item').forEach(item => { | |
| const trackName = item.querySelector('.track-name').textContent; | |
| if (trackName === this.currentTrack.name) { | |
| item.classList.add('playing'); | |
| } | |
| }); | |
| } | |
| } | |
| async checkIfTrackIsLiked(trackId) { | |
| try { | |
| const response = await fetch(`https://api.spotify.com/v1/me/tracks/contains?ids=${trackId}`, { | |
| headers: { 'Authorization': `Bearer ${this.token}` } | |
| }); | |
| const [isLiked] = await response.json(); | |
| const currentLikeBtn = document.getElementById('like-current-btn'); | |
| if (isLiked) { | |
| currentLikeBtn.classList.add('liked'); | |
| currentLikeBtn.textContent = '♥'; | |
| } else { | |
| currentLikeBtn.classList.remove('liked'); | |
| currentLikeBtn.textContent = '♡'; | |
| } | |
| } catch (error) { | |
| console.error('Error checking if track is liked:', error); | |
| } | |
| } | |
| formatTime(ms) { | |
| const minutes = Math.floor(ms / 60000); | |
| const seconds = Math.floor((ms % 60000) / 1000); | |
| return `${minutes}:${seconds.toString().padStart(2, '0')}`; | |
| } | |
| showLoading() { | |
| document.getElementById('loading-overlay').classList.remove('hidden'); | |
| } | |
| hideLoading() { | |
| document.getElementById('loading-overlay').classList.add('hidden'); | |
| } | |
| logout() { | |
| localStorage.removeItem('spotify_access_token'); | |
| this.token = null; | |
| if (this.player) { | |
| this.player.disconnect(); | |
| } | |
| this.showLoginScreen(); | |
| } | |
| setupEventListeners() { | |
| // Login | |
| document.getElementById('login-btn').addEventListener('click', () => { | |
| window.location.href = '/login'; | |
| }); | |
| // Logout | |
| document.getElementById('logout-btn').addEventListener('click', () => { | |
| this.logout(); | |
| }); | |
| // Tab switching | |
| document.querySelectorAll('.nav-tab').forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| const tabName = tab.dataset.tab; | |
| // Update active tab | |
| document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active')); | |
| tab.classList.add('active'); | |
| // Show corresponding content | |
| document.querySelectorAll('.tab-content').forEach(content => { | |
| content.classList.remove('active'); | |
| }); | |
| document.getElementById(`${tabName}-tab`).classList.add('active'); | |
| }); | |
| }); | |
| // Search | |
| const searchInput = document.getElementById('search-input'); | |
| const searchBtn = document.getElementById('search-btn'); | |
| const performSearch = () => { | |
| this.search(searchInput.value); | |
| }; | |
| searchBtn.addEventListener('click', performSearch); | |
| searchInput.addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter') { | |
| performSearch(); | |
| } | |
| }); | |
| // Player controls | |
| document.getElementById('play-btn').addEventListener('click', () => { | |
| this.togglePlayPause(); | |
| }); | |
| document.getElementById('next-btn').addEventListener('click', () => { | |
| this.nextTrack(); | |
| }); | |
| document.getElementById('prev-btn').addEventListener('click', () => { | |
| this.previousTrack(); | |
| }); | |
| // Volume control | |
| document.getElementById('volume-slider').addEventListener('input', (e) => { | |
| this.setVolume(e.target.value); | |
| }); | |
| // Progress bar click | |
| document.querySelector('.progress-bar').addEventListener('click', async (e) => { | |
| const rect = e.target.getBoundingClientRect(); | |
| const percent = (e.clientX - rect.left) / rect.width; | |
| const position = percent * this.duration; | |
| try { | |
| await this.player.seek(position); | |
| } catch (error) { | |
| console.error('Error seeking:', error); | |
| } | |
| }); | |
| // Current track like button | |
| document.getElementById('like-current-btn').addEventListener('click', () => { | |
| if (this.currentTrack) { | |
| this.toggleLike(this.currentTrack.id, document.getElementById('like-current-btn')); | |
| } | |
| }); | |
| // Update progress periodically | |
| setInterval(() => { | |
| if (this.isPlaying) { | |
| this.player.getCurrentState().then(state => { | |
| if (state) { | |
| this.currentPosition = state.position; | |
| this.updateUI(); | |
| } | |
| }); | |
| } | |
| }, 1000); | |
| } | |
| } | |
| // Initialize the app | |
| const spotifyPlayer = new SpotifyPlayer(); |