├── README.md ├── index.html ├── js ├── spotify-api.js ├── spotify-auth.js ├── scheduler.js └── app.js └── css └── styles.css /README.md: -------------------------------------------------------------------------------- 1 | # TimeKeepingSpotify 2 | 3 | Timekeeping app 4 | 5 | A timekeeping/alarm app that uses the Spotify API to play specific music on set times. 6 | 7 | ## Features 8 | 9 | - **Spotify Login**: Secure OAuth 2.0 PKCE authentication (no server required) 10 | - **Schedule Music**: Set specific times to play tracks from Spotify 11 | - **Volume Control**: Set custom volume levels for each scheduled alarm 12 | - **Restore Playback**: Option to return to your previous playlist after the scheduled song finishes 13 | - **Search Tracks**: Search for songs or paste Spotify URIs/URLs directly 14 | - **Daily Repeats**: Schedules automatically repeat daily 15 | 16 | ## Setup 17 | 18 | ### 1. Create a Spotify App 19 | 20 | 1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) 21 | 2. Click "Create App" 22 | 3. Fill in the app details: 23 | - App name: TimeKeeping Spotify (or your preferred name) 24 | - App description: A music scheduling app 25 | - Redirect URI: Your hosting URL (e.g., `https://yourdomain.com/` or `http://localhost:8080/` for local testing) 26 | 4. Check the "Web API" checkbox 27 | 5. Accept the terms and click "Save" 28 | 6. Copy your **Client ID** from the app settings 29 | 30 | ### 2. Configure the App 31 | 32 | 1. Open `js/spotify-auth.js` 33 | 2. Replace `YOUR_SPOTIFY_CLIENT_ID` with your actual Spotify Client ID: 34 | ```javascript 35 | const CLIENT_ID = 'your-actual-client-id-here'; 36 | ``` 37 | 38 | ### 3. Host the App 39 | 40 | This is a static web app that can be hosted on any HTTPS web server: 41 | 42 | #### Local Development 43 | ```bash 44 | # Using Python 45 | python -m http.server 8080 46 | 47 | # Using Node.js 48 | npx serve . 49 | 50 | # Using PHP 51 | php -S localhost:8080 52 | ``` 53 | 54 | Then open `http://localhost:8080` in your browser. 55 | 56 | #### Apache Server 57 | Simply copy all files to your Apache web root directory (e.g., `/var/www/html/`). 58 | 59 | Make sure your Apache server: 60 | - Has HTTPS enabled (required for Spotify API) 61 | - The redirect URI in your Spotify app matches your hosting URL 62 | 63 | ## Usage 64 | 65 | 1. **Login**: Click "Login with Spotify" to authenticate 66 | 2. **Schedule Music**: 67 | - Set the time you want music to play 68 | - Search for a track or paste a Spotify URI/URL 69 | - Adjust the volume level 70 | - Optionally enable "Return to previous playlist when song finishes" 71 | - Click "Add Schedule" 72 | 3. **Manage Schedules**: 73 | - View all scheduled items 74 | - Toggle schedules on/off 75 | - Test a schedule immediately 76 | - Delete schedules 77 | 78 | ## Requirements 79 | 80 | - A Spotify Premium account (required for playback control) 81 | - An active Spotify device (app, web player, or device must be open) 82 | - A modern web browser with JavaScript enabled 83 | - HTTPS hosting (required for Spotify OAuth) 84 | 85 | ## File Structure 86 | 87 | ``` 88 | TimeKeepingSpotify/ 89 | ├── index.html # Main application page 90 | ├── css/ 91 | │ └── styles.css # Application styles 92 | ├── js/ 93 | │ ├── spotify-auth.js # Spotify OAuth 2.0 PKCE authentication 94 | │ ├── spotify-api.js # Spotify Web API wrapper 95 | │ ├── scheduler.js # Schedule management and triggering 96 | │ └── app.js # Main application logic 97 | └── README.md # This file 98 | ``` 99 | 100 | ## How It Works 101 | 102 | 1. **Authentication**: Uses Spotify's OAuth 2.0 PKCE flow (secure for client-side apps) 103 | 2. **Scheduling**: Stores schedules in browser localStorage 104 | 3. **Playback Control**: Uses Spotify Web API to: 105 | - Pause current playback 106 | - Set volume 107 | - Play the scheduled track 108 | - Restore previous playback (if enabled) 109 | 110 | ## Browser Support 111 | 112 | - Chrome 60+ 113 | - Firefox 55+ 114 | - Safari 11+ 115 | - Edge 79+ 116 | 117 | ## License 118 | 119 | MIT License 120 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | TimeKeeping Spotify 7 | 8 | 9 | 10 |
11 |
12 |

🎵 TimeKeeping Spotify

13 |

Schedule music to play at specific times

14 |
15 | 16 | 17 |
18 |
19 |

Connect to Spotify

20 |

Login with your Spotify account to control playback and schedule music.

