commit 51491dae350b12ab658ea537d380aeccc5641492
parent d527cb5372348947c30511295befdcc3ff3f23f2
Author: Hunter
Date:   Tue, 11 Nov 2025 00:22:55 -0500

restructure

Diffstat:
M.gitignore | 2+-
Mgenerate_manifests.py | 8++++----
Mindex.html | 4++--
Mreadme.md | 2+-
Aresources/script.js | 1129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/styles.css | 338+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dscript.js | 1129-------------------------------------------------------------------------------
Dstyles.css | 338-------------------------------------------------------------------------------
8 files changed, 1475 insertions(+), 1475 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -8,7 +8,7 @@ __pycache__/ # Tracks and playlists tracks/* !tracks/readme.md -resources/album_art.jpg +tracks/album_art.jpg # macOS .DS_Store diff --git a/generate_manifests.py b/generate_manifests.py @@ -68,7 +68,7 @@ def get_configuration(localhost=False): # File paths (no need to edit these) SCRIPT_DIR = Path(__file__).parent.absolute() TRACKS_JSON = SCRIPT_DIR / "tracks" / "tracks.json" -STYLES_CSS = SCRIPT_DIR / "styles.css" +STYLES_CSS = SCRIPT_DIR / "resources" / "styles.css" def get_background_color(): @@ -154,15 +154,15 @@ def generate_pwa_manifests(app_name=None, base_path=None): "static_files": [ "./", "index.html", - "styles.css", - "script.js", + "resources/styles.css", + "resources/script.js", "tracks/tracks.json", "resources/icon.png", "resources/play.png", "resources/pause.png", "resources/prev.png", "resources/next.png", - "resources/album_art.jpg" + "tracks/album_art.jpg" ], "tracks": [f"tracks/{track['filename']}" for track in tracks] } diff --git a/index.html b/index.html @@ -10,7 +10,7 @@ <link rel="apple-touch-icon" href="resources/icon.png"> <link rel="manifest" href="manifest.json"> <title>vibe capsule</title> - <link rel="stylesheet" href="styles.css"> + <link rel="stylesheet" href="resources/styles.css"> </head> <body> <div class="window-container"> @@ -38,6 +38,6 @@ <audio id="audioPlayer"></audio> - <script src="script.js"></script> + <script src="resources/script.js"></script> </body> </html> diff --git a/readme.md b/readme.md @@ -41,7 +41,7 @@ it's yours, and then it's theirs. - `manifest.json` (PWA configuration file) - `resource-manifest.json` (defines the files to be cached for offline use) - `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. + - optionally, add an `album_art.jpg` to `/tracks`. it'll be used as the cover for your mix when playing on supported devices. 4. **deploy your mixapp** - upload the entire project directory to any web host with HTTPS support (GitHub Pages, AWS S3, etc.) diff --git a/resources/script.js b/resources/script.js @@ -0,0 +1,1129 @@ +// 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('tracks/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', + 'tracks/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(); + }); +} diff --git a/resources/styles.css b/resources/styles.css @@ -0,0 +1,338 @@ +: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('play.png'); + background-position: 52.5% center; +} + +#playPause.pause { + background-image: url('pause.png'); + background-position: center; +} + +#prev { + background-image: url('prev.png'); + background-position: 45% center; +} + +#next { + background-image: url('next.png'); + background-position: 55% center; +} diff --git a/script.js b/script.js @@ -1,1129 +0,0 @@ -// 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(); - }); -} diff --git a/styles.css b/styles.css @@ -1,338 +0,0 @@ -: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; -}