restructure
Hunter
Tuesday, November 11th, 2025 at 12:22 AM
8 # Tracks and playlists
9 tracks/*
10 !tracks/readme.md
-resources/album_art.jpg
11 +tracks/album_art.jpg
12
13 # macOS
14 .DS_Store
68 # File paths (no need to edit these)
69 SCRIPT_DIR = Path(__file__).parent.absolute()
70 TRACKS_JSON = SCRIPT_DIR / "tracks" / "tracks.json"
-STYLES_CSS = SCRIPT_DIR / "styles.css"
71 +STYLES_CSS = SCRIPT_DIR / "resources" / "styles.css"
72
73
74 def get_background_color():
154 "static_files": [
155 "./",
156 "index.html",
- "styles.css",
- "script.js",
157 + "resources/styles.css",
158 + "resources/script.js",
159 "tracks/tracks.json",
160 "resources/icon.png",
161 "resources/play.png",
162 "resources/pause.png",
163 "resources/prev.png",
164 "resources/next.png",
- "resources/album_art.jpg"
165 + "tracks/album_art.jpg"
166 ],
167 "tracks": [f"tracks/{track['filename']}" for track in tracks]
168 }
10 <link rel="apple-touch-icon" href="resources/icon.png">
11 <link rel="manifest" href="manifest.json">
12 <title>vibe capsule</title>
- <link rel="stylesheet" href="styles.css">
13 + <link rel="stylesheet" href="resources/styles.css">
14 </head>
15 <body>
16 <div class="window-container">
38
39 <audio id="audioPlayer"></audio>
40
- <script src="script.js"></script>
41 + <script src="resources/script.js"></script>
42 </body>
43 </html>
41 - `manifest.json` (PWA configuration file)
42 - `resource-manifest.json` (defines the files to be cached for offline use)
43 - `service-worker.js` (manages offline caching)
- - optionally, add an `album_art.jpg` to `/resources`. it'll be used as the cover for your mix when playing on supported devices.
44 + - optionally, add an `album_art.jpg` to `/tracks`. it'll be used as the cover for your mix when playing on supported devices.
45
46 4. **deploy your mixapp**
47 - upload the entire project directory to any web host with HTTPS support (GitHub Pages, AWS S3, etc.)
1 +// Register service worker for PWA functionality
2 +if ('serviceWorker' in navigator) {
3 + window.addEventListener('load', () => {
4 + navigator.serviceWorker.register('service-worker.js')
5 + .then(registration => {
6 + console.log('Service Worker registered successfully:', registration.scope);
7 + })
8 + .catch(error => {
9 + console.log('Service Worker registration failed:', error);
10 + });
11 + });
12 +}
13 +
14 +const playPauseBtn = document.getElementById('playPause');
15 +const prevBtn = document.getElementById('prev');
16 +const nextBtn = document.getElementById('next');
17 +const playlist = document.getElementById('playlist');
18 +const currentSongDisplay = document.getElementById('currentSong');
19 +const progressBar = document.getElementById('progressBar');
20 +const progressContainer = document.getElementById('progressContainer');
21 +const audio = document.getElementById('audioPlayer');
22 +audio.controls = true; // Enable controls for iOS media session
23 +
24 +const shuffle = false;
25 +
26 +function shuffleArray(array) {
27 + const shuffled = [...array];
28 + for (let i = shuffled.length - 1; i > 0; i--) {
29 + const j = Math.floor(Math.random() * (i + 1));
30 + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
31 + }
32 + return shuffled;
33 +}
34 +
35 +let currentSongIndex = 0;
36 +let isPlaying = false;
37 +let progressInterval;
38 +let playerReady = false;
39 +let songs = [];
40 +let animationFrameId = null;
41 +let prePlaySeekTime = 0;
42 +let preloadedAudio = {}; // Cache for preloaded audio elements
43 +let currentPreloadIndex = 0;
44 +let priorityPreloadQueue = []; // Songs requested by user that need priority preloading
45 +let isPreloadingPriority = false;
46 +let totalBytesLoaded = 0; // Track total filesize of all preloaded songs
47 +let cachedTracks = new Set(); // Track which songs are cached for offline use
48 +let CACHE_NAME = null; // Will be loaded from manifest.json
49 +
50 +// Load cache name from manifest.json first, then load tracks
51 +fetch('manifest.json')
52 + .then(response => response.json())
53 + .then(manifest => {
54 + CACHE_NAME = manifest.cache_name || manifest.name;
55 + console.log('Using cache name:', CACHE_NAME);
56 +
57 + // Set page title from manifest
58 + if (manifest.name) {
59 + document.title = manifest.name;
60 + }
61 +
62 + // Now that we have CACHE_NAME, load tracks
63 + return fetch('tracks/tracks.json');
64 + })
65 + .then(response => {
66 + if (!response.ok) {
67 + throw new Error('tracks.json not found');
68 + }
69 + return response.json();
70 + })
71 + .then(data => {
72 + songs = shuffle ? shuffleArray(data) : data;
73 + if (songs.length > 0) {
74 + playerReady = true;
75 + updateCurrentSongDisplay(`Ready to play: ${songs[0].artist} – ${songs[0].title}`);
76 + // Check which tracks are already cached before rendering
77 + return checkCachedTracks().then(() => {
78 + renderPlaylist();
79 + // Pre-cache resources first, then songs
80 + return preloadResources().then(() => {
81 + startPreloadingSongs();
82 + });
83 + });
84 + } else {
85 + updateCurrentSongDisplay('No tracks found');
86 + }
87 + })
88 + .catch(error => {
89 + console.error('Error loading manifest or tracks:', error);
90 + updateCurrentSongDisplay('Unable to load tracks. Please check your connection.');
91 + });
92 +
93 +// Audio event listeners
94 +audio.addEventListener('play', () => {
95 + startProgressBar();
96 + const song = songs[currentSongIndex];
97 + const songText = `${song.artist} – ${song.title}`;
98 +
99 + // Always update display to ensure we remove "Ready to play:" prefix
100 + const currentText = currentSongDisplay.querySelector('span')?.textContent || '';
101 +
102 + // Check if it's a different song (not just play/pause of same song)
103 + const isNewSong = !currentText.includes(songText);
104 + const hasReadyToPlay = currentText.includes('Ready to play:');
105 +
106 + if (isNewSong || hasReadyToPlay) {
107 + updateCurrentSongDisplay(songText);
108 + } else {
109 + // Same song, just resume the marquee
110 + resumeMarquee();
111 + }
112 +
113 + // Update media session metadata
114 + if ('mediaSession' in navigator) {
115 + // Convert relative path to absolute URL for media session
116 + // Use document.baseURI to correctly resolve paths in subdirectories
117 + const albumArtUrl = new URL('tracks/album_art.jpg', document.baseURI).href;
118 + navigator.mediaSession.metadata = new MediaMetadata({
119 + title: song.title,
120 + artist: song.artist,
121 + artwork: [
122 + { src: albumArtUrl, sizes: '860x860', type: 'image/jpeg' },
123 + { src: albumArtUrl, sizes: '512x512', type: 'image/jpeg' },
124 + { src: albumArtUrl, sizes: '256x256', type: 'image/jpeg' },
125 + { src: albumArtUrl, sizes: '128x128', type: 'image/jpeg' }
126 + ]
127 + });
128 +
129 + // Set action handlers after playback starts (required for iOS)
130 + // Explicitly set seek handlers to null so iOS shows next/prev instead
131 + navigator.mediaSession.setActionHandler('play', () => {
132 + if (playerReady && !isPlaying) {
133 + togglePlayPause();
134 + }
135 + });
136 +
137 + navigator.mediaSession.setActionHandler('pause', () => {
138 + if (playerReady && isPlaying) {
139 + togglePlayPause();
140 + }
141 + });
142 +
143 + navigator.mediaSession.setActionHandler('previoustrack', () => {
144 + if (playerReady) {
145 + prevSong();
146 + }
147 + });
148 +
149 + navigator.mediaSession.setActionHandler('nexttrack', () => {
150 + if (playerReady) {
151 + nextSong();
152 + }
153 + });
154 +
155 + // Explicitly set seek handlers to null to show track controls instead
156 + navigator.mediaSession.setActionHandler('seekbackward', null);
157 + navigator.mediaSession.setActionHandler('seekforward', null);
158 + }
159 +});
160 +
161 +audio.addEventListener('pause', () => {
162 + stopProgressBar();
163 + pauseMarquee();
164 +});
165 +
166 +audio.addEventListener('ended', () => {
167 + if (songs[currentSongIndex].looping) {
168 + audio.currentTime = 0;
169 + audio.play();
170 + } else {
171 + nextSong();
172 + }
173 +});
174 +
175 +audio.addEventListener('error', (e) => {
176 + console.error('Audio error:', e);
177 + updateCurrentSongDisplay(`Error loading: ${songs[currentSongIndex].filename}`);
178 + // Try next song after a brief delay
179 + setTimeout(() => nextSong(), 1000);
180 +});
181 +
182 +audio.addEventListener('loadedmetadata', () => {
183 + resetProgressBar();
184 +});
185 +
186 +function renderPlaylist() {
187 + playlist.innerHTML = '';
188 + const currentDisplayText = currentSongDisplay.textContent;
189 + const isInitialized = currentDisplayText !== 'No song playing';
190 +
191 + songs.forEach((song, index) => {
192 + const item = document.createElement('div');
193 + item.classList.add('playlist-item');
194 +
195 + const contentDiv = document.createElement('div');
196 + contentDiv.classList.add('playlist-item-content');
197 +
198 + const titleDiv = document.createElement('div');
199 + titleDiv.classList.add('playlist-item-title');
200 + if (isInitialized && index === currentSongIndex) {
201 + titleDiv.classList.add('current');
202 + }
203 + titleDiv.textContent = song.title;
204 +
205 + const artistDiv = document.createElement('div');
206 + artistDiv.classList.add('playlist-item-artist');
207 + if (isInitialized && index === currentSongIndex) {
208 + artistDiv.classList.add('current');
209 + }
210 + artistDiv.textContent = song.artist;
211 +
212 + // Set cached status for visual indication
213 + const isCached = cachedTracks.has(song.filename);
214 + if (!isCached) {
215 + contentDiv.classList.add('uncached');
216 + }
217 +
218 + contentDiv.appendChild(titleDiv);
219 + contentDiv.appendChild(artistDiv);
220 +
221 + const loopIcon = document.createElement('span');
222 + loopIcon.textContent = '🔁';
223 + loopIcon.style.display = (song.looping || false) ? 'inline' : 'none';
224 +
225 + item.appendChild(contentDiv);
226 + item.appendChild(loopIcon);
227 + item.addEventListener('click', () => toggleLooping(index));
228 + playlist.appendChild(item);
229 + });
230 +}
231 +
232 +function toggleLooping(index) {
233 + if (!playerReady) return;
234 + if (index === currentSongIndex) {
235 + if (!isPlaying && currentSongDisplay.textContent.includes('Ready to play')) {
236 + audio.play().catch(err => {
237 + console.error('Failed to play audio:', err);
238 + });
239 + isPlaying = true;
240 + updatePlayPauseButton();
241 + return;
242 + }
243 + // Toggle looping
244 + songs[index].looping = !(songs[index].looping || false);
245 + renderPlaylist();
246 + } else {
247 + playSong(index);
248 + }
249 +}
250 +
251 +function playSong(index) {
252 + console.log(`playSong called with index: ${index}`);
253 + if (!playerReady) {
254 + console.log('Player not ready');
255 + return;
256 + }
257 + // Clear looping from all songs except the new one if it was already looping
258 + const wasLooping = songs[index].looping || false;
259 + songs.forEach(song => song.looping = false);
260 + if (wasLooping) {
261 + songs[index].looping = true;
262 + }
263 +
264 + currentSongIndex = index;
265 + const song = songs[currentSongIndex];
266 + console.log(`Attempting to play: ${song.artist} – ${song.title}`);
267 + console.log(`Filename: ${song.filename}`);
268 + console.log(`Is in preloadedAudio: ${!!preloadedAudio[song.filename]}`);
269 +
270 + // Use preloaded blob if available, otherwise load from server
271 + if (preloadedAudio[song.filename]) {
272 + const blobUrl = preloadedAudio[song.filename].blobUrl;
273 + console.log(`Playing from preloaded blob: ${song.filename}`);
274 + console.log(`Blob URL: ${blobUrl}`);
275 + audio.src = blobUrl;
276 + } else {
277 + console.log(`Song not preloaded, loading: ${song.filename}`);
278 + audio.src = `tracks/${song.filename}`;
279 + // Request priority preloading for this song
280 + requestPriorityPreload(song.filename);
281 + }
282 +
283 + console.log(`Audio src set to: ${audio.src}`);
284 +
285 + // For iOS PWA: We need to call load() and play() synchronously
286 + // Reset any previous state first
287 + try {
288 + audio.pause();
289 + audio.currentTime = 0;
290 + } catch (e) {
291 + // Ignore errors from resetting
292 + }
293 +
294 + // Load the audio to ensure it's ready (important for iOS PWA)
295 + audio.load();
296 +
297 + // Small delay to let load() initialize, then play
298 + // This needs to be synchronous enough that iOS considers it part of the user gesture
299 + const playAttempt = audio.play();
300 +
301 + if (playAttempt !== undefined) {
302 + playAttempt.then(() => {
303 + console.log('Audio playback started successfully');
304 + isPlaying = true;
305 + updatePlayPauseButton();
306 + }).catch(err => {
307 + console.error('Failed to play audio:', err);
308 + console.error('Error name:', err.name);
309 + console.error('Error message:', err.message);
310 + isPlaying = false;
311 + updatePlayPauseButton();
312 + updateCurrentSongDisplay(`Error playing: ${song.title}`);
313 + });
314 + }
315 +
316 + // Optimistically set playing state
317 + isPlaying = true;
318 + updatePlayPauseButton();
319 + renderPlaylist();
320 +}
321 +
322 +function updateCurrentSongDisplay(text) {
323 + currentSongDisplay.innerHTML = `<span>${text}</span>`;
324 + // Initialize marquee effect after a brief delay to ensure DOM is updated
325 + setTimeout(() => setupMarquee(), 50);
326 +}
327 +
328 +// Marquee state
329 +let marqueeAnimating = false;
330 +let marqueePaused = false;
331 +let marqueeTimeoutId = null;
332 +let marqueeOriginalText = '';
333 +let marqueeDuration = 0;
334 +let marqueeHalfWidth = 0;
335 +let marqueeTransitionEndHandler = null;
336 +let marqueeCurrentTransform = 'translateX(0)';
337 +
338 +function setupMarquee() {
339 + const container = currentSongDisplay;
340 + const textSpan = container.querySelector('span');
341 +
342 + if (!textSpan) return;
343 +
344 + // Clear any existing animation
345 + if (marqueeTimeoutId) {
346 + clearTimeout(marqueeTimeoutId);
347 + marqueeTimeoutId = null;
348 + }
349 + if (marqueeTransitionEndHandler) {
350 + textSpan.removeEventListener('transitionend', marqueeTransitionEndHandler);
351 + marqueeTransitionEndHandler = null;
352 + }
353 + marqueeAnimating = false;
354 + marqueePaused = false;
355 +
356 + // Reset styles
357 + textSpan.style.transition = 'none';
358 + textSpan.style.transform = 'translateX(0)';
359 + marqueeCurrentTransform = 'translateX(0)';
360 + container.classList.remove('no-overflow');
361 +
362 + // Store original text
363 + marqueeOriginalText = textSpan.textContent;
364 +
365 + // Force reflow
366 + void textSpan.offsetWidth;
367 +
368 + // Check if text overflows
369 + const containerWidth = container.offsetWidth - 30; // Account for padding
370 + const textWidth = textSpan.scrollWidth;
371 + const overflows = textWidth > containerWidth;
372 +
373 + if (!overflows) {
374 + // No overflow - show ellipsis behavior
375 + container.classList.add('no-overflow');
376 + container.classList.remove('marquee-active');
377 + return;
378 + }
379 +
380 + // Text overflows - setup marquee
381 + marqueeAnimating = true;
382 + container.classList.add('marquee-active');
383 +
384 + // Add spacing and duplicate text for seamless loop
385 + // Using non-breaking spaces (\u00A0) so they don't collapse in HTML
386 + const spacing = '\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0'; // 12 non-breaking spaces
387 + textSpan.textContent = marqueeOriginalText + spacing + marqueeOriginalText + spacing;
388 +
389 + // Calculate animation parameters
390 + const fullWidth = textSpan.scrollWidth;
391 + marqueeHalfWidth = fullWidth / 2;
392 + marqueeDuration = (marqueeHalfWidth / 50) * 1000; // Adjust speed here (pixels per second)
393 +
394 + // Start animation after a brief delay (only if not paused)
395 + // Don't start animation if already paused
396 + if (!marqueePaused) {
397 + marqueeTimeoutId = setTimeout(() => {
398 + if (marqueePaused) return;
399 +
400 + textSpan.style.transition = `transform ${marqueeDuration}ms linear`;
401 + textSpan.style.transform = `translateX(-${marqueeHalfWidth}px)`;
402 + marqueeCurrentTransform = `translateX(-${marqueeHalfWidth}px)`;
403 +
404 + // Reset and loop
405 + marqueeTransitionEndHandler = () => {
406 + if (!marqueeAnimating || marqueePaused) return;
407 +
408 + textSpan.style.transition = 'none';
409 + textSpan.style.transform = 'translateX(0)';
410 + marqueeCurrentTransform = 'translateX(0)';
411 +
412 + setTimeout(() => {
413 + if (!marqueeAnimating || marqueePaused) return;
414 + textSpan.style.transition = `transform ${marqueeDuration}ms linear`;
415 + textSpan.style.transform = `translateX(-${marqueeHalfWidth}px)`;
416 + marqueeCurrentTransform = `translateX(-${marqueeHalfWidth}px)`;
417 + }, 50);
418 + };
419 +
420 + textSpan.addEventListener('transitionend', marqueeTransitionEndHandler);
421 + }, 1000); // Initial delay before starting scroll
422 + }
423 +}
424 +
425 +function pauseMarquee() {
426 + marqueePaused = true;
427 + const textSpan = currentSongDisplay.querySelector('span');
428 + if (!textSpan || !marqueeAnimating) return;
429 +
430 + // Capture current transform position
431 + const computedStyle = window.getComputedStyle(textSpan);
432 + const currentTransform = computedStyle.transform;
433 + marqueeCurrentTransform = currentTransform;
434 +
435 + // Freeze at current position
436 + textSpan.style.transition = 'none';
437 + textSpan.style.transform = currentTransform;
438 +
439 + // Clear any pending timeout
440 + if (marqueeTimeoutId) {
441 + clearTimeout(marqueeTimeoutId);
442 + marqueeTimeoutId = null;
443 + }
444 +}
445 +
446 +function resumeMarquee() {
447 + if (!marqueeAnimating) return;
448 + marqueePaused = false;
449 +
450 + const textSpan = currentSongDisplay.querySelector('span');
451 + if (!textSpan) return;
452 +
453 + // Apply the stored transform position first
454 + textSpan.style.transition = 'none';
455 + textSpan.style.transform = marqueeCurrentTransform;
456 +
457 + // Force reflow to apply the transform
458 + void textSpan.offsetWidth;
459 +
460 + // Get current position from the stored transform
461 + const matrix = new DOMMatrix(marqueeCurrentTransform);
462 + const currentX = matrix.m41;
463 +
464 + // Calculate remaining distance and time
465 + const distanceTraveled = Math.abs(currentX);
466 + const percentComplete = distanceTraveled / marqueeHalfWidth;
467 + const timeRemaining = marqueeDuration * (1 - percentComplete);
468 +
469 + // Resume animation from current position
470 + textSpan.style.transition = `transform ${timeRemaining}ms linear`;
471 + textSpan.style.transform = `translateX(-${marqueeHalfWidth}px)`;
472 + marqueeCurrentTransform = `translateX(-${marqueeHalfWidth}px)`;
473 +}
474 +
475 +// Add window resize listener to recalculate marquee
476 +window.addEventListener('resize', () => {
477 + if (marqueeOriginalText) {
478 + // Store the paused state before recalculating
479 + const wasPaused = marqueePaused;
480 +
481 + // Restore original text before recalculating
482 + const textSpan = currentSongDisplay.querySelector('span');
483 + if (textSpan) {
484 + textSpan.textContent = marqueeOriginalText;
485 + }
486 +
487 + setupMarquee();
488 +
489 + // Restore paused state after recalculation
490 + if (wasPaused) {
491 + marqueePaused = true;
492 + }
493 + }
494 +});
495 +
496 +function togglePlayPause() {
497 + if (!playerReady) return;
498 + if (isPlaying) {
499 + audio.pause();
500 + isPlaying = false;
501 + } else {
502 + // If no song is loaded, load the first one
503 + if (!audio.src || audio.src === '') {
504 + playSong(currentSongIndex);
505 + } else {
506 + audio.play().catch(err => {
507 + console.error('Failed to play audio:', err);
508 + const song = songs[currentSongIndex];
509 + updateCurrentSongDisplay(`Error playing: ${song.title}`);
510 + });
511 + isPlaying = true;
512 + }
513 + }
514 + updatePlayPauseButton();
515 +}
516 +
517 +function updatePlayPauseButton() {
518 + playPauseBtn.classList.toggle('pause', isPlaying);
519 +}
520 +
521 +function nextSong() {
522 + if (!playerReady) return;
523 + currentSongIndex = (currentSongIndex + 1) % songs.length;
524 + playSong(currentSongIndex);
525 +}
526 +
527 +function prevSong() {
528 + if (!playerReady) return;
529 + if (audio.currentTime <= 3) {
530 + currentSongIndex = (currentSongIndex - 1 + songs.length) % songs.length;
531 + playSong(currentSongIndex);
532 + } else {
533 + audio.currentTime = 0;
534 + }
535 +}
536 +
537 +function startProgressBar() {
538 + stopProgressBar();
539 +
540 + function animate() {
541 + updateProgressBar();
542 + animationFrameId = requestAnimationFrame(animate);
543 + }
544 +
545 + animate();
546 +
547 + // iOS PWA background playback fix: use setInterval as backup
548 + // setInterval is less throttled than requestAnimationFrame in background
549 + backgroundPlaybackCheckInterval = setInterval(() => {
550 + if (!audio.paused && audio.duration && audio.currentTime >= audio.duration - 0.5) {
551 + console.log('Background check: song ended, triggering next');
552 + clearInterval(backgroundPlaybackCheckInterval);
553 + backgroundPlaybackCheckInterval = null;
554 +
555 + if (songs[currentSongIndex].looping) {
556 + audio.currentTime = 0;
557 + audio.play();
558 + } else {
559 + nextSong();
560 + }
561 + }
562 +
563 + // Update Media Session position state for iOS
564 + if ('mediaSession' in navigator && 'setPositionState' in navigator.mediaSession) {
565 + try {
566 + if (audio.duration && !isNaN(audio.duration) && isFinite(audio.duration)) {
567 + navigator.mediaSession.setPositionState({
568 + duration: audio.duration,
569 + playbackRate: audio.playbackRate,
570 + position: audio.currentTime
571 + });
572 + }
573 + } catch (e) {
574 + // Ignore errors from setPositionState
575 + }
576 + }
577 + }, 500); // Check every 500ms
578 +}
579 +
580 +function stopProgressBar() {
581 + if (animationFrameId !== null) {
582 + cancelAnimationFrame(animationFrameId);
583 + animationFrameId = null;
584 + }
585 + if (backgroundPlaybackCheckInterval !== null) {
586 + clearInterval(backgroundPlaybackCheckInterval);
587 + backgroundPlaybackCheckInterval = null;
588 + }
589 +}
590 +
591 +function resetProgressBar() {
592 + progressBar.style.setProperty('--progress', '0');
593 +}
594 +
595 +function updateProgressBar() {
596 + if (audio.duration && !isDragging && !isSeeking) {
597 + const currentTime = audio.currentTime;
598 + const duration = audio.duration;
599 + const progressPercentage = (currentTime / duration) * 100;
600 + const displayPercentage = isNaN(progressPercentage) ? 0 : progressPercentage;
601 + progressBar.style.setProperty('--progress', displayPercentage);
602 + }
603 +}
604 +
605 +let isDragging = false;
606 +let wasPlayingBeforeDrag = false;
607 +let pendingSeekPercentage = null;
608 +let backgroundPlaybackCheckInterval = null;
609 +
610 +function updateVisualProgress(event) {
611 + if (!playerReady) return;
612 +
613 + const rect = progressBar.getBoundingClientRect();
614 + // Support both mouse and touch events
615 + const clientX = event.touches ? event.touches[0].clientX : event.clientX;
616 + const clickPosition = clientX - rect.left;
617 + const clickPercentage = Math.max(0, Math.min(1, clickPosition / rect.width));
618 +
619 + progressBar.style.setProperty('--progress', clickPercentage * 100);
620 + return clickPercentage;
621 +}
622 +
623 +let isSeeking = false;
624 +let targetSeekTime = null;
625 +
626 +function applySeek(clickPercentage) {
627 + if (!playerReady) return;
628 +
629 + // If audio hasn't been loaded yet, load it but don't play
630 + if (!audio.src || audio.src === '') {
631 + const song = songs[currentSongIndex];
632 +
633 + // Use preloaded blob if available, otherwise load from server
634 + if (preloadedAudio[song.filename]) {
635 + audio.src = preloadedAudio[song.filename].blobUrl;
636 + } else {
637 + audio.src = `tracks/${song.filename}`;
638 + }
639 +
640 + // Wait for metadata to be loaded before seeking
641 + audio.addEventListener('loadedmetadata', function setInitialTime() {
642 + const duration = audio.duration;
643 + const seekTime = duration * clickPercentage;
644 + attemptSeekWithRetry(seekTime, clickPercentage);
645 + prePlaySeekTime = seekTime;
646 + audio.removeEventListener('loadedmetadata', setInitialTime);
647 + }, { once: true });
648 + } else if (audio.duration) {
649 + const duration = audio.duration;
650 + const seekTime = duration * clickPercentage;
651 + attemptSeekWithRetry(seekTime, clickPercentage);
652 + prePlaySeekTime = seekTime;
653 + }
654 +}
655 +
656 +function isTimeBuffered(time) {
657 + // Check if the given time is within any buffered time range
658 + for (let i = 0; i < audio.buffered.length; i++) {
659 + if (time >= audio.buffered.start(i) && time <= audio.buffered.end(i)) {
660 + return true;
661 + }
662 + }
663 + return false;
664 +}
665 +
666 +function attemptSeekWithRetry(seekTime, targetPercentage) {
667 + targetSeekTime = seekTime;
668 + isSeeking = true;
669 +
670 + // Lock the progress bar at the target position
671 + progressBar.style.setProperty('--progress', targetPercentage * 100);
672 +
673 + const wasPlaying = !audio.paused;
674 +
675 + // Try to seek
676 + audio.currentTime = seekTime;
677 +
678 + // Handler to check if we reached the target after seeking completes
679 + function checkSeekSuccess() {
680 + // Allow small tolerance for floating point comparison
681 + if (Math.abs(audio.currentTime - targetSeekTime) > 0.5) {
682 + // Browser clamped to buffered range - need to wait for more data
683 + // Now pause during seeking
684 + if (wasPlaying) {
685 + audio.pause();
686 + }
687 + continueSeekingToTarget(wasPlaying);
688 + } else {
689 + // Successfully reached target immediately (was already buffered)
690 + isSeeking = false;
691 + targetSeekTime = null;
692 + // No need to update display - the seek was instant and playback continues normally
693 + }
694 + }
695 +
696 + audio.addEventListener('seeked', checkSeekSuccess, { once: true });
697 +}
698 +
699 +function continueSeekingToTarget(wasPlaying) {
700 + // Handler for when more data loads
701 + function retrySeek() {
702 + if (!isSeeking || targetSeekTime === null) {
703 + return; // Seeking was cancelled
704 + }
705 +
706 + audio.currentTime = targetSeekTime;
707 +
708 + // Check again after this seek completes
709 + function checkAgain() {
710 + if (!isSeeking || targetSeekTime === null) {
711 + return;
712 + }
713 +
714 + if (Math.abs(audio.currentTime - targetSeekTime) > 0.5) {
715 + // Still not there, keep trying
716 + continueSeekingToTarget(wasPlaying);
717 + } else {
718 + // Success!
719 + isSeeking = false;
720 + targetSeekTime = null;
721 +
722 + if (wasPlaying) {
723 + audio.play();
724 + }
725 + }
726 + }
727 +
728 + audio.addEventListener('seeked', checkAgain, { once: true });
729 + }
730 +
731 + // Wait for more data to load, then try again
732 + audio.addEventListener('progress', retrySeek, { once: true });
733 +
734 + // Also set a timeout fallback in case progress doesn't fire
735 + setTimeout(() => {
736 + if (isSeeking && targetSeekTime !== null && Math.abs(audio.currentTime - targetSeekTime) > 0.5) {
737 + retrySeek();
738 + }
739 + }, 1000);
740 +}
741 +
742 +function onProgressMouseDown(event) {
743 + if (!playerReady) return;
744 + isDragging = true;
745 + wasPlayingBeforeDrag = isPlaying;
746 + progressContainer.style.cursor = 'grabbing';
747 + document.body.style.cursor = 'grabbing';
748 + pendingSeekPercentage = updateVisualProgress(event);
749 + event.preventDefault();
750 +}
751 +
752 +function onProgressMouseMove(event) {
753 + if (isDragging) {
754 + pendingSeekPercentage = updateVisualProgress(event);
755 + }
756 +}
757 +
758 +function onProgressMouseUp(event) {
759 + if (isDragging) {
760 + isDragging = false;
761 + progressContainer.style.cursor = '';
762 + document.body.style.cursor = '';
763 +
764 + // Apply the seek now that drag is complete
765 + if (pendingSeekPercentage !== null) {
766 + applySeek(pendingSeekPercentage);
767 + pendingSeekPercentage = null;
768 + }
769 +
770 + // If it was "Ready to play" (not playing before), start playing now
771 + if (!wasPlayingBeforeDrag && !isPlaying && audio.src) {
772 + audio.play();
773 + isPlaying = true;
774 + updatePlayPauseButton();
775 + }
776 + }
777 +}
778 +
779 +playPauseBtn.addEventListener('click', togglePlayPause);
780 +nextBtn.addEventListener('click', nextSong);
781 +prevBtn.addEventListener('click', prevSong);
782 +
783 +// Mouse events for desktop
784 +progressContainer.addEventListener('mousedown', onProgressMouseDown);
785 +document.addEventListener('mousemove', onProgressMouseMove);
786 +document.addEventListener('mouseup', onProgressMouseUp);
787 +
788 +// Touch events for mobile
789 +progressContainer.addEventListener('touchstart', onProgressMouseDown, { passive: false });
790 +document.addEventListener('touchmove', onProgressMouseMove, { passive: false });
791 +document.addEventListener('touchend', onProgressMouseUp);
792 +
793 +// Keyboard controls
794 +document.addEventListener('keydown', function(event) {
795 + if (!playerReady) return;
796 +
797 + // Spacebar: play/pause
798 + if (event.code === 'Space') {
799 + event.preventDefault();
800 + togglePlayPause();
801 + }
802 +});
803 +
804 +// Pre-caching system
805 +function preloadResources() {
806 + console.log('Preloading UI resources...');
807 +
808 + const resources = [
809 + 'resources/play.png',
810 + 'resources/pause.png',
811 + 'resources/prev.png',
812 + 'resources/next.png',
813 + 'tracks/album_art.jpg'
814 + ];
815 +
816 + const imagePromises = resources.map(src => {
817 + return new Promise((resolve, reject) => {
818 + const img = new Image();
819 + img.onload = () => {
820 + console.log(`Preloaded: ${src}`);
821 + resolve();
822 + };
823 + img.onerror = () => {
824 + console.error(`Failed to preload: ${src}`);
825 + resolve(); // Resolve anyway to not block other resources
826 + };
827 + img.src = src;
828 + });
829 + });
830 +
831 + return Promise.all(imagePromises).then(() => {
832 + console.log('All UI resources preloaded');
833 + });
834 +}
835 +
836 +function startPreloadingSongs() {
837 + // Start with the first song
838 + currentPreloadIndex = 0;
839 + preloadNextSong();
840 +}
841 +
842 +function preloadNextSong() {
843 + if (currentPreloadIndex >= songs.length) {
844 + const totalMB = (totalBytesLoaded / 1024 / 1024).toFixed(2);
845 + console.log(`All songs preloaded - Total size: ${totalMB} MB (${totalBytesLoaded} bytes)`);
846 + return;
847 + }
848 +
849 + const song = songs[currentPreloadIndex];
850 + const filename = song.filename;
851 +
852 + // Skip if already preloaded in memory
853 + if (preloadedAudio[filename]) {
854 + currentPreloadIndex++;
855 + preloadNextSong();
856 + return;
857 + }
858 +
859 + // If already cached, load from cache into memory
860 + if (cachedTracks.has(filename)) {
861 + console.log(`Loading from cache: ${song.artist} – ${song.title}`);
862 + loadFromCache(filename).then(() => {
863 + currentPreloadIndex++;
864 + setTimeout(() => preloadNextSong(), 100);
865 + }).catch(err => {
866 + console.error(`Failed to load from cache, fetching instead:`, err);
867 + // If cache load fails, fetch from network
868 + fetchAndPreloadSong(song, filename);
869 + });
870 + return;
871 + }
872 +
873 + console.log(`Preloading: ${song.artist} – ${song.title}`);
874 + fetchAndPreloadSong(song, filename);
875 +}
876 +
877 +function fetchAndPreloadSong(song, filename) {
878 + // Use fetch to force full download of the entire file
879 + fetch(`tracks/${filename}`)
880 + .then(response => {
881 + if (!response.ok) {
882 + throw new Error(`HTTP error! status: ${response.status}`);
883 + }
884 + // Get total file size for progress tracking
885 + const contentLength = response.headers.get('content-length');
886 + console.log(`Downloading ${song.title} (${(contentLength / 1024 / 1024).toFixed(2)} MB)...`);
887 +
888 + // Read the entire response as a blob
889 + return response.blob();
890 + })
891 + .then(blob => {
892 + // Add blob size to total
893 + totalBytesLoaded += blob.size;
894 +
895 + // Create a blob URL that will persist in memory
896 + const blobUrl = URL.createObjectURL(blob);
897 +
898 + preloadedAudio[filename] = {
899 + blobUrl: blobUrl,
900 + blob: blob
901 + };
902 +
903 + console.log(`✓ Fully preloaded: ${song.artist} – ${song.title}`);
904 +
905 + // Store in Cache API for offline access
906 + return storeBlobInCache(filename, blob).then(() => {
907 + // Mark as cached and update UI
908 + cachedTracks.add(filename);
909 + updateTrackCachedStatus(filename);
910 +
911 + // Move to next song
912 + currentPreloadIndex++;
913 + setTimeout(() => preloadNextSong(), 100);
914 + });
915 + })
916 + .catch(error => {
917 + console.error(`Failed to preload ${filename}:`, error);
918 + currentPreloadIndex++;
919 + preloadNextSong();
920 + });
921 +}
922 +
923 +// Check which tracks are already cached on app load
924 +async function checkCachedTracks() {
925 + try {
926 + const cache = await caches.open(CACHE_NAME);
927 + const cachedRequests = await cache.keys();
928 +
929 + // Check each song to see if it's cached
930 + for (const song of songs) {
931 + // Build the same absolute URL that storeBlobInCache uses for consistency
932 + const absoluteUrl = new URL(`tracks/${song.filename}`, window.location.href).href;
933 + const isInCache = cachedRequests.some(request => request.url === absoluteUrl);
934 + if (isInCache) {
935 + cachedTracks.add(song.filename);
936 + }
937 + }
938 +
939 + console.log(`Found ${cachedTracks.size}/${songs.length} tracks already cached`);
940 + console.log('Cached tracks:', Array.from(cachedTracks));
941 + } catch (error) {
942 + console.error('Failed to check cached tracks:', error);
943 + }
944 +}
945 +
946 +// Debug function to check preloaded state
947 +window.debugAudioState = function() {
948 + console.log('=== Audio State Debug ===');
949 + console.log('Player ready:', playerReady);
950 + console.log('Is playing:', isPlaying);
951 + console.log('Current song index:', currentSongIndex);
952 + console.log('Total songs:', songs.length);
953 + console.log('Cached tracks count:', cachedTracks.size);
954 + console.log('Preloaded audio count:', Object.keys(preloadedAudio).length);
955 + console.log('Current audio src:', audio.src);
956 + console.log('Audio paused:', audio.paused);
957 + console.log('Audio error:', audio.error);
958 + if (songs[currentSongIndex]) {
959 + console.log('Current song:', songs[currentSongIndex].filename);
960 + console.log('Is preloaded:', !!preloadedAudio[songs[currentSongIndex].filename]);
961 + console.log('Is cached:', cachedTracks.has(songs[currentSongIndex].filename));
962 + }
963 + console.log('======================');
964 +};
965 +
966 +// Load a track from cache into memory
967 +async function loadFromCache(filename) {
968 + try {
969 + const cache = await caches.open(CACHE_NAME);
970 + // Try both relative and absolute URLs
971 + let response = await cache.match(`tracks/${filename}`);
972 + if (!response) {
973 + // Try with absolute URL
974 + const absoluteUrl = new URL(`tracks/${filename}`, window.location.href).href;
975 + response = await cache.match(absoluteUrl);
976 + }
977 +
978 + if (!response) {
979 + throw new Error('Not in cache');
980 + }
981 +
982 + const blob = await response.blob();
983 +
984 + // Add blob size to total
985 + totalBytesLoaded += blob.size;
986 +
987 + // Create a blob URL that will persist in memory
988 + const blobUrl = URL.createObjectURL(blob);
989 +
990 + preloadedAudio[filename] = {
991 + blobUrl: blobUrl,
992 + blob: blob
993 + };
994 + console.log(`✓ Loaded from cache: ${filename}`);
995 + } catch (error) {
996 + console.error(`Failed to load from cache: ${filename}`, error);
997 + throw error;
998 + }
999 +}
1000 +
1001 +// Store blob in Cache API for offline access
1002 +async function storeBlobInCache(filename, blob) {
1003 + try {
1004 + const cache = await caches.open(CACHE_NAME);
1005 + const response = new Response(blob, {
1006 + headers: {
1007 + 'Content-Type': 'audio/mpeg',
1008 + 'Content-Length': blob.size
1009 + }
1010 + });
1011 + // Use absolute URL for consistency
1012 + const absoluteUrl = new URL(`tracks/${filename}`, window.location.href).href;
1013 + await cache.put(absoluteUrl, response);
1014 + console.log(`✓ Cached for offline: ${filename}`);
1015 + } catch (error) {
1016 + console.error(`Failed to cache ${filename}:`, error);
1017 + }
1018 +}
1019 +
1020 +// Update UI to show track is cached
1021 +function updateTrackCachedStatus(filename) {
1022 + const songIndex = songs.findIndex(s => s.filename === filename);
1023 + if (songIndex === -1) return;
1024 +
1025 + // Find the playlist item and remove uncached class
1026 + const playlistItems = playlist.querySelectorAll('.playlist-item');
1027 + if (playlistItems[songIndex]) {
1028 + const contentDiv = playlistItems[songIndex].querySelector('.playlist-item-content');
1029 + if (contentDiv) {
1030 + contentDiv.classList.remove('uncached');
1031 + }
1032 + }
1033 +}
1034 +
1035 +// Priority preloading system
1036 +function requestPriorityPreload(filename) {
1037 + // Skip if already preloaded or already in priority queue
1038 + if (preloadedAudio[filename] || priorityPreloadQueue.includes(filename)) {
1039 + return;
1040 + }
1041 +
1042 + console.log(`🔥 Priority preload requested: ${filename}`);
1043 + priorityPreloadQueue.push(filename);
1044 +
1045 + // Start priority preloading if not already running
1046 + if (!isPreloadingPriority) {
1047 + processPriorityPreload();
1048 + }
1049 +}
1050 +
1051 +function processPriorityPreload() {
1052 + if (priorityPreloadQueue.length === 0) {
1053 + isPreloadingPriority = false;
1054 + return;
1055 + }
1056 +
1057 + isPreloadingPriority = true;
1058 + const filename = priorityPreloadQueue.shift();
1059 +
1060 + // Check if already preloaded (might have finished during normal preloading)
1061 + if (preloadedAudio[filename]) {
1062 + processPriorityPreload();
1063 + return;
1064 + }
1065 +
1066 + // Find the song info
1067 + const song = songs.find(s => s.filename === filename);
1068 + if (!song) {
1069 + processPriorityPreload();
1070 + return;
1071 + }
1072 +
1073 + // If already cached, load from cache
1074 + if (cachedTracks.has(filename)) {
1075 + console.log(`🔥 Priority loading from cache: ${song.artist} – ${song.title}`);
1076 + loadFromCache(filename).then(() => {
1077 + processPriorityPreload();
1078 + }).catch(err => {
1079 + console.error(`Failed to load from cache, fetching instead:`, err);
1080 + priorityFetchAndPreloadSong(song, filename);
1081 + });
1082 + return;
1083 + }
1084 +
1085 + console.log(`🔥 Priority preloading: ${song.artist} – ${song.title}`);
1086 + priorityFetchAndPreloadSong(song, filename);
1087 +}
1088 +
1089 +function priorityFetchAndPreloadSong(song, filename) {
1090 + // Use fetch to force full download of the entire file
1091 + fetch(`tracks/${filename}`)
1092 + .then(response => {
1093 + if (!response.ok) {
1094 + throw new Error(`HTTP error! status: ${response.status}`);
1095 + }
1096 + return response.blob();
1097 + })
1098 + .then(blob => {
1099 + // Create a blob URL that will persist in memory
1100 + const blobUrl = URL.createObjectURL(blob);
1101 +
1102 + preloadedAudio[filename] = {
1103 + blobUrl: blobUrl,
1104 + blob: blob
1105 + };
1106 +
1107 + // Next time this song plays, it will use the cached version
1108 + console.log(`✓ Priority preloaded: ${song.artist} – ${song.title}`);
1109 +
1110 + // Store in Cache API for offline access
1111 + return storeBlobInCache(filename, blob).then(() => {
1112 + // Mark as cached and update UI
1113 + cachedTracks.add(filename);
1114 + updateTrackCachedStatus(filename);
1115 +
1116 + // Process next priority request
1117 + processPriorityPreload();
1118 + });
1119 + })
1120 + .catch(error => {
1121 + // Ignore abort errors (happens when normal preload finishes first)
1122 + if (error.name === 'AbortError' || error.message.includes('aborted')) {
1123 + // This is expected - normal preloading probably finished first
1124 + } else {
1125 + console.error(`Failed to priority preload ${filename}:`, error);
1126 + }
1127 + processPriorityPreload();
1128 + });
1129 +}
1 +:root {
2 + --text: #dddddd;
3 + --dimmed-text: #ddddddcc;
4 + --primary: #4c7ae6;
5 + --accent: #f421ff;
6 + --background: #080a0c;
7 +}
8 +
9 +* {
10 + scrollbar-color: var(--dimmed-text) var(--background);
11 + user-select: none;
12 + -webkit-user-select: none;
13 + -webkit-touch-callout: none;
14 + -webkit-tap-highlight-color: transparent;
15 +}
16 +
17 +html {
18 + scroll-behavior: smooth;
19 + overscroll-behavior: none;
20 + -ms-overflow-style: none;
21 + scrollbar-width: none;
22 +}
23 +
24 +html::-webkit-scrollbar {
25 + display: none;
26 +}
27 +
28 +.playlist-wrapper::-webkit-scrollbar {
29 + display: none;
30 +}
31 +
32 +body {
33 + margin: 0;
34 + padding: 0;
35 + font-family: "MS Sans Serif", Arial, sans-serif;
36 + height: 100dvh;
37 + display: flex;
38 + flex-direction: column;
39 + font-size: 25px;
40 + background-color: var(--background);
41 + position: fixed;
42 + width: 100%;
43 +}
44 +
45 +.window-container {
46 + background: transparent;
47 + height: 100%;
48 + display: flex;
49 + flex-direction: column;
50 +}
51 +
52 +.player-container {
53 + flex-grow: 1;
54 + display: flex;
55 + flex-direction: column;
56 + overflow: hidden;
57 + position: relative;
58 + padding-top: 10px;
59 +}
60 +
61 +.controls {
62 + display: flex;
63 + justify-content: center;
64 + background: transparent;
65 + position: absolute;
66 + bottom: 0;
67 + left: 0;
68 + right: 0;
69 + box-sizing: border-box;
70 + z-index: 3;
71 + pointer-events: none;
72 +}
73 +
74 +.control-button {
75 + width: clamp(60px, calc((100vw - 58px) / 3), 120px);
76 + aspect-ratio: 8 / 3;
77 + background-size: contain;
78 + background-repeat: no-repeat;
79 + background-position: center;
80 + border: none;
81 + cursor: pointer;
82 + margin: 0;
83 + border-radius: 50%;
84 + background-color: var(--text);
85 + box-shadow:
86 + inset -2px -2px 4px rgba(0, 0, 0, 0.3),
87 + inset 2px 2px 4px rgba(255, 255, 255, 0.4),
88 + 0 2px 4px rgba(0, 0, 0, 0.5),
89 + 0 4px 8px rgba(0, 0, 0, 0.3);
90 + filter:
91 + drop-shadow(0 0 1px rgba(0, 0, 0, 0.8))
92 + drop-shadow(1px 1px 0 rgba(0, 0, 0, 0.6))
93 + drop-shadow(-0.5px -0.5px 0 rgba(255, 255, 255, 0.2));
94 + pointer-events: auto;
95 +}
96 +
97 +.controls-inner {
98 + display: flex;
99 + justify-content: space-between;
100 + padding: 10px 15px;
101 + pointer-events: auto;
102 + width: 100%;
103 + box-sizing: border-box;
104 +}
105 +
106 +@media (min-width: 420px) {
107 + .controls-inner {
108 + justify-content: center;
109 + gap: 14px;
110 + width: auto;
111 + }
112 +
113 + .control-button {
114 + width: 120px;
115 + }
116 +}
117 +
118 +.playlist-wrapper {
119 + flex-grow: 1;
120 + position: relative;
121 + overflow-y: auto;
122 + padding-bottom: 70px;
123 + overscroll-behavior: none;
124 + margin-top: -1px;
125 +}
126 +
127 +.playlist {
128 + padding-top: 0;
129 + cursor: initial;
130 +}
131 +
132 +.playlist-item {
133 + display: flex;
134 + justify-content: space-between;
135 + align-items: center;
136 + color: var(--text);
137 + min-height: 45px;
138 + padding-left: 15px;
139 + padding-right: 10px;
140 + padding-bottom: 18px;
141 +}
142 +
143 +.playlist-item-content {
144 + flex-grow: 1;
145 + display: flex;
146 + flex-direction: column;
147 + gap: 2px;
148 + transition: opacity 0.3s ease;
149 +}
150 +
151 +.playlist-item-content.uncached {
152 + opacity: 0.4;
153 +}
154 +
155 +.playlist-item-title {
156 + font-size: 21px;
157 + line-height: 1.2;
158 +}
159 +
160 +.playlist-item-title.current {
161 + font-weight: bold;
162 + color: var(--primary);
163 +}
164 +
165 +.playlist-item-artist {
166 + font-size: 17px;
167 + color: var(--dimmed-text);
168 + line-height: 1.2;
169 +}
170 +
171 +.playlist-item-artist.current {
172 + font-weight: bold;
173 + color: var(--primary);
174 + opacity: 1;
175 +}
176 +
177 +@media (hover: hover) and (pointer: fine) {
178 + .playlist-item:hover {
179 + cursor: pointer;
180 + color: var(--accent);
181 + }
182 +
183 + .playlist-item:hover .playlist-item-artist {
184 + color: var(--accent);
185 + }
186 +
187 + .playlist-item:hover .playlist-item-title.current,
188 + .playlist-item:hover .playlist-item-artist.current {
189 + color: var(--accent);
190 + }
191 +}
192 +
193 +.current-song {
194 + text-align: left;
195 + padding-left: 15px;
196 + padding-bottom: 10px;
197 + font-weight: bold;
198 + color: var(--primary);
199 + min-height: 35px;
200 + display: flex;
201 + align-items: center;
202 + background-color: var(--background);
203 + z-index: 2;
204 + position: relative;
205 +}
206 +
207 +.current-song span {
208 + display: inline-block;
209 + white-space: nowrap;
210 + will-change: transform;
211 +}
212 +
213 +.current-song.no-overflow span {
214 + overflow: hidden;
215 + text-overflow: hidden;
216 + max-width: 100%;
217 +}
218 +
219 +#audioPlayer {
220 + display: none;
221 +}
222 +
223 +.progress-container {
224 + width: 100%;
225 + font-size: 24px;
226 + cursor: grab;
227 + margin-top: 0;
228 + height: 35px;
229 + display: flex;
230 + align-items: center;
231 + padding: 0 15px;
232 + padding-bottom: 7px;
233 + box-sizing: border-box;
234 + position: relative;
235 + background-color: var(--background);
236 + z-index: 2;
237 +}
238 +
239 +.progress-container::before {
240 + content: '';
241 + position: absolute;
242 + left: 15px;
243 + right: 15px;
244 + top: 50%;
245 + transform: translateY(-50%);
246 + margin-top: -3.5px;
247 + height: 24px;
248 + pointer-events: auto;
249 +}
250 +
251 +.shadow-overlay {
252 + position: sticky;
253 + top: -1px;
254 + left: 0;
255 + right: 0;
256 + height: 14px;
257 + background: linear-gradient(
258 + to bottom,
259 + var(--background) 0%,
260 + rgba(8, 10, 12, 0.5) 40%,
261 + rgba(8, 10, 12, 0.2) 70%,
262 + transparent 100%
263 + );
264 + pointer-events: none;
265 + z-index: 2;
266 +}
267 +
268 +.progress-container:hover {
269 + cursor: grab;
270 +}
271 +
272 +.progress-bar {
273 + width: 100%;
274 + height: 4px;
275 + background-color: var(--text);
276 + position: relative;
277 + border-radius: 1px;
278 + --progress: 0;
279 + --circle-size: 16px;
280 +}
281 +
282 +.progress-bar::before {
283 + content: '';
284 + position: absolute;
285 + left: 0;
286 + top: 0;
287 + height: 100%;
288 + width: calc((100% - var(--circle-size) + 2px) * var(--progress) / 100 + var(--circle-size) / 2);
289 + background-color: var(--accent);
290 + border-radius: 1px;
291 + transition: none;
292 +}
293 +
294 +.progress-bar::after {
295 + content: '';
296 + position: absolute;
297 + left: calc((100% - var(--circle-size) + 2px) * var(--progress) / 100 - 1px);
298 + top: 50%;
299 + transform: translateY(-50%);
300 + width: var(--circle-size);
301 + height: var(--circle-size);
302 + background-color: var(--accent);
303 + border-radius: 50%;
304 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
305 + transition: none;
306 +}
307 +
308 +.progress-text {
309 + display: none;
310 +}
311 +
312 +.progress-text-left {
313 + display: none;
314 +}
315 +
316 +.progress-text-center {
317 + display: none;
318 +}
319 +
320 +#playPause {
321 + background-image: url('play.png');
322 + background-position: 52.5% center;
323 +}
324 +
325 +#playPause.pause {
326 + background-image: url('pause.png');
327 + background-position: center;
328 +}
329 +
330 +#prev {
331 + background-image: url('prev.png');
332 + background-position: 45% center;
333 +}
334 +
335 +#next {
336 + background-image: url('next.png');
337 + background-position: 55% center;
338 +}
-// Register service worker for PWA functionality
-if ('serviceWorker' in navigator) {
- window.addEventListener('load', () => {
- navigator.serviceWorker.register('service-worker.js')
- .then(registration => {
- console.log('Service Worker registered successfully:', registration.scope);
- })
- .catch(error => {
- console.log('Service Worker registration failed:', error);
- });
- });
-}
-
-const playPauseBtn = document.getElementById('playPause');
-const prevBtn = document.getElementById('prev');
-const nextBtn = document.getElementById('next');
-const playlist = document.getElementById('playlist');
-const currentSongDisplay = document.getElementById('currentSong');
-const progressBar = document.getElementById('progressBar');
-const progressContainer = document.getElementById('progressContainer');
-const audio = document.getElementById('audioPlayer');
-audio.controls = true; // Enable controls for iOS media session
-
-const shuffle = false;
-
-function shuffleArray(array) {
- const shuffled = [...array];
- for (let i = shuffled.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
- }
- return shuffled;
-}
-
-let currentSongIndex = 0;
-let isPlaying = false;
-let progressInterval;
-let playerReady = false;
-let songs = [];
-let animationFrameId = null;
-let prePlaySeekTime = 0;
-let preloadedAudio = {}; // Cache for preloaded audio elements
-let currentPreloadIndex = 0;
-let priorityPreloadQueue = []; // Songs requested by user that need priority preloading
-let isPreloadingPriority = false;
-let totalBytesLoaded = 0; // Track total filesize of all preloaded songs
-let cachedTracks = new Set(); // Track which songs are cached for offline use
-let CACHE_NAME = null; // Will be loaded from manifest.json
-
-// Load cache name from manifest.json first, then load tracks
-fetch('manifest.json')
- .then(response => response.json())
- .then(manifest => {
- CACHE_NAME = manifest.cache_name || manifest.name;
- console.log('Using cache name:', CACHE_NAME);
-
- // Set page title from manifest
- if (manifest.name) {
- document.title = manifest.name;
- }
-
- // Now that we have CACHE_NAME, load tracks
- return fetch('tracks/tracks.json');
- })
- .then(response => {
- if (!response.ok) {
- throw new Error('tracks.json not found');
- }
- return response.json();
- })
- .then(data => {
- songs = shuffle ? shuffleArray(data) : data;
- if (songs.length > 0) {
- playerReady = true;
- updateCurrentSongDisplay(`Ready to play: ${songs[0].artist} – ${songs[0].title}`);
- // Check which tracks are already cached before rendering
- return checkCachedTracks().then(() => {
- renderPlaylist();
- // Pre-cache resources first, then songs
- return preloadResources().then(() => {
- startPreloadingSongs();
- });
- });
- } else {
- updateCurrentSongDisplay('No tracks found');
- }
- })
- .catch(error => {
- console.error('Error loading manifest or tracks:', error);
- updateCurrentSongDisplay('Unable to load tracks. Please check your connection.');
- });
-
-// Audio event listeners
-audio.addEventListener('play', () => {
- startProgressBar();
- const song = songs[currentSongIndex];
- const songText = `${song.artist} – ${song.title}`;
-
- // Always update display to ensure we remove "Ready to play:" prefix
- const currentText = currentSongDisplay.querySelector('span')?.textContent || '';
-
- // Check if it's a different song (not just play/pause of same song)
- const isNewSong = !currentText.includes(songText);
- const hasReadyToPlay = currentText.includes('Ready to play:');
-
- if (isNewSong || hasReadyToPlay) {
- updateCurrentSongDisplay(songText);
- } else {
- // Same song, just resume the marquee
- resumeMarquee();
- }
-
- // Update media session metadata
- if ('mediaSession' in navigator) {
- // Convert relative path to absolute URL for media session
- // Use document.baseURI to correctly resolve paths in subdirectories
- const albumArtUrl = new URL('resources/album_art.jpg', document.baseURI).href;
- navigator.mediaSession.metadata = new MediaMetadata({
- title: song.title,
- artist: song.artist,
- artwork: [
- { src: albumArtUrl, sizes: '860x860', type: 'image/jpeg' },
- { src: albumArtUrl, sizes: '512x512', type: 'image/jpeg' },
- { src: albumArtUrl, sizes: '256x256', type: 'image/jpeg' },
- { src: albumArtUrl, sizes: '128x128', type: 'image/jpeg' }
- ]
- });
-
- // Set action handlers after playback starts (required for iOS)
- // Explicitly set seek handlers to null so iOS shows next/prev instead
- navigator.mediaSession.setActionHandler('play', () => {
- if (playerReady && !isPlaying) {
- togglePlayPause();
- }
- });
-
- navigator.mediaSession.setActionHandler('pause', () => {
- if (playerReady && isPlaying) {
- togglePlayPause();
- }
- });
-
- navigator.mediaSession.setActionHandler('previoustrack', () => {
- if (playerReady) {
- prevSong();
- }
- });
-
- navigator.mediaSession.setActionHandler('nexttrack', () => {
- if (playerReady) {
- nextSong();
- }
- });
-
- // Explicitly set seek handlers to null to show track controls instead
- navigator.mediaSession.setActionHandler('seekbackward', null);
- navigator.mediaSession.setActionHandler('seekforward', null);
- }
-});
-
-audio.addEventListener('pause', () => {
- stopProgressBar();
- pauseMarquee();
-});
-
-audio.addEventListener('ended', () => {
- if (songs[currentSongIndex].looping) {
- audio.currentTime = 0;
- audio.play();
- } else {
- nextSong();
- }
-});
-
-audio.addEventListener('error', (e) => {
- console.error('Audio error:', e);
- updateCurrentSongDisplay(`Error loading: ${songs[currentSongIndex].filename}`);
- // Try next song after a brief delay
- setTimeout(() => nextSong(), 1000);
-});
-
-audio.addEventListener('loadedmetadata', () => {
- resetProgressBar();
-});
-
-function renderPlaylist() {
- playlist.innerHTML = '';
- const currentDisplayText = currentSongDisplay.textContent;
- const isInitialized = currentDisplayText !== 'No song playing';
-
- songs.forEach((song, index) => {
- const item = document.createElement('div');
- item.classList.add('playlist-item');
-
- const contentDiv = document.createElement('div');
- contentDiv.classList.add('playlist-item-content');
-
- const titleDiv = document.createElement('div');
- titleDiv.classList.add('playlist-item-title');
- if (isInitialized && index === currentSongIndex) {
- titleDiv.classList.add('current');
- }
- titleDiv.textContent = song.title;
-
- const artistDiv = document.createElement('div');
- artistDiv.classList.add('playlist-item-artist');
- if (isInitialized && index === currentSongIndex) {
- artistDiv.classList.add('current');
- }
- artistDiv.textContent = song.artist;
-
- // Set cached status for visual indication
- const isCached = cachedTracks.has(song.filename);
- if (!isCached) {
- contentDiv.classList.add('uncached');
- }
-
- contentDiv.appendChild(titleDiv);
- contentDiv.appendChild(artistDiv);
-
- const loopIcon = document.createElement('span');
- loopIcon.textContent = '🔁';
- loopIcon.style.display = (song.looping || false) ? 'inline' : 'none';
-
- item.appendChild(contentDiv);
- item.appendChild(loopIcon);
- item.addEventListener('click', () => toggleLooping(index));
- playlist.appendChild(item);
- });
-}
-
-function toggleLooping(index) {
- if (!playerReady) return;
- if (index === currentSongIndex) {
- if (!isPlaying && currentSongDisplay.textContent.includes('Ready to play')) {
- audio.play().catch(err => {
- console.error('Failed to play audio:', err);
- });
- isPlaying = true;
- updatePlayPauseButton();
- return;
- }
- // Toggle looping
- songs[index].looping = !(songs[index].looping || false);
- renderPlaylist();
- } else {
- playSong(index);
- }
-}
-
-function playSong(index) {
- console.log(`playSong called with index: ${index}`);
- if (!playerReady) {
- console.log('Player not ready');
- return;
- }
- // Clear looping from all songs except the new one if it was already looping
- const wasLooping = songs[index].looping || false;
- songs.forEach(song => song.looping = false);
- if (wasLooping) {
- songs[index].looping = true;
- }
-
- currentSongIndex = index;
- const song = songs[currentSongIndex];
- console.log(`Attempting to play: ${song.artist} – ${song.title}`);
- console.log(`Filename: ${song.filename}`);
- console.log(`Is in preloadedAudio: ${!!preloadedAudio[song.filename]}`);
-
- // Use preloaded blob if available, otherwise load from server
- if (preloadedAudio[song.filename]) {
- const blobUrl = preloadedAudio[song.filename].blobUrl;
- console.log(`Playing from preloaded blob: ${song.filename}`);
- console.log(`Blob URL: ${blobUrl}`);
- audio.src = blobUrl;
- } else {
- console.log(`Song not preloaded, loading: ${song.filename}`);
- audio.src = `tracks/${song.filename}`;
- // Request priority preloading for this song
- requestPriorityPreload(song.filename);
- }
-
- console.log(`Audio src set to: ${audio.src}`);
-
- // For iOS PWA: We need to call load() and play() synchronously
- // Reset any previous state first
- try {
- audio.pause();
- audio.currentTime = 0;
- } catch (e) {
- // Ignore errors from resetting
- }
-
- // Load the audio to ensure it's ready (important for iOS PWA)
- audio.load();
-
- // Small delay to let load() initialize, then play
- // This needs to be synchronous enough that iOS considers it part of the user gesture
- const playAttempt = audio.play();
-
- if (playAttempt !== undefined) {
- playAttempt.then(() => {
- console.log('Audio playback started successfully');
- isPlaying = true;
- updatePlayPauseButton();
- }).catch(err => {
- console.error('Failed to play audio:', err);
- console.error('Error name:', err.name);
- console.error('Error message:', err.message);
- isPlaying = false;
- updatePlayPauseButton();
- updateCurrentSongDisplay(`Error playing: ${song.title}`);
- });
- }
-
- // Optimistically set playing state
- isPlaying = true;
- updatePlayPauseButton();
- renderPlaylist();
-}
-
-function updateCurrentSongDisplay(text) {
- currentSongDisplay.innerHTML = `<span>${text}</span>`;
- // Initialize marquee effect after a brief delay to ensure DOM is updated
- setTimeout(() => setupMarquee(), 50);
-}
-
-// Marquee state
-let marqueeAnimating = false;
-let marqueePaused = false;
-let marqueeTimeoutId = null;
-let marqueeOriginalText = '';
-let marqueeDuration = 0;
-let marqueeHalfWidth = 0;
-let marqueeTransitionEndHandler = null;
-let marqueeCurrentTransform = 'translateX(0)';
-
-function setupMarquee() {
- const container = currentSongDisplay;
- const textSpan = container.querySelector('span');
-
- if (!textSpan) return;
-
- // Clear any existing animation
- if (marqueeTimeoutId) {
- clearTimeout(marqueeTimeoutId);
- marqueeTimeoutId = null;
- }
- if (marqueeTransitionEndHandler) {
- textSpan.removeEventListener('transitionend', marqueeTransitionEndHandler);
- marqueeTransitionEndHandler = null;
- }
- marqueeAnimating = false;
- marqueePaused = false;
-
- // Reset styles
- textSpan.style.transition = 'none';
- textSpan.style.transform = 'translateX(0)';
- marqueeCurrentTransform = 'translateX(0)';
- container.classList.remove('no-overflow');
-
- // Store original text
- marqueeOriginalText = textSpan.textContent;
-
- // Force reflow
- void textSpan.offsetWidth;
-
- // Check if text overflows
- const containerWidth = container.offsetWidth - 30; // Account for padding
- const textWidth = textSpan.scrollWidth;
- const overflows = textWidth > containerWidth;
-
- if (!overflows) {
- // No overflow - show ellipsis behavior
- container.classList.add('no-overflow');
- container.classList.remove('marquee-active');
- return;
- }
-
- // Text overflows - setup marquee
- marqueeAnimating = true;
- container.classList.add('marquee-active');
-
- // Add spacing and duplicate text for seamless loop
- // Using non-breaking spaces (\u00A0) so they don't collapse in HTML
- const spacing = '\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0'; // 12 non-breaking spaces
- textSpan.textContent = marqueeOriginalText + spacing + marqueeOriginalText + spacing;
-
- // Calculate animation parameters
- const fullWidth = textSpan.scrollWidth;
- marqueeHalfWidth = fullWidth / 2;
- marqueeDuration = (marqueeHalfWidth / 50) * 1000; // Adjust speed here (pixels per second)
-
- // Start animation after a brief delay (only if not paused)
- // Don't start animation if already paused
- if (!marqueePaused) {
- marqueeTimeoutId = setTimeout(() => {
- if (marqueePaused) return;
-
- textSpan.style.transition = `transform ${marqueeDuration}ms linear`;
- textSpan.style.transform = `translateX(-${marqueeHalfWidth}px)`;
- marqueeCurrentTransform = `translateX(-${marqueeHalfWidth}px)`;
-
- // Reset and loop
- marqueeTransitionEndHandler = () => {
- if (!marqueeAnimating || marqueePaused) return;
-
- textSpan.style.transition = 'none';
- textSpan.style.transform = 'translateX(0)';
- marqueeCurrentTransform = 'translateX(0)';
-
- setTimeout(() => {
- if (!marqueeAnimating || marqueePaused) return;
- textSpan.style.transition = `transform ${marqueeDuration}ms linear`;
- textSpan.style.transform = `translateX(-${marqueeHalfWidth}px)`;
- marqueeCurrentTransform = `translateX(-${marqueeHalfWidth}px)`;
- }, 50);
- };
-
- textSpan.addEventListener('transitionend', marqueeTransitionEndHandler);
- }, 1000); // Initial delay before starting scroll
- }
-}
-
-function pauseMarquee() {
- marqueePaused = true;
- const textSpan = currentSongDisplay.querySelector('span');
- if (!textSpan || !marqueeAnimating) return;
-
- // Capture current transform position
- const computedStyle = window.getComputedStyle(textSpan);
- const currentTransform = computedStyle.transform;
- marqueeCurrentTransform = currentTransform;
-
- // Freeze at current position
- textSpan.style.transition = 'none';
- textSpan.style.transform = currentTransform;
-
- // Clear any pending timeout
- if (marqueeTimeoutId) {
- clearTimeout(marqueeTimeoutId);
- marqueeTimeoutId = null;
- }
-}
-
-function resumeMarquee() {
- if (!marqueeAnimating) return;
- marqueePaused = false;
-
- const textSpan = currentSongDisplay.querySelector('span');
- if (!textSpan) return;
-
- // Apply the stored transform position first
- textSpan.style.transition = 'none';
- textSpan.style.transform = marqueeCurrentTransform;
-
- // Force reflow to apply the transform
- void textSpan.offsetWidth;
-
- // Get current position from the stored transform
- const matrix = new DOMMatrix(marqueeCurrentTransform);
- const currentX = matrix.m41;
-
- // Calculate remaining distance and time
- const distanceTraveled = Math.abs(currentX);
- const percentComplete = distanceTraveled / marqueeHalfWidth;
- const timeRemaining = marqueeDuration * (1 - percentComplete);
-
- // Resume animation from current position
- textSpan.style.transition = `transform ${timeRemaining}ms linear`;
- textSpan.style.transform = `translateX(-${marqueeHalfWidth}px)`;
- marqueeCurrentTransform = `translateX(-${marqueeHalfWidth}px)`;
-}
-
-// Add window resize listener to recalculate marquee
-window.addEventListener('resize', () => {
- if (marqueeOriginalText) {
- // Store the paused state before recalculating
- const wasPaused = marqueePaused;
-
- // Restore original text before recalculating
- const textSpan = currentSongDisplay.querySelector('span');
- if (textSpan) {
- textSpan.textContent = marqueeOriginalText;
- }
-
- setupMarquee();
-
- // Restore paused state after recalculation
- if (wasPaused) {
- marqueePaused = true;
- }
- }
-});
-
-function togglePlayPause() {
- if (!playerReady) return;
- if (isPlaying) {
- audio.pause();
- isPlaying = false;
- } else {
- // If no song is loaded, load the first one
- if (!audio.src || audio.src === '') {
- playSong(currentSongIndex);
- } else {
- audio.play().catch(err => {
- console.error('Failed to play audio:', err);
- const song = songs[currentSongIndex];
- updateCurrentSongDisplay(`Error playing: ${song.title}`);
- });
- isPlaying = true;
- }
- }
- updatePlayPauseButton();
-}
-
-function updatePlayPauseButton() {
- playPauseBtn.classList.toggle('pause', isPlaying);
-}
-
-function nextSong() {
- if (!playerReady) return;
- currentSongIndex = (currentSongIndex + 1) % songs.length;
- playSong(currentSongIndex);
-}
-
-function prevSong() {
- if (!playerReady) return;
- if (audio.currentTime <= 3) {
- currentSongIndex = (currentSongIndex - 1 + songs.length) % songs.length;
- playSong(currentSongIndex);
- } else {
- audio.currentTime = 0;
- }
-}
-
-function startProgressBar() {
- stopProgressBar();
-
- function animate() {
- updateProgressBar();
- animationFrameId = requestAnimationFrame(animate);
- }
-
- animate();
-
- // iOS PWA background playback fix: use setInterval as backup
- // setInterval is less throttled than requestAnimationFrame in background
- backgroundPlaybackCheckInterval = setInterval(() => {
- if (!audio.paused && audio.duration && audio.currentTime >= audio.duration - 0.5) {
- console.log('Background check: song ended, triggering next');
- clearInterval(backgroundPlaybackCheckInterval);
- backgroundPlaybackCheckInterval = null;
-
- if (songs[currentSongIndex].looping) {
- audio.currentTime = 0;
- audio.play();
- } else {
- nextSong();
- }
- }
-
- // Update Media Session position state for iOS
- if ('mediaSession' in navigator && 'setPositionState' in navigator.mediaSession) {
- try {
- if (audio.duration && !isNaN(audio.duration) && isFinite(audio.duration)) {
- navigator.mediaSession.setPositionState({
- duration: audio.duration,
- playbackRate: audio.playbackRate,
- position: audio.currentTime
- });
- }
- } catch (e) {
- // Ignore errors from setPositionState
- }
- }
- }, 500); // Check every 500ms
-}
-
-function stopProgressBar() {
- if (animationFrameId !== null) {
- cancelAnimationFrame(animationFrameId);
- animationFrameId = null;
- }
- if (backgroundPlaybackCheckInterval !== null) {
- clearInterval(backgroundPlaybackCheckInterval);
- backgroundPlaybackCheckInterval = null;
- }
-}
-
-function resetProgressBar() {
- progressBar.style.setProperty('--progress', '0');
-}
-
-function updateProgressBar() {
- if (audio.duration && !isDragging && !isSeeking) {
- const currentTime = audio.currentTime;
- const duration = audio.duration;
- const progressPercentage = (currentTime / duration) * 100;
- const displayPercentage = isNaN(progressPercentage) ? 0 : progressPercentage;
- progressBar.style.setProperty('--progress', displayPercentage);
- }
-}
-
-let isDragging = false;
-let wasPlayingBeforeDrag = false;
-let pendingSeekPercentage = null;
-let backgroundPlaybackCheckInterval = null;
-
-function updateVisualProgress(event) {
- if (!playerReady) return;
-
- const rect = progressBar.getBoundingClientRect();
- // Support both mouse and touch events
- const clientX = event.touches ? event.touches[0].clientX : event.clientX;
- const clickPosition = clientX - rect.left;
- const clickPercentage = Math.max(0, Math.min(1, clickPosition / rect.width));
-
- progressBar.style.setProperty('--progress', clickPercentage * 100);
- return clickPercentage;
-}
-
-let isSeeking = false;
-let targetSeekTime = null;
-
-function applySeek(clickPercentage) {
- if (!playerReady) return;
-
- // If audio hasn't been loaded yet, load it but don't play
- if (!audio.src || audio.src === '') {
- const song = songs[currentSongIndex];
-
- // Use preloaded blob if available, otherwise load from server
- if (preloadedAudio[song.filename]) {
- audio.src = preloadedAudio[song.filename].blobUrl;
- } else {
- audio.src = `tracks/${song.filename}`;
- }
-
- // Wait for metadata to be loaded before seeking
- audio.addEventListener('loadedmetadata', function setInitialTime() {
- const duration = audio.duration;
- const seekTime = duration * clickPercentage;
- attemptSeekWithRetry(seekTime, clickPercentage);
- prePlaySeekTime = seekTime;
- audio.removeEventListener('loadedmetadata', setInitialTime);
- }, { once: true });
- } else if (audio.duration) {
- const duration = audio.duration;
- const seekTime = duration * clickPercentage;
- attemptSeekWithRetry(seekTime, clickPercentage);
- prePlaySeekTime = seekTime;
- }
-}
-
-function isTimeBuffered(time) {
- // Check if the given time is within any buffered time range
- for (let i = 0; i < audio.buffered.length; i++) {
- if (time >= audio.buffered.start(i) && time <= audio.buffered.end(i)) {
- return true;
- }
- }
- return false;
-}
-
-function attemptSeekWithRetry(seekTime, targetPercentage) {
- targetSeekTime = seekTime;
- isSeeking = true;
-
- // Lock the progress bar at the target position
- progressBar.style.setProperty('--progress', targetPercentage * 100);
-
- const wasPlaying = !audio.paused;
-
- // Try to seek
- audio.currentTime = seekTime;
-
- // Handler to check if we reached the target after seeking completes
- function checkSeekSuccess() {
- // Allow small tolerance for floating point comparison
- if (Math.abs(audio.currentTime - targetSeekTime) > 0.5) {
- // Browser clamped to buffered range - need to wait for more data
- // Now pause during seeking
- if (wasPlaying) {
- audio.pause();
- }
- continueSeekingToTarget(wasPlaying);
- } else {
- // Successfully reached target immediately (was already buffered)
- isSeeking = false;
- targetSeekTime = null;
- // No need to update display - the seek was instant and playback continues normally
- }
- }
-
- audio.addEventListener('seeked', checkSeekSuccess, { once: true });
-}
-
-function continueSeekingToTarget(wasPlaying) {
- // Handler for when more data loads
- function retrySeek() {
- if (!isSeeking || targetSeekTime === null) {
- return; // Seeking was cancelled
- }
-
- audio.currentTime = targetSeekTime;
-
- // Check again after this seek completes
- function checkAgain() {
- if (!isSeeking || targetSeekTime === null) {
- return;
- }
-
- if (Math.abs(audio.currentTime - targetSeekTime) > 0.5) {
- // Still not there, keep trying
- continueSeekingToTarget(wasPlaying);
- } else {
- // Success!
- isSeeking = false;
- targetSeekTime = null;
-
- if (wasPlaying) {
- audio.play();
- }
- }
- }
-
- audio.addEventListener('seeked', checkAgain, { once: true });
- }
-
- // Wait for more data to load, then try again
- audio.addEventListener('progress', retrySeek, { once: true });
-
- // Also set a timeout fallback in case progress doesn't fire
- setTimeout(() => {
- if (isSeeking && targetSeekTime !== null && Math.abs(audio.currentTime - targetSeekTime) > 0.5) {
- retrySeek();
- }
- }, 1000);
-}
-
-function onProgressMouseDown(event) {
- if (!playerReady) return;
- isDragging = true;
- wasPlayingBeforeDrag = isPlaying;
- progressContainer.style.cursor = 'grabbing';
- document.body.style.cursor = 'grabbing';
- pendingSeekPercentage = updateVisualProgress(event);
- event.preventDefault();
-}
-
-function onProgressMouseMove(event) {
- if (isDragging) {
- pendingSeekPercentage = updateVisualProgress(event);
- }
-}
-
-function onProgressMouseUp(event) {
- if (isDragging) {
- isDragging = false;
- progressContainer.style.cursor = '';
- document.body.style.cursor = '';
-
- // Apply the seek now that drag is complete
- if (pendingSeekPercentage !== null) {
- applySeek(pendingSeekPercentage);
- pendingSeekPercentage = null;
- }
-
- // If it was "Ready to play" (not playing before), start playing now
- if (!wasPlayingBeforeDrag && !isPlaying && audio.src) {
- audio.play();
- isPlaying = true;
- updatePlayPauseButton();
- }
- }
-}
-
-playPauseBtn.addEventListener('click', togglePlayPause);
-nextBtn.addEventListener('click', nextSong);
-prevBtn.addEventListener('click', prevSong);
-
-// Mouse events for desktop
-progressContainer.addEventListener('mousedown', onProgressMouseDown);
-document.addEventListener('mousemove', onProgressMouseMove);
-document.addEventListener('mouseup', onProgressMouseUp);
-
-// Touch events for mobile
-progressContainer.addEventListener('touchstart', onProgressMouseDown, { passive: false });
-document.addEventListener('touchmove', onProgressMouseMove, { passive: false });
-document.addEventListener('touchend', onProgressMouseUp);
-
-// Keyboard controls
-document.addEventListener('keydown', function(event) {
- if (!playerReady) return;
-
- // Spacebar: play/pause
- if (event.code === 'Space') {
- event.preventDefault();
- togglePlayPause();
- }
-});
-
-// Pre-caching system
-function preloadResources() {
- console.log('Preloading UI resources...');
-
- const resources = [
- 'resources/play.png',
- 'resources/pause.png',
- 'resources/prev.png',
- 'resources/next.png',
- 'resources/album_art.jpg'
- ];
-
- const imagePromises = resources.map(src => {
- return new Promise((resolve, reject) => {
- const img = new Image();
- img.onload = () => {
- console.log(`Preloaded: ${src}`);
- resolve();
- };
- img.onerror = () => {
- console.error(`Failed to preload: ${src}`);
- resolve(); // Resolve anyway to not block other resources
- };
- img.src = src;
- });
- });
-
- return Promise.all(imagePromises).then(() => {
- console.log('All UI resources preloaded');
- });
-}
-
-function startPreloadingSongs() {
- // Start with the first song
- currentPreloadIndex = 0;
- preloadNextSong();
-}
-
-function preloadNextSong() {
- if (currentPreloadIndex >= songs.length) {
- const totalMB = (totalBytesLoaded / 1024 / 1024).toFixed(2);
- console.log(`All songs preloaded - Total size: ${totalMB} MB (${totalBytesLoaded} bytes)`);
- return;
- }
-
- const song = songs[currentPreloadIndex];
- const filename = song.filename;
-
- // Skip if already preloaded in memory
- if (preloadedAudio[filename]) {
- currentPreloadIndex++;
- preloadNextSong();
- return;
- }
-
- // If already cached, load from cache into memory
- if (cachedTracks.has(filename)) {
- console.log(`Loading from cache: ${song.artist} – ${song.title}`);
- loadFromCache(filename).then(() => {
- currentPreloadIndex++;
- setTimeout(() => preloadNextSong(), 100);
- }).catch(err => {
- console.error(`Failed to load from cache, fetching instead:`, err);
- // If cache load fails, fetch from network
- fetchAndPreloadSong(song, filename);
- });
- return;
- }
-
- console.log(`Preloading: ${song.artist} – ${song.title}`);
- fetchAndPreloadSong(song, filename);
-}
-
-function fetchAndPreloadSong(song, filename) {
- // Use fetch to force full download of the entire file
- fetch(`tracks/${filename}`)
- .then(response => {
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- // Get total file size for progress tracking
- const contentLength = response.headers.get('content-length');
- console.log(`Downloading ${song.title} (${(contentLength / 1024 / 1024).toFixed(2)} MB)...`);
-
- // Read the entire response as a blob
- return response.blob();
- })
- .then(blob => {
- // Add blob size to total
- totalBytesLoaded += blob.size;
-
- // Create a blob URL that will persist in memory
- const blobUrl = URL.createObjectURL(blob);
-
- preloadedAudio[filename] = {
- blobUrl: blobUrl,
- blob: blob
- };
-
- console.log(`✓ Fully preloaded: ${song.artist} – ${song.title}`);
-
- // Store in Cache API for offline access
- return storeBlobInCache(filename, blob).then(() => {
- // Mark as cached and update UI
- cachedTracks.add(filename);
- updateTrackCachedStatus(filename);
-
- // Move to next song
- currentPreloadIndex++;
- setTimeout(() => preloadNextSong(), 100);
- });
- })
- .catch(error => {
- console.error(`Failed to preload ${filename}:`, error);
- currentPreloadIndex++;
- preloadNextSong();
- });
-}
-
-// Check which tracks are already cached on app load
-async function checkCachedTracks() {
- try {
- const cache = await caches.open(CACHE_NAME);
- const cachedRequests = await cache.keys();
-
- // Check each song to see if it's cached
- for (const song of songs) {
- // Build the same absolute URL that storeBlobInCache uses for consistency
- const absoluteUrl = new URL(`tracks/${song.filename}`, window.location.href).href;
- const isInCache = cachedRequests.some(request => request.url === absoluteUrl);
- if (isInCache) {
- cachedTracks.add(song.filename);
- }
- }
-
- console.log(`Found ${cachedTracks.size}/${songs.length} tracks already cached`);
- console.log('Cached tracks:', Array.from(cachedTracks));
- } catch (error) {
- console.error('Failed to check cached tracks:', error);
- }
-}
-
-// Debug function to check preloaded state
-window.debugAudioState = function() {
- console.log('=== Audio State Debug ===');
- console.log('Player ready:', playerReady);
- console.log('Is playing:', isPlaying);
- console.log('Current song index:', currentSongIndex);
- console.log('Total songs:', songs.length);
- console.log('Cached tracks count:', cachedTracks.size);
- console.log('Preloaded audio count:', Object.keys(preloadedAudio).length);
- console.log('Current audio src:', audio.src);
- console.log('Audio paused:', audio.paused);
- console.log('Audio error:', audio.error);
- if (songs[currentSongIndex]) {
- console.log('Current song:', songs[currentSongIndex].filename);
- console.log('Is preloaded:', !!preloadedAudio[songs[currentSongIndex].filename]);
- console.log('Is cached:', cachedTracks.has(songs[currentSongIndex].filename));
- }
- console.log('======================');
-};
-
-// Load a track from cache into memory
-async function loadFromCache(filename) {
- try {
- const cache = await caches.open(CACHE_NAME);
- // Try both relative and absolute URLs
- let response = await cache.match(`tracks/${filename}`);
- if (!response) {
- // Try with absolute URL
- const absoluteUrl = new URL(`tracks/${filename}`, window.location.href).href;
- response = await cache.match(absoluteUrl);
- }
-
- if (!response) {
- throw new Error('Not in cache');
- }
-
- const blob = await response.blob();
-
- // Add blob size to total
- totalBytesLoaded += blob.size;
-
- // Create a blob URL that will persist in memory
- const blobUrl = URL.createObjectURL(blob);
-
- preloadedAudio[filename] = {
- blobUrl: blobUrl,
- blob: blob
- };
- console.log(`✓ Loaded from cache: ${filename}`);
- } catch (error) {
- console.error(`Failed to load from cache: ${filename}`, error);
- throw error;
- }
-}
-
-// Store blob in Cache API for offline access
-async function storeBlobInCache(filename, blob) {
- try {
- const cache = await caches.open(CACHE_NAME);
- const response = new Response(blob, {
- headers: {
- 'Content-Type': 'audio/mpeg',
- 'Content-Length': blob.size
- }
- });
- // Use absolute URL for consistency
- const absoluteUrl = new URL(`tracks/${filename}`, window.location.href).href;
- await cache.put(absoluteUrl, response);
- console.log(`✓ Cached for offline: ${filename}`);
- } catch (error) {
- console.error(`Failed to cache ${filename}:`, error);
- }
-}
-
-// Update UI to show track is cached
-function updateTrackCachedStatus(filename) {
- const songIndex = songs.findIndex(s => s.filename === filename);
- if (songIndex === -1) return;
-
- // Find the playlist item and remove uncached class
- const playlistItems = playlist.querySelectorAll('.playlist-item');
- if (playlistItems[songIndex]) {
- const contentDiv = playlistItems[songIndex].querySelector('.playlist-item-content');
- if (contentDiv) {
- contentDiv.classList.remove('uncached');
- }
- }
-}
-
-// Priority preloading system
-function requestPriorityPreload(filename) {
- // Skip if already preloaded or already in priority queue
- if (preloadedAudio[filename] || priorityPreloadQueue.includes(filename)) {
- return;
- }
-
- console.log(`🔥 Priority preload requested: ${filename}`);
- priorityPreloadQueue.push(filename);
-
- // Start priority preloading if not already running
- if (!isPreloadingPriority) {
- processPriorityPreload();
- }
-}
-
-function processPriorityPreload() {
- if (priorityPreloadQueue.length === 0) {
- isPreloadingPriority = false;
- return;
- }
-
- isPreloadingPriority = true;
- const filename = priorityPreloadQueue.shift();
-
- // Check if already preloaded (might have finished during normal preloading)
- if (preloadedAudio[filename]) {
- processPriorityPreload();
- return;
- }
-
- // Find the song info
- const song = songs.find(s => s.filename === filename);
- if (!song) {
- processPriorityPreload();
- return;
- }
-
- // If already cached, load from cache
- if (cachedTracks.has(filename)) {
- console.log(`🔥 Priority loading from cache: ${song.artist} – ${song.title}`);
- loadFromCache(filename).then(() => {
- processPriorityPreload();
- }).catch(err => {
- console.error(`Failed to load from cache, fetching instead:`, err);
- priorityFetchAndPreloadSong(song, filename);
- });
- return;
- }
-
- console.log(`🔥 Priority preloading: ${song.artist} – ${song.title}`);
- priorityFetchAndPreloadSong(song, filename);
-}
-
-function priorityFetchAndPreloadSong(song, filename) {
- // Use fetch to force full download of the entire file
- fetch(`tracks/${filename}`)
- .then(response => {
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- return response.blob();
- })
- .then(blob => {
- // Create a blob URL that will persist in memory
- const blobUrl = URL.createObjectURL(blob);
-
- preloadedAudio[filename] = {
- blobUrl: blobUrl,
- blob: blob
- };
-
- // Next time this song plays, it will use the cached version
- console.log(`✓ Priority preloaded: ${song.artist} – ${song.title}`);
-
- // Store in Cache API for offline access
- return storeBlobInCache(filename, blob).then(() => {
- // Mark as cached and update UI
- cachedTracks.add(filename);
- updateTrackCachedStatus(filename);
-
- // Process next priority request
- processPriorityPreload();
- });
- })
- .catch(error => {
- // Ignore abort errors (happens when normal preload finishes first)
- if (error.name === 'AbortError' || error.message.includes('aborted')) {
- // This is expected - normal preloading probably finished first
- } else {
- console.error(`Failed to priority preload ${filename}:`, error);
- }
- processPriorityPreload();
- });
-}
-:root {
- --text: #dddddd;
- --dimmed-text: #ddddddcc;
- --primary: #4c7ae6;
- --accent: #f421ff;
- --background: #080a0c;
-}
-
-* {
- scrollbar-color: var(--dimmed-text) var(--background);
- user-select: none;
- -webkit-user-select: none;
- -webkit-touch-callout: none;
- -webkit-tap-highlight-color: transparent;
-}
-
-html {
- scroll-behavior: smooth;
- overscroll-behavior: none;
- -ms-overflow-style: none;
- scrollbar-width: none;
-}
-
-html::-webkit-scrollbar {
- display: none;
-}
-
-.playlist-wrapper::-webkit-scrollbar {
- display: none;
-}
-
-body {
- margin: 0;
- padding: 0;
- font-family: "MS Sans Serif", Arial, sans-serif;
- height: 100dvh;
- display: flex;
- flex-direction: column;
- font-size: 25px;
- background-color: var(--background);
- position: fixed;
- width: 100%;
-}
-
-.window-container {
- background: transparent;
- height: 100%;
- display: flex;
- flex-direction: column;
-}
-
-.player-container {
- flex-grow: 1;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- position: relative;
- padding-top: 10px;
-}
-
-.controls {
- display: flex;
- justify-content: center;
- background: transparent;
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
- box-sizing: border-box;
- z-index: 3;
- pointer-events: none;
-}
-
-.control-button {
- width: clamp(60px, calc((100vw - 58px) / 3), 120px);
- aspect-ratio: 8 / 3;
- background-size: contain;
- background-repeat: no-repeat;
- background-position: center;
- border: none;
- cursor: pointer;
- margin: 0;
- border-radius: 50%;
- background-color: var(--text);
- box-shadow:
- inset -2px -2px 4px rgba(0, 0, 0, 0.3),
- inset 2px 2px 4px rgba(255, 255, 255, 0.4),
- 0 2px 4px rgba(0, 0, 0, 0.5),
- 0 4px 8px rgba(0, 0, 0, 0.3);
- filter:
- drop-shadow(0 0 1px rgba(0, 0, 0, 0.8))
- drop-shadow(1px 1px 0 rgba(0, 0, 0, 0.6))
- drop-shadow(-0.5px -0.5px 0 rgba(255, 255, 255, 0.2));
- pointer-events: auto;
-}
-
-.controls-inner {
- display: flex;
- justify-content: space-between;
- padding: 10px 15px;
- pointer-events: auto;
- width: 100%;
- box-sizing: border-box;
-}
-
-@media (min-width: 420px) {
- .controls-inner {
- justify-content: center;
- gap: 14px;
- width: auto;
- }
-
- .control-button {
- width: 120px;
- }
-}
-
-.playlist-wrapper {
- flex-grow: 1;
- position: relative;
- overflow-y: auto;
- padding-bottom: 70px;
- overscroll-behavior: none;
- margin-top: -1px;
-}
-
-.playlist {
- padding-top: 0;
- cursor: initial;
-}
-
-.playlist-item {
- display: flex;
- justify-content: space-between;
- align-items: center;
- color: var(--text);
- min-height: 45px;
- padding-left: 15px;
- padding-right: 10px;
- padding-bottom: 18px;
-}
-
-.playlist-item-content {
- flex-grow: 1;
- display: flex;
- flex-direction: column;
- gap: 2px;
- transition: opacity 0.3s ease;
-}
-
-.playlist-item-content.uncached {
- opacity: 0.4;
-}
-
-.playlist-item-title {
- font-size: 21px;
- line-height: 1.2;
-}
-
-.playlist-item-title.current {
- font-weight: bold;
- color: var(--primary);
-}
-
-.playlist-item-artist {
- font-size: 17px;
- color: var(--dimmed-text);
- line-height: 1.2;
-}
-
-.playlist-item-artist.current {
- font-weight: bold;
- color: var(--primary);
- opacity: 1;
-}
-
-@media (hover: hover) and (pointer: fine) {
- .playlist-item:hover {
- cursor: pointer;
- color: var(--accent);
- }
-
- .playlist-item:hover .playlist-item-artist {
- color: var(--accent);
- }
-
- .playlist-item:hover .playlist-item-title.current,
- .playlist-item:hover .playlist-item-artist.current {
- color: var(--accent);
- }
-}
-
-.current-song {
- text-align: left;
- padding-left: 15px;
- padding-bottom: 10px;
- font-weight: bold;
- color: var(--primary);
- min-height: 35px;
- display: flex;
- align-items: center;
- background-color: var(--background);
- z-index: 2;
- position: relative;
-}
-
-.current-song span {
- display: inline-block;
- white-space: nowrap;
- will-change: transform;
-}
-
-.current-song.no-overflow span {
- overflow: hidden;
- text-overflow: hidden;
- max-width: 100%;
-}
-
-#audioPlayer {
- display: none;
-}
-
-.progress-container {
- width: 100%;
- font-size: 24px;
- cursor: grab;
- margin-top: 0;
- height: 35px;
- display: flex;
- align-items: center;
- padding: 0 15px;
- padding-bottom: 7px;
- box-sizing: border-box;
- position: relative;
- background-color: var(--background);
- z-index: 2;
-}
-
-.progress-container::before {
- content: '';
- position: absolute;
- left: 15px;
- right: 15px;
- top: 50%;
- transform: translateY(-50%);
- margin-top: -3.5px;
- height: 24px;
- pointer-events: auto;
-}
-
-.shadow-overlay {
- position: sticky;
- top: -1px;
- left: 0;
- right: 0;
- height: 14px;
- background: linear-gradient(
- to bottom,
- var(--background) 0%,
- rgba(8, 10, 12, 0.5) 40%,
- rgba(8, 10, 12, 0.2) 70%,
- transparent 100%
- );
- pointer-events: none;
- z-index: 2;
-}
-
-.progress-container:hover {
- cursor: grab;
-}
-
-.progress-bar {
- width: 100%;
- height: 4px;
- background-color: var(--text);
- position: relative;
- border-radius: 1px;
- --progress: 0;
- --circle-size: 16px;
-}
-
-.progress-bar::before {
- content: '';
- position: absolute;
- left: 0;
- top: 0;
- height: 100%;
- width: calc((100% - var(--circle-size) + 2px) * var(--progress) / 100 + var(--circle-size) / 2);
- background-color: var(--accent);
- border-radius: 1px;
- transition: none;
-}
-
-.progress-bar::after {
- content: '';
- position: absolute;
- left: calc((100% - var(--circle-size) + 2px) * var(--progress) / 100 - 1px);
- top: 50%;
- transform: translateY(-50%);
- width: var(--circle-size);
- height: var(--circle-size);
- background-color: var(--accent);
- border-radius: 50%;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
- transition: none;
-}
-
-.progress-text {
- display: none;
-}
-
-.progress-text-left {
- display: none;
-}
-
-.progress-text-center {
- display: none;
-}
-
-#playPause {
- background-image: url('resources/play.png');
- background-position: 52.5% center;
-}
-
-#playPause.pause {
- background-image: url('resources/pause.png');
- background-position: center;
-}
-
-#prev {
- background-image: url('resources/prev.png');
- background-position: 45% center;
-}
-
-#next {
- background-image: url('resources/next.png');
- background-position: 55% center;
-}