├── README.md
├── index.html
├── js
├── spotify-api.js
├── spotify-auth.js
├── scheduler.js
└── app.js
└── css
└── styles.css
/README.md:
--------------------------------------------------------------------------------
1 | # TimeKeepingSpotify
2 |
3 |
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 |
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 |
29 |
30 |
31 |
32 |
![User Avatar]()
33 |
34 |
User
35 |
email@example.com
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
Current Playback
44 |
45 |
No active playback
46 |
47 |
48 |
49 |
50 |
51 |
Schedule Music
52 |
83 |
84 |
85 |
86 |
87 |
Scheduled Music
88 |
89 |
No scheduled items
90 |
91 |
92 |
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 | ''
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 |
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 |
--------------------------------------------------------------------------------