21 | 24 |
25 |
26 | 27 | 28 | 93 | 94 | 95 | 96 |
97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /js/spotify-api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Spotify Web API Wrapper 3 | * Handles all Spotify API calls for playback control 4 | */ 5 | 6 | const SpotifyAPI = (function() { 7 | const API_BASE = 'https://api.spotify.com/v1'; 8 | 9 | /** 10 | * Make an authenticated API request 11 | */ 12 | async function apiRequest(endpoint, options = {}) { 13 | const accessToken = await SpotifyAuth.getAccessToken(); 14 | if (!accessToken) { 15 | throw new Error('Not authenticated'); 16 | } 17 | 18 | const response = await fetch(`${API_BASE}${endpoint}`, { 19 | ...options, 20 | headers: { 21 | 'Authorization': `Bearer ${accessToken}`, 22 | 'Content-Type': 'application/json', 23 | ...options.headers, 24 | }, 25 | }); 26 | 27 | // Handle 204 No Content 28 | if (response.status === 204) { 29 | return null; 30 | } 31 | 32 | // Handle errors 33 | if (!response.ok) { 34 | const error = await response.json().catch(() => ({})); 35 | throw new Error(error.error?.message || `API Error: ${response.status}`); 36 | } 37 | 38 | return response.json(); 39 | } 40 | 41 | /** 42 | * Get current user profile 43 | */ 44 | async function getCurrentUser() { 45 | return apiRequest('/me'); 46 | } 47 | 48 | /** 49 | * Get current playback state 50 | */ 51 | async function getPlaybackState() { 52 | try { 53 | return await apiRequest('/me/player'); 54 | } catch (error) { 55 | // No active device returns null 56 | return null; 57 | } 58 | } 59 | 60 | /** 61 | * Get user's available devices 62 | */ 63 | async function getDevices() { 64 | const response = await apiRequest('/me/player/devices'); 65 | return response.devices || []; 66 | } 67 | 68 | /** 69 | * Start/resume playback 70 | * @param {Object} options - Playback options 71 | * @param {string} options.deviceId - Target device ID 72 | * @param {string} options.contextUri - Spotify URI of context (album, playlist, etc.) 73 | * @param {string[]} options.uris - Array of track URIs to play 74 | * @param {number} options.positionMs - Position to start playback 75 | */ 76 | async function play(options = {}) { 77 | const query = options.deviceId ? `?device_id=${options.deviceId}` : ''; 78 | const body = {}; 79 | 80 | if (options.contextUri) { 81 | body.context_uri = options.contextUri; 82 | } 83 | if (options.uris) { 84 | body.uris = options.uris; 85 | } 86 | if (options.positionMs !== undefined) { 87 | body.position_ms = options.positionMs; 88 | } 89 | 90 | return apiRequest(`/me/player/play${query}`, { 91 | method: 'PUT', 92 | body: JSON.stringify(body), 93 | }); 94 | } 95 | 96 | /** 97 | * Pause playback 98 | * @param {string} deviceId - Target device ID (optional) 99 | */ 100 | async function pause(deviceId) { 101 | const query = deviceId ? `?device_id=${deviceId}` : ''; 102 | return apiRequest(`/me/player/pause${query}`, { 103 | method: 'PUT', 104 | }); 105 | } 106 | 107 | /** 108 | * Set volume 109 | * @param {number} volumePercent - Volume level (0-100) 110 | * @param {string} deviceId - Target device ID (optional) 111 | */ 112 | async function setVolume(volumePercent, deviceId) { 113 | const query = new URLSearchParams({ 114 | volume_percent: Math.min(100, Math.max(0, volumePercent)), 115 | }); 116 | if (deviceId) { 117 | query.append('device_id', deviceId); 118 | } 119 | return apiRequest(`/me/player/volume?${query.toString()}`, { 120 | method: 'PUT', 121 | }); 122 | } 123 | 124 | /** 125 | * Skip to next track 126 | */ 127 | async function next() { 128 | return apiRequest('/me/player/next', { 129 | method: 'POST', 130 | }); 131 | } 132 | 133 | /** 134 | * Skip to previous track 135 | */ 136 | async function previous() { 137 | return apiRequest('/me/player/previous', { 138 | method: 'POST', 139 | }); 140 | } 141 | 142 | /** 143 | * Search for tracks 144 | * @param {string} query - Search query 145 | * @param {number} limit - Number of results (default: 5) 146 | */ 147 | async function searchTracks(query, limit = 5) { 148 | const params = new URLSearchParams({ 149 | q: query, 150 | type: 'track', 151 | limit: limit, 152 | }); 153 | const response = await apiRequest(`/search?${params.toString()}`); 154 | return response.tracks?.items || []; 155 | } 156 | 157 | /** 158 | * Get track information 159 | * @param {string} trackId - Spotify track ID 160 | */ 161 | async function getTrack(trackId) { 162 | return apiRequest(`/tracks/${trackId}`); 163 | } 164 | 165 | /** 166 | * Get current playing track 167 | */ 168 | async function getCurrentlyPlaying() { 169 | try { 170 | return await apiRequest('/me/player/currently-playing'); 171 | } catch { 172 | return null; 173 | } 174 | } 175 | 176 | /** 177 | * Transfer playback to a specific device 178 | * @param {string} deviceId - Target device ID 179 | * @param {boolean} play - Start playing on new device 180 | */ 181 | async function transferPlayback(deviceId, play = false) { 182 | return apiRequest('/me/player', { 183 | method: 'PUT', 184 | body: JSON.stringify({ 185 | device_ids: [deviceId], 186 | play: play, 187 | }), 188 | }); 189 | } 190 | 191 | /** 192 | * Extract track ID from Spotify URI or URL 193 | * @param {string} input - Spotify URI or URL 194 | */ 195 | function extractTrackId(input) { 196 | // Handle Spotify URI format: spotify:track:XXXX 197 | const uriMatch = input.match(/spotify:track:([a-zA-Z0-9]+)/); 198 | if (uriMatch) { 199 | return uriMatch[1]; 200 | } 201 | 202 | // Handle Spotify URL format: https://open.spotify.com/track/XXXX 203 | const urlMatch = input.match(/open\.spotify\.com\/track\/([a-zA-Z0-9]+)/); 204 | if (urlMatch) { 205 | return urlMatch[1]; 206 | } 207 | 208 | return null; 209 | } 210 | 211 | /** 212 | * Convert track ID to URI 213 | * @param {string} trackId - Spotify track ID 214 | */ 215 | function trackIdToUri(trackId) { 216 | return `spotify:track:${trackId}`; 217 | } 218 | 219 | // Public API 220 | return { 221 | getCurrentUser, 222 | getPlaybackState, 223 | getDevices, 224 | play, 225 | pause, 226 | setVolume, 227 | next, 228 | previous, 229 | searchTracks, 230 | getTrack, 231 | getCurrentlyPlaying, 232 | transferPlayback, 233 | extractTrackId, 234 | trackIdToUri, 235 | }; 236 | })(); 237 | -------------------------------------------------------------------------------- /js/spotify-auth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Spotify OAuth 2.0 PKCE Authentication for Static Web Apps 3 | * This module handles the authentication flow with Spotify 4 | */ 5 | 6 | const SpotifyAuth = (function() { 7 | // Configuration - User should update CLIENT_ID with their Spotify App Client ID 8 | // Redirect URI should match what's registered in Spotify Developer Dashboard 9 | const CLIENT_ID = '87f43a21f2684990a3048c0b33bf52a5'; // Replace with your Spotify Client ID 10 | const REDIRECT_URI = window.location.origin + window.location.pathname; 11 | const SCOPES = [ 12 | 'user-read-private', 13 | 'user-read-email', 14 | 'user-read-playback-state', 15 | 'user-modify-playback-state', 16 | 'user-read-currently-playing', 17 | 'streaming' 18 | ].join(' '); 19 | 20 | const AUTH_ENDPOINT = 'https://accounts.spotify.com/authorize'; 21 | const TOKEN_ENDPOINT = 'https://accounts.spotify.com/api/token'; 22 | 23 | // Storage keys 24 | const ACCESS_TOKEN_KEY = 'spotify_access_token'; 25 | const REFRESH_TOKEN_KEY = 'spotify_refresh_token'; 26 | const TOKEN_EXPIRY_KEY = 'spotify_token_expiry'; 27 | const CODE_VERIFIER_KEY = 'spotify_code_verifier'; 28 | 29 | /** 30 | * Generate a random string for PKCE 31 | */ 32 | function generateRandomString(length) { 33 | const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 34 | const values = crypto.getRandomValues(new Uint8Array(length)); 35 | return values.reduce((acc, x) => acc + possible[x % possible.length], ''); 36 | } 37 | 38 | /** 39 | * Generate SHA-256 hash 40 | */ 41 | async function sha256(plain) { 42 | const encoder = new TextEncoder(); 43 | const data = encoder.encode(plain); 44 | return window.crypto.subtle.digest('SHA-256', data); 45 | } 46 | 47 | /** 48 | * Base64 URL encode 49 | */ 50 | function base64urlencode(arrayBuffer) { 51 | let str = ''; 52 | const bytes = new Uint8Array(arrayBuffer); 53 | for (let i = 0; i < bytes.byteLength; i++) { 54 | str += String.fromCharCode(bytes[i]); 55 | } 56 | return btoa(str) 57 | .replace(/\+/g, '-') 58 | .replace(/\//g, '_') 59 | .replace(/=+$/, ''); 60 | } 61 | 62 | /** 63 | * Generate PKCE code challenge from verifier 64 | */ 65 | async function generateCodeChallenge(codeVerifier) { 66 | const hashed = await sha256(codeVerifier); 67 | return base64urlencode(hashed); 68 | } 69 | 70 | /** 71 | * Initiate the login flow 72 | */ 73 | async function login() { 74 | const codeVerifier = generateRandomString(64); 75 | const codeChallenge = await generateCodeChallenge(codeVerifier); 76 | 77 | // Store code verifier for later use 78 | localStorage.setItem(CODE_VERIFIER_KEY, codeVerifier); 79 | 80 | const params = new URLSearchParams({ 81 | client_id: CLIENT_ID, 82 | response_type: 'code', 83 | redirect_uri: REDIRECT_URI, 84 | scope: SCOPES, 85 | code_challenge_method: 'S256', 86 | code_challenge: codeChallenge, 87 | show_dialog: 'true' 88 | }); 89 | 90 | window.location.href = `${AUTH_ENDPOINT}?${params.toString()}`; 91 | } 92 | 93 | /** 94 | * Handle the callback from Spotify OAuth 95 | */ 96 | async function handleCallback() { 97 | const urlParams = new URLSearchParams(window.location.search); 98 | const code = urlParams.get('code'); 99 | const error = urlParams.get('error'); 100 | 101 | if (error) { 102 | console.error('Authorization error:', error); 103 | return false; 104 | } 105 | 106 | if (!code) { 107 | return false; 108 | } 109 | 110 | const codeVerifier = localStorage.getItem(CODE_VERIFIER_KEY); 111 | if (!codeVerifier) { 112 | console.error('Code verifier not found'); 113 | return false; 114 | } 115 | 116 | try { 117 | const response = await fetch(TOKEN_ENDPOINT, { 118 | method: 'POST', 119 | headers: { 120 | 'Content-Type': 'application/x-www-form-urlencoded', 121 | }, 122 | body: new URLSearchParams({ 123 | client_id: CLIENT_ID, 124 | grant_type: 'authorization_code', 125 | code: code, 126 | redirect_uri: REDIRECT_URI, 127 | code_verifier: codeVerifier, 128 | }), 129 | }); 130 | 131 | const data = await response.json(); 132 | 133 | if (data.error) { 134 | console.error('Token error:', data.error); 135 | return false; 136 | } 137 | 138 | // Store tokens 139 | storeTokens(data); 140 | 141 | // Clear the URL parameters 142 | window.history.replaceState({}, document.title, REDIRECT_URI); 143 | 144 | // Clear code verifier 145 | localStorage.removeItem(CODE_VERIFIER_KEY); 146 | 147 | return true; 148 | } catch (error) { 149 | console.error('Token exchange error:', error); 150 | return false; 151 | } 152 | } 153 | 154 | /** 155 | * Store tokens in localStorage 156 | */ 157 | function storeTokens(tokenData) { 158 | const expiryTime = Date.now() + (tokenData.expires_in * 1000); 159 | localStorage.setItem(ACCESS_TOKEN_KEY, tokenData.access_token); 160 | localStorage.setItem(TOKEN_EXPIRY_KEY, expiryTime.toString()); 161 | if (tokenData.refresh_token) { 162 | localStorage.setItem(REFRESH_TOKEN_KEY, tokenData.refresh_token); 163 | } 164 | } 165 | 166 | /** 167 | * Refresh the access token 168 | */ 169 | async function refreshToken() { 170 | const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); 171 | if (!refreshToken) { 172 | return false; 173 | } 174 | 175 | try { 176 | const response = await fetch(TOKEN_ENDPOINT, { 177 | method: 'POST', 178 | headers: { 179 | 'Content-Type': 'application/x-www-form-urlencoded', 180 | }, 181 | body: new URLSearchParams({ 182 | client_id: CLIENT_ID, 183 | grant_type: 'refresh_token', 184 | refresh_token: refreshToken, 185 | }), 186 | }); 187 | 188 | const data = await response.json(); 189 | 190 | if (data.error) { 191 | console.error('Token refresh error:', data.error); 192 | return false; 193 | } 194 | 195 | storeTokens(data); 196 | return true; 197 | } catch (error) { 198 | console.error('Token refresh error:', error); 199 | return false; 200 | } 201 | } 202 | 203 | /** 204 | * Get the current access token, refreshing if necessary 205 | */ 206 | async function getAccessToken() { 207 | const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY); 208 | const tokenExpiry = localStorage.getItem(TOKEN_EXPIRY_KEY); 209 | 210 | if (!accessToken) { 211 | return null; 212 | } 213 | 214 | // Check if token is expired or will expire in the next 5 minutes 215 | const expiryBuffer = 5 * 60 * 1000; // 5 minutes 216 | if (tokenExpiry && Date.now() > (parseInt(tokenExpiry) - expiryBuffer)) { 217 | const refreshed = await refreshToken(); 218 | if (!refreshed) { 219 | return null; 220 | } 221 | return localStorage.getItem(ACCESS_TOKEN_KEY); 222 | } 223 | 224 | return accessToken; 225 | } 226 | 227 | /** 228 | * Check if user is logged in 229 | */ 230 | function isLoggedIn() { 231 | return localStorage.getItem(ACCESS_TOKEN_KEY) !== null; 232 | } 233 | 234 | /** 235 | * Logout - clear all stored tokens 236 | */ 237 | function logout() { 238 | localStorage.removeItem(ACCESS_TOKEN_KEY); 239 | localStorage.removeItem(REFRESH_TOKEN_KEY); 240 | localStorage.removeItem(TOKEN_EXPIRY_KEY); 241 | localStorage.removeItem(CODE_VERIFIER_KEY); 242 | } 243 | 244 | /** 245 | * Get the Client ID (for verification) 246 | */ 247 | function getClientId() { 248 | return CLIENT_ID; 249 | } 250 | 251 | // Public API 252 | return { 253 | login, 254 | handleCallback, 255 | getAccessToken, 256 | isLoggedIn, 257 | logout, 258 | refreshToken, 259 | getClientId 260 | }; 261 | })(); 262 | -------------------------------------------------------------------------------- /css/styles.css: -------------------------------------------------------------------------------- 1 | /* Reset and Base Styles */ 2 | * { 3 | margin: 0; 4 | padding: 0; 5 | box-sizing: border-box; 6 | } 7 | 8 | body { 9 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; 10 | background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); 11 | min-height: 100vh; 12 | color: #ffffff; 13 | line-height: 1.6; 14 | } 15 | 16 | .container { 17 | max-width: 800px; 18 | margin: 0 auto; 19 | padding: 20px; 20 | } 21 | 22 | /* Header */ 23 | header { 24 | text-align: center; 25 | margin-bottom: 30px; 26 | padding: 20px 0; 27 | } 28 | 29 | header h1 { 30 | font-size: 2.5rem; 31 | color: #1DB954; 32 | margin-bottom: 10px; 33 | } 34 | 35 | .subtitle { 36 | color: #b3b3b3; 37 | font-size: 1.1rem; 38 | } 39 | 40 | /* Cards */ 41 | .card { 42 | background: rgba(255, 255, 255, 0.1); 43 | backdrop-filter: blur(10px); 44 | border-radius: 12px; 45 | padding: 25px; 46 | margin-bottom: 20px; 47 | border: 1px solid rgba(255, 255, 255, 0.1); 48 | } 49 | 50 | .card h2 { 51 | color: #1DB954; 52 | margin-bottom: 15px; 53 | font-size: 1.3rem; 54 | } 55 | 56 | /* Buttons */ 57 | .btn { 58 | display: inline-flex; 59 | align-items: center; 60 | justify-content: center; 61 | gap: 10px; 62 | padding: 12px 24px; 63 | border: none; 64 | border-radius: 50px; 65 | font-size: 1rem; 66 | font-weight: 600; 67 | cursor: pointer; 68 | transition: all 0.3s ease; 69 | } 70 | 71 | .btn-primary { 72 | background: #1DB954; 73 | color: #ffffff; 74 | } 75 | 76 | .btn-primary:hover { 77 | background: #1ed760; 78 | transform: translateY(-2px); 79 | box-shadow: 0 4px 15px rgba(29, 185, 84, 0.4); 80 | } 81 | 82 | .btn-secondary { 83 | background: rgba(255, 255, 255, 0.1); 84 | color: #ffffff; 85 | border: 1px solid rgba(255, 255, 255, 0.2); 86 | } 87 | 88 | .btn-secondary:hover { 89 | background: rgba(255, 255, 255, 0.2); 90 | } 91 | 92 | .btn-danger { 93 | background: #e74c3c; 94 | color: #ffffff; 95 | } 96 | 97 | .btn-danger:hover { 98 | background: #c0392b; 99 | } 100 | 101 | .btn-small { 102 | padding: 8px 16px; 103 | font-size: 0.85rem; 104 | } 105 | 106 | .spotify-icon { 107 | font-size: 1.2rem; 108 | } 109 | 110 | /* User Info */ 111 | .user-info { 112 | display: flex; 113 | justify-content: space-between; 114 | align-items: center; 115 | flex-wrap: wrap; 116 | gap: 15px; 117 | } 118 | 119 | .user-details { 120 | display: flex; 121 | align-items: center; 122 | gap: 15px; 123 | } 124 | 125 | .avatar { 126 | width: 50px; 127 | height: 50px; 128 | border-radius: 50%; 129 | object-fit: cover; 130 | border: 2px solid #1DB954; 131 | } 132 | 133 | /* Form Styles */ 134 | .form-group { 135 | margin-bottom: 20px; 136 | } 137 | 138 | .form-group label { 139 | display: block; 140 | margin-bottom: 8px; 141 | color: #b3b3b3; 142 | font-weight: 500; 143 | } 144 | 145 | .form-group input[type="text"], 146 | .form-group input[type="time"] { 147 | width: 100%; 148 | padding: 12px 15px; 149 | border: 1px solid rgba(255, 255, 255, 0.2); 150 | border-radius: 8px; 151 | background: rgba(255, 255, 255, 0.1); 152 | color: #ffffff; 153 | font-size: 1rem; 154 | } 155 | 156 | .form-group input[type="text"]:focus, 157 | .form-group input[type="time"]:focus { 158 | outline: none; 159 | border-color: #1DB954; 160 | box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.2); 161 | } 162 | 163 | .form-group input[type="range"] { 164 | width: calc(100% - 60px); 165 | margin-right: 10px; 166 | accent-color: #1DB954; 167 | } 168 | 169 | #volume-display { 170 | color: #1DB954; 171 | font-weight: 600; 172 | } 173 | 174 | .checkbox-group { 175 | display: flex; 176 | align-items: center; 177 | gap: 10px; 178 | } 179 | 180 | .checkbox-group input[type="checkbox"] { 181 | width: 20px; 182 | height: 20px; 183 | accent-color: #1DB954; 184 | } 185 | 186 | .checkbox-group label { 187 | margin-bottom: 0; 188 | cursor: pointer; 189 | } 190 | 191 | /* Track Duration Info */ 192 | .track-duration-info { 193 | margin-top: 8px; 194 | padding: 8px 12px; 195 | background: rgba(29, 185, 84, 0.15); 196 | border-radius: 6px; 197 | color: #b3b3b3; 198 | font-size: 0.9rem; 199 | } 200 | 201 | .track-duration-info strong { 202 | color: #1DB954; 203 | } 204 | 205 | /* Playback Duration Controls */ 206 | #playback-duration { 207 | width: calc(50% - 10px); 208 | margin-right: 10px; 209 | } 210 | 211 | #playback-duration-number { 212 | width: 70px; 213 | padding: 6px 8px; 214 | border: 1px solid rgba(255, 255, 255, 0.2); 215 | border-radius: 6px; 216 | background: rgba(255, 255, 255, 0.1); 217 | color: #ffffff; 218 | font-size: 0.9rem; 219 | text-align: center; 220 | } 221 | 222 | #playback-duration-number:focus { 223 | outline: none; 224 | border-color: #1DB954; 225 | } 226 | 227 | #playback-duration-display { 228 | color: #1DB954; 229 | font-weight: 600; 230 | margin: 0 8px; 231 | } 232 | 233 | .duration-hint { 234 | color: #b3b3b3; 235 | font-size: 0.85rem; 236 | font-style: italic; 237 | } 238 | 239 | /* Search Results */ 240 | .search-results { 241 | margin-top: 10px; 242 | max-height: 200px; 243 | overflow-y: auto; 244 | background: rgba(0, 0, 0, 0.3); 245 | border-radius: 8px; 246 | } 247 | 248 | .search-result-item { 249 | display: flex; 250 | align-items: center; 251 | gap: 10px; 252 | padding: 10px; 253 | cursor: pointer; 254 | transition: background 0.2s; 255 | } 256 | 257 | .search-result-item:hover { 258 | background: rgba(29, 185, 84, 0.2); 259 | } 260 | 261 | .search-result-item img { 262 | width: 40px; 263 | height: 40px; 264 | border-radius: 4px; 265 | } 266 | 267 | .search-result-item .track-info { 268 | flex: 1; 269 | } 270 | 271 | .search-result-item .track-name { 272 | font-weight: 500; 273 | } 274 | 275 | .search-result-item .track-artist { 276 | font-size: 0.85rem; 277 | color: #b3b3b3; 278 | } 279 | 280 | /* Schedules List */ 281 | .schedules-list { 282 | display: flex; 283 | flex-direction: column; 284 | gap: 10px; 285 | } 286 | 287 | .schedule-item { 288 | display: flex; 289 | align-items: center; 290 | justify-content: space-between; 291 | padding: 15px; 292 | background: rgba(0, 0, 0, 0.2); 293 | border-radius: 8px; 294 | gap: 15px; 295 | flex-wrap: wrap; 296 | transition: all 0.3s ease; 297 | } 298 | 299 | .schedule-item.active-schedule { 300 | background: rgba(29, 185, 84, 0.15); 301 | border: 2px solid rgba(29, 185, 84, 0.5); 302 | box-shadow: 0 0 15px rgba(29, 185, 84, 0.3); 303 | } 304 | 305 | .schedule-item .time { 306 | font-size: 1.5rem; 307 | font-weight: 700; 308 | color: #1DB954; 309 | min-width: 80px; 310 | } 311 | 312 | .schedule-item .track-info { 313 | flex: 1; 314 | min-width: 150px; 315 | } 316 | 317 | .schedule-item .track-name { 318 | font-weight: 500; 319 | } 320 | 321 | .schedule-item .track-details { 322 | font-size: 0.85rem; 323 | color: #b3b3b3; 324 | } 325 | 326 | .schedule-item .countdown { 327 | font-size: 0.85rem; 328 | color: #1DB954; 329 | font-weight: 500; 330 | margin-top: 4px; 331 | } 332 | 333 | .schedule-item .countdown.past { 334 | color: #e74c3c; 335 | } 336 | 337 | .schedule-item .schedule-actions { 338 | display: flex; 339 | gap: 10px; 340 | } 341 | 342 | .schedule-item .restore-badge { 343 | background: rgba(29, 185, 84, 0.2); 344 | color: #1DB954; 345 | padding: 4px 8px; 346 | border-radius: 4px; 347 | font-size: 0.75rem; 348 | } 349 | 350 | /* Active Schedule Status */ 351 | .active-status { 352 | margin-top: 8px; 353 | padding: 10px; 354 | background: rgba(29, 185, 84, 0.2); 355 | border-radius: 6px; 356 | border-left: 3px solid #1DB954; 357 | } 358 | 359 | .active-badge { 360 | display: inline-block; 361 | background: #1DB954; 362 | color: #ffffff; 363 | padding: 4px 10px; 364 | border-radius: 4px; 365 | font-size: 0.75rem; 366 | font-weight: 700; 367 | letter-spacing: 1px; 368 | margin-right: 10px; 369 | animation: pulse 2s ease-in-out infinite; 370 | } 371 | 372 | @keyframes pulse { 373 | 0%, 100% { 374 | opacity: 1; 375 | } 376 | 50% { 377 | opacity: 0.7; 378 | } 379 | } 380 | 381 | .active-time { 382 | font-size: 0.85rem; 383 | color: #b3b3b3; 384 | } 385 | 386 | .active-time strong { 387 | color: #1DB954; 388 | font-weight: 600; 389 | } 390 | 391 | /* Playback Info */ 392 | .playback-info { 393 | display: flex; 394 | align-items: center; 395 | gap: 15px; 396 | flex-wrap: wrap; 397 | } 398 | 399 | .playback-info img { 400 | width: 60px; 401 | height: 60px; 402 | border-radius: 4px; 403 | } 404 | 405 | .playback-info .now-playing { 406 | flex: 1; 407 | } 408 | 409 | .playback-info .now-playing-label { 410 | font-size: 0.85rem; 411 | color: #1DB954; 412 | text-transform: uppercase; 413 | letter-spacing: 1px; 414 | } 415 | 416 | .playback-info .now-playing-track { 417 | font-weight: 600; 418 | font-size: 1.1rem; 419 | } 420 | 421 | .playback-info .now-playing-artist { 422 | color: #b3b3b3; 423 | } 424 | 425 | /* Toast Notifications */ 426 | .toast { 427 | position: fixed; 428 | bottom: 30px; 429 | left: 50%; 430 | transform: translateX(-50%); 431 | background: #1DB954; 432 | color: #ffffff; 433 | padding: 15px 30px; 434 | border-radius: 8px; 435 | box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); 436 | z-index: 1000; 437 | animation: slideUp 0.3s ease; 438 | } 439 | 440 | .toast.error { 441 | background: #e74c3c; 442 | } 443 | 444 | @keyframes slideUp { 445 | from { 446 | opacity: 0; 447 | transform: translateX(-50%) translateY(20px); 448 | } 449 | to { 450 | opacity: 1; 451 | transform: translateX(-50%) translateY(0); 452 | } 453 | } 454 | 455 | /* Utility Classes */ 456 | .hidden { 457 | display: none !important; 458 | } 459 | 460 | .text-muted { 461 | color: #b3b3b3; 462 | } 463 | 464 | /* Responsive Design */ 465 | @media (max-width: 600px) { 466 | header h1 { 467 | font-size: 1.8rem; 468 | } 469 | 470 | .card { 471 | padding: 20px; 472 | } 473 | 474 | .user-info { 475 | flex-direction: column; 476 | text-align: center; 477 | } 478 | 479 | .user-details { 480 | flex-direction: column; 481 | } 482 | 483 | .schedule-item { 484 | flex-direction: column; 485 | text-align: center; 486 | } 487 | 488 | .schedule-item .schedule-actions { 489 | width: 100%; 490 | justify-content: center; 491 | } 492 | } 493 | -------------------------------------------------------------------------------- /js/scheduler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Scheduler Module 3 | * Handles scheduling music to play at specific times 4 | */ 5 | 6 | const Scheduler = (function() { 7 | const SCHEDULES_KEY = 'spotify_schedules'; 8 | const SCHEDULE_CHECK_INTERVAL_MS = 1000; // Check every second for precise timing 9 | const MAX_TRACK_MONITOR_SECONDS = 600; // Monitor track for up to 10 minutes 10 | 11 | let schedules = []; 12 | let checkInterval = null; 13 | let previousPlaybackState = null; 14 | let activeSchedule = null; // Track the currently active schedule 15 | let activeScheduleStartTime = null; // When the active schedule started playing 16 | 17 | /** 18 | * Initialize the scheduler 19 | */ 20 | function init() { 21 | loadSchedules(); 22 | startChecking(); 23 | } 24 | 25 | /** 26 | * Load schedules from localStorage 27 | */ 28 | function loadSchedules() { 29 | const stored = localStorage.getItem(SCHEDULES_KEY); 30 | if (stored) { 31 | try { 32 | schedules = JSON.parse(stored); 33 | // Filter out any past schedules that don't repeat 34 | schedules = schedules.filter(s => !s.triggered || s.repeat); 35 | // Reset triggered flag for repeating schedules on new day 36 | schedules.forEach(s => { 37 | if (s.repeat) { 38 | const lastTriggered = s.lastTriggeredDate; 39 | const today = new Date().toDateString(); 40 | if (lastTriggered !== today) { 41 | s.triggered = false; 42 | } 43 | } 44 | }); 45 | saveSchedules(); 46 | } catch { 47 | schedules = []; 48 | } 49 | } 50 | } 51 | 52 | /** 53 | * Save schedules to localStorage 54 | */ 55 | function saveSchedules() { 56 | localStorage.setItem(SCHEDULES_KEY, JSON.stringify(schedules)); 57 | } 58 | 59 | /** 60 | * Add a new schedule 61 | * @param {Object} schedule - Schedule object 62 | * @param {string} schedule.time - Time in HH:MM format 63 | * @param {string} schedule.trackUri - Spotify track URI 64 | * @param {string} schedule.trackName - Track name for display 65 | * @param {string} schedule.artistName - Artist name for display 66 | * @param {number} schedule.volume - Volume level (0-100) 67 | * @param {boolean} schedule.restorePlayback - Whether to restore previous playback after song ends 68 | * @param {boolean} schedule.repeat - Whether to repeat daily 69 | * @param {number} schedule.playbackDuration - Duration to play in seconds 70 | * @param {number} schedule.trackDuration - Full track duration in seconds 71 | */ 72 | function addSchedule(schedule) { 73 | const newSchedule = { 74 | id: Date.now().toString(), 75 | time: schedule.time, 76 | trackUri: schedule.trackUri, 77 | trackName: schedule.trackName || 'Unknown Track', 78 | artistName: schedule.artistName || 'Unknown Artist', 79 | volume: schedule.volume || 50, 80 | restorePlayback: schedule.restorePlayback || false, 81 | repeat: schedule.repeat !== false, // Default to repeat daily 82 | triggered: false, 83 | enabled: true, 84 | playbackDuration: schedule.playbackDuration || null, 85 | trackDuration: schedule.trackDuration || null 86 | }; 87 | schedules.push(newSchedule); 88 | saveSchedules(); 89 | return newSchedule; 90 | } 91 | 92 | /** 93 | * Remove a schedule by ID 94 | * @param {string} scheduleId - Schedule ID 95 | */ 96 | function removeSchedule(scheduleId) { 97 | schedules = schedules.filter(s => s.id !== scheduleId); 98 | saveSchedules(); 99 | } 100 | 101 | /** 102 | * Toggle schedule enabled state 103 | * @param {string} scheduleId - Schedule ID 104 | */ 105 | function toggleSchedule(scheduleId) { 106 | const schedule = schedules.find(s => s.id === scheduleId); 107 | if (schedule) { 108 | schedule.enabled = !schedule.enabled; 109 | saveSchedules(); 110 | } 111 | } 112 | 113 | /** 114 | * Get all schedules 115 | */ 116 | function getSchedules() { 117 | return [...schedules]; 118 | } 119 | 120 | /** 121 | * Start checking for scheduled times 122 | */ 123 | function startChecking() { 124 | if (checkInterval) { 125 | clearInterval(checkInterval); 126 | } 127 | // Check every second for precise timing at schedule time 128 | checkInterval = setInterval(checkSchedules, SCHEDULE_CHECK_INTERVAL_MS); 129 | } 130 | 131 | /** 132 | * Stop checking for scheduled times 133 | */ 134 | function stopChecking() { 135 | if (checkInterval) { 136 | clearInterval(checkInterval); 137 | checkInterval = null; 138 | } 139 | } 140 | 141 | /** 142 | * Check if any schedules should be triggered 143 | */ 144 | async function checkSchedules() { 145 | const now = new Date(); 146 | const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; 147 | const currentSeconds = now.getSeconds(); 148 | 149 | for (const schedule of schedules) { 150 | // Only trigger at the start of the minute (within first 2 seconds) 151 | if (schedule.enabled && !schedule.triggered && schedule.time === currentTime && currentSeconds < 2) { 152 | await triggerSchedule(schedule); 153 | } 154 | } 155 | } 156 | 157 | /** 158 | * Trigger a scheduled playback 159 | * @param {Object} schedule - Schedule to trigger 160 | */ 161 | async function triggerSchedule(schedule) { 162 | try { 163 | console.log(`Triggering schedule: ${schedule.trackName} at ${schedule.time}`); 164 | 165 | // Store current playback state if restore is enabled 166 | let savedPlaybackState = null; 167 | if (schedule.restorePlayback) { 168 | savedPlaybackState = await SpotifyAPI.getPlaybackState(); 169 | previousPlaybackState = savedPlaybackState; // Keep for module-level access 170 | } 171 | 172 | // Pause current playback 173 | try { 174 | await SpotifyAPI.pause(); 175 | } catch { 176 | // Ignore if nothing is playing 177 | } 178 | 179 | // Wait a moment for pause to take effect 180 | await new Promise(resolve => setTimeout(resolve, 500)); 181 | 182 | // Set volume 183 | await SpotifyAPI.setVolume(schedule.volume); 184 | 185 | // Play the scheduled track 186 | await SpotifyAPI.play({ 187 | uris: [schedule.trackUri] 188 | }); 189 | 190 | // Mark as triggered and active 191 | schedule.triggered = true; 192 | schedule.lastTriggeredDate = new Date().toDateString(); 193 | saveSchedules(); 194 | 195 | // Set as active schedule 196 | activeSchedule = schedule; 197 | activeScheduleStartTime = Date.now(); 198 | 199 | // Show notification 200 | showNotification(`Now playing: ${schedule.trackName}`); 201 | 202 | // If playback duration is set and less than full track, monitor and stop 203 | if (schedule.playbackDuration && schedule.trackDuration && schedule.playbackDuration < schedule.trackDuration) { 204 | monitorPlaybackDuration(schedule, savedPlaybackState); 205 | } else if (schedule.restorePlayback && savedPlaybackState) { 206 | // If restore is enabled and playing full track, monitor for track end 207 | monitorTrackEnd(schedule, savedPlaybackState); 208 | } else { 209 | // No restore needed, but still monitor to clear active state 210 | monitorForCompletion(schedule); 211 | } 212 | 213 | } catch (error) { 214 | console.error('Error triggering schedule:', error); 215 | showNotification(`Error: ${error.message}`, true); 216 | activeSchedule = null; 217 | activeScheduleStartTime = null; 218 | } 219 | } 220 | 221 | /** 222 | * Monitor playback and stop after specified duration 223 | * @param {Object} schedule - The triggered schedule 224 | * @param {Object} savedPlaybackState - The saved playback state to restore 225 | */ 226 | async function monitorPlaybackDuration(schedule, savedPlaybackState) { 227 | const targetDuration = schedule.playbackDuration * 1000; // Convert to ms 228 | const startTime = Date.now(); 229 | 230 | const checkPlayback = setInterval(async () => { 231 | try { 232 | const elapsed = Date.now() - startTime; 233 | 234 | // Stop playback when duration is reached 235 | if (elapsed >= targetDuration) { 236 | clearInterval(checkPlayback); 237 | 238 | const currentState = await SpotifyAPI.getPlaybackState(); 239 | 240 | // Only pause/restore if still playing the scheduled track 241 | if (currentState && currentState.item?.uri === schedule.trackUri) { 242 | await SpotifyAPI.pause(); 243 | 244 | // If restore is enabled, restore previous playback 245 | if (schedule.restorePlayback && savedPlaybackState) { 246 | await new Promise(resolve => setTimeout(resolve, 1000)); 247 | await restorePreviousPlayback(savedPlaybackState); 248 | } 249 | } 250 | 251 | // Clear active schedule 252 | activeSchedule = null; 253 | activeScheduleStartTime = null; 254 | return; 255 | } 256 | 257 | // Check if user changed the track 258 | const currentState = await SpotifyAPI.getPlaybackState(); 259 | if (!currentState || currentState.item?.uri !== schedule.trackUri) { 260 | clearInterval(checkPlayback); 261 | // User changed the track, don't interfere 262 | activeSchedule = null; 263 | activeScheduleStartTime = null; 264 | return; 265 | } 266 | 267 | } catch (error) { 268 | console.error('Error monitoring playback duration:', error); 269 | clearInterval(checkPlayback); 270 | activeSchedule = null; 271 | activeScheduleStartTime = null; 272 | } 273 | }, 1000); 274 | } 275 | 276 | /** 277 | * Monitor for track end and restore previous playback 278 | * @param {Object} schedule - The triggered schedule 279 | * @param {Object} prevState - Previous playback state to restore 280 | */ 281 | async function monitorTrackEnd(schedule, prevState) { 282 | let checkCount = 0; 283 | 284 | const checkPlayback = setInterval(async () => { 285 | checkCount++; 286 | if (checkCount > MAX_TRACK_MONITOR_SECONDS) { 287 | clearInterval(checkPlayback); 288 | activeSchedule = null; 289 | activeScheduleStartTime = null; 290 | return; 291 | } 292 | 293 | try { 294 | const currentState = await SpotifyAPI.getPlaybackState(); 295 | 296 | // Check if the scheduled track is still playing 297 | if (!currentState || !currentState.is_playing) { 298 | clearInterval(checkPlayback); 299 | activeSchedule = null; 300 | activeScheduleStartTime = null; 301 | await restorePreviousPlayback(prevState); 302 | return; 303 | } 304 | 305 | // Check if a different track is now playing (user changed it) 306 | const currentTrackUri = currentState.item?.uri; 307 | if (currentTrackUri && currentTrackUri !== schedule.trackUri) { 308 | clearInterval(checkPlayback); 309 | // User changed the track, don't restore 310 | activeSchedule = null; 311 | activeScheduleStartTime = null; 312 | return; 313 | } 314 | 315 | // Check if track has ended (progress near duration) 316 | if (currentState.item && currentState.progress_ms >= currentState.item.duration_ms - 1000) { 317 | clearInterval(checkPlayback); 318 | activeSchedule = null; 319 | activeScheduleStartTime = null; 320 | await restorePreviousPlayback(prevState); 321 | } 322 | 323 | } catch (error) { 324 | console.error('Error monitoring playback:', error); 325 | activeSchedule = null; 326 | activeScheduleStartTime = null; 327 | } 328 | }, 1000); 329 | } 330 | 331 | /** 332 | * Monitor for playback completion (no restore) 333 | * @param {Object} schedule - The triggered schedule 334 | */ 335 | async function monitorForCompletion(schedule) { 336 | let checkCount = 0; 337 | 338 | const checkPlayback = setInterval(async () => { 339 | checkCount++; 340 | if (checkCount > MAX_TRACK_MONITOR_SECONDS) { 341 | clearInterval(checkPlayback); 342 | activeSchedule = null; 343 | activeScheduleStartTime = null; 344 | return; 345 | } 346 | 347 | try { 348 | const currentState = await SpotifyAPI.getPlaybackState(); 349 | 350 | // Check if track is no longer playing or changed 351 | if (!currentState || !currentState.is_playing || currentState.item?.uri !== schedule.trackUri) { 352 | clearInterval(checkPlayback); 353 | activeSchedule = null; 354 | activeScheduleStartTime = null; 355 | return; 356 | } 357 | 358 | } catch (error) { 359 | console.error('Error monitoring playback completion:', error); 360 | clearInterval(checkPlayback); 361 | activeSchedule = null; 362 | activeScheduleStartTime = null; 363 | } 364 | }, 1000); 365 | } 366 | 367 | /** 368 | * Restore previous playback state 369 | * @param {Object} prevState - Previous playback state 370 | */ 371 | async function restorePreviousPlayback(prevState) { 372 | try { 373 | console.log('Restoring previous playback...'); 374 | 375 | // Wait a moment for the track to fully end 376 | await new Promise(resolve => setTimeout(resolve, 1000)); 377 | 378 | // Restore volume 379 | if (prevState.device?.volume_percent !== undefined) { 380 | await SpotifyAPI.setVolume(prevState.device.volume_percent); 381 | } 382 | 383 | // If there was a context (playlist/album), restore it 384 | if (prevState.context?.uri) { 385 | await SpotifyAPI.play({ 386 | contextUri: prevState.context.uri, 387 | positionMs: prevState.progress_ms || 0, 388 | }); 389 | } else if (prevState.item?.uri) { 390 | // Otherwise, just play the previous track 391 | await SpotifyAPI.play({ 392 | uris: [prevState.item.uri], 393 | positionMs: prevState.progress_ms || 0, 394 | }); 395 | } 396 | 397 | showNotification('Restored previous playback'); 398 | } catch (error) { 399 | console.error('Error restoring playback:', error); 400 | showNotification('Could not restore previous playback', true); 401 | } 402 | } 403 | 404 | /** 405 | * Show a notification (delegated to app) 406 | */ 407 | function showNotification(message, isError = false) { 408 | // This will be handled by the app module 409 | if (typeof App !== 'undefined' && App.showToast) { 410 | App.showToast(message, isError); 411 | } else { 412 | console.log(message); 413 | } 414 | } 415 | 416 | /** 417 | * Reset all schedules for a new day 418 | */ 419 | function resetDailySchedules() { 420 | schedules.forEach(s => { 421 | if (s.repeat) { 422 | s.triggered = false; 423 | } 424 | }); 425 | saveSchedules(); 426 | } 427 | 428 | /** 429 | * Manually trigger a schedule (for testing) 430 | * @param {string} scheduleId - Schedule ID 431 | */ 432 | async function triggerNow(scheduleId) { 433 | const schedule = schedules.find(s => s.id === scheduleId); 434 | if (schedule) { 435 | schedule.triggered = false; 436 | await triggerSchedule(schedule); 437 | } 438 | } 439 | 440 | /** 441 | * Get the currently active schedule and its status 442 | * @returns {Object|null} Active schedule info or null 443 | */ 444 | function getActiveScheduleStatus() { 445 | if (!activeSchedule || !activeScheduleStartTime) { 446 | return null; 447 | } 448 | 449 | const elapsed = Math.floor((Date.now() - activeScheduleStartTime) / 1000); 450 | let remaining = null; 451 | 452 | if (activeSchedule.playbackDuration && activeSchedule.trackDuration && 453 | activeSchedule.playbackDuration < activeSchedule.trackDuration) { 454 | remaining = Math.max(0, activeSchedule.playbackDuration - elapsed); 455 | } else if (activeSchedule.trackDuration) { 456 | remaining = Math.max(0, activeSchedule.trackDuration - elapsed); 457 | } 458 | 459 | return { 460 | schedule: activeSchedule, 461 | elapsed: elapsed, 462 | remaining: remaining, 463 | willRestore: activeSchedule.restorePlayback 464 | }; 465 | } 466 | 467 | // Public API 468 | return { 469 | init, 470 | addSchedule, 471 | removeSchedule, 472 | toggleSchedule, 473 | getSchedules, 474 | startChecking, 475 | stopChecking, 476 | resetDailySchedules, 477 | triggerNow, 478 | getActiveScheduleStatus, 479 | }; 480 | })(); 481 | -------------------------------------------------------------------------------- /js/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Main Application Module 3 | * Coordinates all other modules and handles UI 4 | */ 5 | 6 | const App = (function() { 7 | // DOM Elements 8 | let loginSection; 9 | let appSection; 10 | let loginBtn; 11 | let logoutBtn; 12 | let userAvatar; 13 | let userName; 14 | let userEmail; 15 | let currentPlayback; 16 | let scheduleForm; 17 | let scheduleTime; 18 | let scheduleTrack; 19 | let scheduleVolume; 20 | let volumeDisplay; 21 | let scheduleRestore; 22 | let searchResults; 23 | let schedulesList; 24 | let toast; 25 | let playbackDuration; 26 | let playbackDurationNumber; 27 | let playbackDurationDisplay; 28 | let trackDurationInfo; 29 | let trackDurationDisplay; 30 | 31 | // State 32 | let selectedTrack = null; 33 | let searchTimeout = null; 34 | let playbackInterval = null; 35 | let countdownInterval = null; 36 | 37 | // Default avatar for users without a profile image 38 | const DEFAULT_AVATAR = 'data:image/svg+xml,' + encodeURIComponent( 39 | '' + 40 | '' + 41 | '👤' + 42 | '' 43 | ); 44 | 45 | /** 46 | * Initialize the application 47 | */ 48 | async function init() { 49 | // Cache DOM elements 50 | cacheElements(); 51 | 52 | // Attach event listeners 53 | attachEventListeners(); 54 | 55 | // Check for OAuth callback 56 | const callbackHandled = await SpotifyAuth.handleCallback(); 57 | 58 | // Check login status 59 | if (SpotifyAuth.isLoggedIn()) { 60 | await showApp(); 61 | } else if (!callbackHandled) { 62 | showLogin(); 63 | } 64 | } 65 | 66 | /** 67 | * Cache DOM elements for later use 68 | */ 69 | function cacheElements() { 70 | loginSection = document.getElementById('login-section'); 71 | appSection = document.getElementById('app-section'); 72 | loginBtn = document.getElementById('login-btn'); 73 | logoutBtn = document.getElementById('logout-btn'); 74 | userAvatar = document.getElementById('user-avatar'); 75 | userName = document.getElementById('user-name'); 76 | userEmail = document.getElementById('user-email'); 77 | currentPlayback = document.getElementById('current-playback'); 78 | scheduleForm = document.getElementById('schedule-form'); 79 | scheduleTime = document.getElementById('schedule-time'); 80 | scheduleTrack = document.getElementById('schedule-track'); 81 | scheduleVolume = document.getElementById('schedule-volume'); 82 | volumeDisplay = document.getElementById('volume-display'); 83 | scheduleRestore = document.getElementById('schedule-restore'); 84 | searchResults = document.getElementById('search-results'); 85 | schedulesList = document.getElementById('schedules-list'); 86 | toast = document.getElementById('toast'); 87 | playbackDuration = document.getElementById('playback-duration'); 88 | playbackDurationNumber = document.getElementById('playback-duration-number'); 89 | playbackDurationDisplay = document.getElementById('playback-duration-display'); 90 | trackDurationInfo = document.getElementById('track-duration-info'); 91 | trackDurationDisplay = document.getElementById('track-duration-display'); 92 | } 93 | 94 | /** 95 | * Attach event listeners 96 | */ 97 | function attachEventListeners() { 98 | // Login button 99 | loginBtn.addEventListener('click', () => { 100 | SpotifyAuth.login(); 101 | }); 102 | 103 | // Logout button 104 | logoutBtn.addEventListener('click', handleLogout); 105 | 106 | // Volume slider 107 | scheduleVolume.addEventListener('input', () => { 108 | volumeDisplay.textContent = `${scheduleVolume.value}%`; 109 | }); 110 | 111 | // Playback duration slider and number input 112 | playbackDuration.addEventListener('input', () => { 113 | const seconds = parseInt(playbackDuration.value); 114 | playbackDurationNumber.value = seconds; 115 | updatePlaybackDurationDisplay(seconds); 116 | }); 117 | 118 | playbackDurationNumber.addEventListener('input', () => { 119 | const seconds = Math.max(0, parseInt(playbackDurationNumber.value) || 0); 120 | const maxSeconds = parseInt(playbackDuration.max); 121 | const clampedSeconds = Math.min(seconds, maxSeconds); 122 | playbackDuration.value = clampedSeconds; 123 | playbackDurationNumber.value = clampedSeconds; 124 | updatePlaybackDurationDisplay(clampedSeconds); 125 | }); 126 | 127 | // Track search 128 | scheduleTrack.addEventListener('input', handleTrackSearch); 129 | scheduleTrack.addEventListener('focus', () => { 130 | if (searchResults.children.length > 0) { 131 | searchResults.classList.remove('hidden'); 132 | } 133 | }); 134 | 135 | // Close search results when clicking outside 136 | document.addEventListener('click', (e) => { 137 | if (!scheduleTrack.contains(e.target) && !searchResults.contains(e.target)) { 138 | searchResults.classList.add('hidden'); 139 | } 140 | }); 141 | 142 | // Schedule form submission 143 | scheduleForm.addEventListener('submit', handleScheduleSubmit); 144 | } 145 | 146 | /** 147 | * Show login section 148 | */ 149 | function showLogin() { 150 | loginSection.classList.remove('hidden'); 151 | appSection.classList.add('hidden'); 152 | } 153 | 154 | /** 155 | * Show main app section 156 | */ 157 | async function showApp() { 158 | loginSection.classList.add('hidden'); 159 | appSection.classList.remove('hidden'); 160 | 161 | try { 162 | // Load user profile 163 | const user = await SpotifyAPI.getCurrentUser(); 164 | userName.textContent = user.display_name || 'User'; 165 | userEmail.textContent = user.email || ''; 166 | if (user.images && user.images.length > 0) { 167 | userAvatar.src = user.images[0].url; 168 | } else { 169 | userAvatar.src = DEFAULT_AVATAR; 170 | } 171 | 172 | // Initialize scheduler 173 | Scheduler.init(); 174 | 175 | // Load schedules 176 | renderSchedules(); 177 | 178 | // Start playback monitoring 179 | startPlaybackMonitoring(); 180 | 181 | } catch (error) { 182 | console.error('Error loading app:', error); 183 | showToast('Error loading app. Please try logging in again.', true); 184 | } 185 | } 186 | 187 | /** 188 | * Handle logout 189 | */ 190 | function handleLogout() { 191 | SpotifyAuth.logout(); 192 | Scheduler.stopChecking(); 193 | if (playbackInterval) { 194 | clearInterval(playbackInterval); 195 | } 196 | if (countdownInterval) { 197 | clearInterval(countdownInterval); 198 | } 199 | showLogin(); 200 | showToast('Logged out successfully'); 201 | } 202 | 203 | /** 204 | * Handle track search input 205 | */ 206 | async function handleTrackSearch(e) { 207 | const query = e.target.value.trim(); 208 | 209 | // Clear previous timeout 210 | if (searchTimeout) { 211 | clearTimeout(searchTimeout); 212 | } 213 | 214 | // Check if it's a Spotify URI or URL 215 | const trackId = SpotifyAPI.extractTrackId(query); 216 | if (trackId) { 217 | try { 218 | const track = await SpotifyAPI.getTrack(trackId); 219 | selectTrack(track); 220 | searchResults.classList.add('hidden'); 221 | } catch { 222 | // Not a valid track, continue with search 223 | } 224 | return; 225 | } 226 | 227 | // Don't search for short queries 228 | if (query.length < 2) { 229 | searchResults.classList.add('hidden'); 230 | return; 231 | } 232 | 233 | // Debounce search 234 | searchTimeout = setTimeout(async () => { 235 | try { 236 | const tracks = await SpotifyAPI.searchTracks(query); 237 | renderSearchResults(tracks); 238 | } catch (error) { 239 | console.error('Search error:', error); 240 | } 241 | }, 300); 242 | } 243 | 244 | /** 245 | * Render search results 246 | */ 247 | function renderSearchResults(tracks) { 248 | if (tracks.length === 0) { 249 | searchResults.classList.add('hidden'); 250 | return; 251 | } 252 | 253 | searchResults.innerHTML = tracks.map(track => ` 254 |
255 | 256 |
257 |
${escapeHtml(track.name)}
258 |
${escapeHtml(track.artists.map(a => a.name).join(', '))}
259 |
260 |
261 | `).join(''); 262 | 263 | // Add click handlers to results 264 | searchResults.querySelectorAll('.search-result-item').forEach(item => { 265 | item.addEventListener('click', () => { 266 | const track = { 267 | uri: item.dataset.uri, 268 | name: item.dataset.name, 269 | artists: [{ name: item.dataset.artist }], 270 | duration_ms: parseInt(item.dataset.duration) 271 | }; 272 | selectTrack(track); 273 | }); 274 | }); 275 | 276 | searchResults.classList.remove('hidden'); 277 | } 278 | 279 | /** 280 | * Select a track from search results 281 | */ 282 | function selectTrack(track) { 283 | selectedTrack = { 284 | uri: track.uri, 285 | name: track.name, 286 | artist: track.artists[0]?.name || 'Unknown', 287 | duration_ms: track.duration_ms 288 | }; 289 | scheduleTrack.value = `${track.name} - ${selectedTrack.artist}`; 290 | searchResults.classList.add('hidden'); 291 | 292 | // Update track duration display 293 | if (track.duration_ms) { 294 | const durationSeconds = Math.floor(track.duration_ms / 1000); 295 | trackDurationDisplay.textContent = formatTime(durationSeconds); 296 | trackDurationInfo.classList.remove('hidden'); 297 | 298 | // Update playback duration slider max 299 | playbackDuration.max = durationSeconds; 300 | playbackDurationNumber.max = durationSeconds; 301 | 302 | // Set to full track by default 303 | playbackDuration.value = durationSeconds; 304 | playbackDurationNumber.value = durationSeconds; 305 | updatePlaybackDurationDisplay(durationSeconds); 306 | } 307 | } 308 | 309 | /** 310 | * Handle schedule form submission 311 | */ 312 | async function handleScheduleSubmit(e) { 313 | e.preventDefault(); 314 | 315 | if (!selectedTrack) { 316 | showToast('Please select a track first', true); 317 | return; 318 | } 319 | 320 | const time = scheduleTime.value; 321 | if (!time) { 322 | showToast('Please set a time', true); 323 | return; 324 | } 325 | 326 | const playbackDurationSeconds = parseInt(playbackDuration.value); 327 | 328 | const schedule = Scheduler.addSchedule({ 329 | time: time, 330 | trackUri: selectedTrack.uri, 331 | trackName: selectedTrack.name, 332 | artistName: selectedTrack.artist, 333 | volume: parseInt(scheduleVolume.value), 334 | restorePlayback: scheduleRestore.checked, 335 | playbackDuration: playbackDurationSeconds, 336 | trackDuration: selectedTrack.duration_ms ? Math.floor(selectedTrack.duration_ms / 1000) : null 337 | }); 338 | 339 | // Only reset the time field (keep volume, track, checkbox, and playback duration) 340 | scheduleTime.value = ''; 341 | 342 | // Refresh schedule list 343 | renderSchedules(); 344 | 345 | // Suggest next time based on pattern 346 | suggestNextTime(schedule.time); 347 | 348 | showToast(`Scheduled: ${schedule.trackName} at ${schedule.time}`); 349 | } 350 | 351 | /** 352 | * Render the list of schedules 353 | */ 354 | function renderSchedules() { 355 | const schedules = Scheduler.getSchedules(); 356 | const activeStatus = Scheduler.getActiveScheduleStatus(); 357 | 358 | if (schedules.length === 0) { 359 | schedulesList.innerHTML = '

No scheduled items

'; 360 | return; 361 | } 362 | 363 | // Sort by time 364 | schedules.sort((a, b) => a.time.localeCompare(b.time)); 365 | 366 | schedulesList.innerHTML = schedules.map(schedule => { 367 | const countdownText = getCountdownText(schedule.time); 368 | const playbackInfo = schedule.playbackDuration && schedule.trackDuration ? 369 | ` · Play: ${formatTime(schedule.playbackDuration)}/${formatTime(schedule.trackDuration)}` : ''; 370 | 371 | const isActive = activeStatus && activeStatus.schedule.id === schedule.id; 372 | const activeClass = isActive ? 'active-schedule' : ''; 373 | 374 | let activeStatusHTML = ''; 375 | if (isActive) { 376 | const elapsedText = formatTime(activeStatus.elapsed); 377 | const remainingText = activeStatus.remaining !== null ? formatTime(activeStatus.remaining) : '?'; 378 | const restoreText = activeStatus.willRestore ? ' → will restore' : ''; 379 | activeStatusHTML = ` 380 |
381 | 🔊 PLAYING 382 | Elapsed: ${elapsedText} | Remaining: ${remainingText}${restoreText} 383 |
384 | `; 385 | } 386 | 387 | return ` 388 |
389 | ${schedule.time} 390 |
391 |
${escapeHtml(schedule.trackName)}
392 |
393 | ${escapeHtml(schedule.artistName)} · Volume: ${schedule.volume}%${playbackInfo} 394 | ${schedule.restorePlayback ? '↩ Restore' : ''} 395 |
396 | ${activeStatusHTML} 397 |
${countdownText}
398 |
399 |
400 | 403 | 406 | 409 |
410 |
411 | `; 412 | }).join(''); 413 | 414 | // Add event listeners 415 | schedulesList.querySelectorAll('.schedule-item').forEach(item => { 416 | const id = item.dataset.id; 417 | 418 | item.querySelector('.toggle-btn').addEventListener('click', () => { 419 | Scheduler.toggleSchedule(id); 420 | renderSchedules(); 421 | }); 422 | 423 | item.querySelector('.test-btn').addEventListener('click', async () => { 424 | await Scheduler.triggerNow(id); 425 | }); 426 | 427 | item.querySelector('.delete-btn').addEventListener('click', () => { 428 | Scheduler.removeSchedule(id); 429 | renderSchedules(); 430 | showToast('Schedule removed'); 431 | }); 432 | }); 433 | 434 | // Start countdown updates and active schedule updates 435 | startCountdownUpdates(); 436 | } 437 | 438 | /** 439 | * Start monitoring current playback 440 | */ 441 | function startPlaybackMonitoring() { 442 | updatePlaybackDisplay(); 443 | playbackInterval = setInterval(updatePlaybackDisplay, 5000); 444 | } 445 | 446 | /** 447 | * Update current playback display 448 | */ 449 | async function updatePlaybackDisplay() { 450 | try { 451 | const state = await SpotifyAPI.getPlaybackState(); 452 | 453 | if (!state || !state.item) { 454 | currentPlayback.innerHTML = '

No active playback

'; 455 | return; 456 | } 457 | 458 | const track = state.item; 459 | const albumArt = track.album.images[1]?.url || track.album.images[0]?.url || ''; 460 | 461 | currentPlayback.innerHTML = ` 462 | Album art 463 |
464 |
${state.is_playing ? '♫ Now Playing' : '⏸ Paused'}
465 |
${escapeHtml(track.name)}
466 |
${escapeHtml(track.artists.map(a => a.name).join(', '))}
467 |
468 | `; 469 | } catch (error) { 470 | console.error('Error updating playback:', error); 471 | } 472 | } 473 | 474 | /** 475 | * Show a toast notification 476 | */ 477 | function showToast(message, isError = false) { 478 | toast.textContent = message; 479 | toast.className = `toast ${isError ? 'error' : ''}`; 480 | toast.classList.remove('hidden'); 481 | 482 | setTimeout(() => { 483 | toast.classList.add('hidden'); 484 | }, 3000); 485 | } 486 | 487 | /** 488 | * Escape HTML to prevent XSS 489 | */ 490 | function escapeHtml(text) { 491 | const div = document.createElement('div'); 492 | div.textContent = text; 493 | return div.innerHTML; 494 | } 495 | 496 | /** 497 | * Format seconds to MM:SS 498 | */ 499 | function formatTime(seconds) { 500 | const mins = Math.floor(seconds / 60); 501 | const secs = seconds % 60; 502 | return `${mins}:${String(secs).padStart(2, '0')}`; 503 | } 504 | 505 | /** 506 | * Suggest next schedule time based on previous pattern 507 | */ 508 | function suggestNextTime(lastTime) { 509 | const schedules = Scheduler.getSchedules(); 510 | 511 | // If we have at least 2 schedules, calculate the time difference 512 | if (schedules.length >= 2) { 513 | // Sort by time 514 | const sorted = schedules.slice().sort((a, b) => a.time.localeCompare(b.time)); 515 | const lastTwo = sorted.slice(-2); 516 | 517 | // Calculate time difference in minutes 518 | const [h1, m1] = lastTwo[0].time.split(':').map(Number); 519 | const [h2, m2] = lastTwo[1].time.split(':').map(Number); 520 | let time1 = h1 * 60 + m1; 521 | let time2 = h2 * 60 + m2; 522 | 523 | // Handle day boundary - if time2 is less than time1, add 24 hours to time2 524 | if (time2 < time1) { 525 | time2 += 24 * 60; 526 | } 527 | 528 | const diff = time2 - time1; 529 | 530 | // Apply the same difference 531 | const [h, m] = lastTime.split(':').map(Number); 532 | const currentMinutes = h * 60 + m; 533 | let nextMinutes = currentMinutes + diff; 534 | 535 | // Handle day overflow - normalize to 0-1439 range 536 | nextMinutes = ((nextMinutes % (24 * 60)) + (24 * 60)) % (24 * 60); 537 | 538 | const nextHours = Math.floor(nextMinutes / 60); 539 | const nextMins = nextMinutes % 60; 540 | 541 | const suggestedTime = `${String(nextHours).padStart(2, '0')}:${String(nextMins).padStart(2, '0')}`; 542 | scheduleTime.value = suggestedTime; 543 | } else { 544 | // No pattern yet, suggest next round time (:00 or :30) 545 | const [h, m] = lastTime.split(':').map(Number); 546 | let nextMinutes; 547 | let nextHours = h; 548 | 549 | if (m < 30) { 550 | nextMinutes = 30; 551 | } else { 552 | nextMinutes = 0; 553 | nextHours = (h + 1) % 24; 554 | } 555 | 556 | const suggestedTime = `${String(nextHours).padStart(2, '0')}:${String(nextMinutes).padStart(2, '0')}`; 557 | scheduleTime.value = suggestedTime; 558 | } 559 | } 560 | 561 | /** 562 | * Get countdown text for a schedule time 563 | */ 564 | function getCountdownText(scheduleTime) { 565 | const now = new Date(); 566 | const [hours, minutes] = scheduleTime.split(':').map(Number); 567 | 568 | const scheduleDate = new Date(); 569 | scheduleDate.setHours(hours, minutes, 0, 0); 570 | 571 | // If the time is in the past today, it's for tomorrow 572 | if (scheduleDate <= now) { 573 | scheduleDate.setDate(scheduleDate.getDate() + 1); 574 | } 575 | 576 | const diffMs = scheduleDate - now; 577 | const diffSeconds = Math.floor(diffMs / 1000); 578 | const diffMinutes = Math.floor(diffSeconds / 60); 579 | const diffHours = Math.floor(diffMinutes / 60); 580 | 581 | if (diffSeconds < 60) { 582 | return `in ${diffSeconds} second${diffSeconds !== 1 ? 's' : ''}`; 583 | } else if (diffMinutes < 60) { 584 | const secs = diffSeconds % 60; 585 | return `in ${diffMinutes}:${String(secs).padStart(2, '0')} minutes`; 586 | } else if (diffHours < 24) { 587 | const mins = diffMinutes % 60; 588 | return `in ${diffHours}:${String(mins).padStart(2, '0')} hours`; 589 | } else { 590 | const days = Math.floor(diffHours / 24); 591 | return `in ${days} day${days !== 1 ? 's' : ''}`; 592 | } 593 | } 594 | 595 | /** 596 | * Start updating countdowns and active schedule status 597 | */ 598 | function startCountdownUpdates() { 599 | if (countdownInterval) { 600 | clearInterval(countdownInterval); 601 | } 602 | 603 | countdownInterval = setInterval(() => { 604 | // Update countdowns 605 | document.querySelectorAll('.countdown').forEach(element => { 606 | const scheduleTime = element.dataset.time; 607 | if (scheduleTime) { 608 | const countdownText = getCountdownText(scheduleTime); 609 | element.textContent = countdownText; 610 | 611 | // Update class based on countdown 612 | if (countdownText === 'Past') { 613 | element.classList.add('past'); 614 | } else { 615 | element.classList.remove('past'); 616 | } 617 | } 618 | }); 619 | 620 | // Update active schedule status 621 | const activeStatus = Scheduler.getActiveScheduleStatus(); 622 | document.querySelectorAll('.active-status').forEach(element => { 623 | const scheduleId = element.dataset.scheduleId; 624 | if (activeStatus && activeStatus.schedule.id === scheduleId) { 625 | const elapsedText = formatTime(activeStatus.elapsed); 626 | const remainingText = activeStatus.remaining !== null ? formatTime(activeStatus.remaining) : '?'; 627 | const restoreText = activeStatus.willRestore ? ' → will restore' : ''; 628 | element.querySelector('.active-time').innerHTML = 629 | `Elapsed: ${elapsedText} | Remaining: ${remainingText}${restoreText}`; 630 | } else { 631 | // Active schedule ended, re-render to remove active status 632 | renderSchedules(); 633 | } 634 | }); 635 | }, 1000); 636 | } 637 | 638 | /** 639 | * Update playback duration display 640 | */ 641 | function updatePlaybackDurationDisplay(seconds) { 642 | const formatted = formatTime(seconds); 643 | const maxSeconds = parseInt(playbackDuration.max); 644 | const isFullTrack = seconds >= maxSeconds; 645 | playbackDurationDisplay.textContent = formatted; 646 | 647 | const hintElement = playbackDurationDisplay.nextElementSibling; 648 | if (hintElement && hintElement.classList.contains('duration-hint')) { 649 | hintElement.textContent = isFullTrack ? '(full track)' : '(partial)'; 650 | } 651 | } 652 | 653 | // Initialize when DOM is ready 654 | document.addEventListener('DOMContentLoaded', init); 655 | 656 | // Public API 657 | return { 658 | showToast, 659 | }; 660 | })(); 661 | --------------------------------------------------------------------------------