preload resources/tracks to improve responsiveness

Hunter
Tuesday, October 28th, 2025 at 3:18 PM

host.py+9-1
script.js+268-2

host.py
57  	# Create server
58  	Handler = http.server.SimpleHTTPRequestHandler
59  
   -	# Suppress default logging
60 +	# Suppress default logging and broken pipe errors
61  	class QuietHandler(Handler):
62  		def log_message(self, format, *args):
63  			pass
64  
65 +		def handle(self):
66 +			"""Handle requests and suppress broken pipe errors"""
67 +			try:
68 +				super().handle()
69 +			except (BrokenPipeError, ConnectionResetError):
70 +				# Browser cancelled the request (normal for media streaming/preloading)
71 +				pass
72 +
73  	try:
74  		with socketserver.TCPServer(("", port), QuietHandler) as httpd:
75  			local_url = f"http://localhost:{port}"
script.js
 14  let songs = [];
 15  let animationFrameId = null;
 16  let prePlaySeekTime = 0;
 17 +let preloadedAudio = {}; // Cache for preloaded audio elements
 18 +let currentPreloadIndex = 0;
 19 +let priorityPreloadQueue = []; // Songs requested by user that need priority preloading
 20 +let isPreloadingPriority = false;
 21  
 22  // Load tracks from tracks.json
 23  fetch('tracks.json')

 33  			playerReady = true;
 34  			updateCurrentSongDisplay(`Ready to play: ${songs[0].artist} - ${songs[0].title}`);
 35  			renderPlaylist();
 36 +			// Pre-cache resources first, then songs
 37 +			preloadResources().then(() => {
 38 +				startPreloadingSongs();
 39 +			});
 40  		} else {
 41  			updateCurrentSongDisplay('No tracks found');
 42  		}

170  
171  	currentSongIndex = index;
172  	const song = songs[currentSongIndex];
    -	audio.src = `tracks/${song.filename}`;
173 +
174 +	// Use preloaded blob if available, otherwise load from server
175 +	if (preloadedAudio[song.filename]) {
176 +		audio.src = preloadedAudio[song.filename].blobUrl;
177 +	} else {
178 +		audio.src = `tracks/${song.filename}`;
179 +		// Request priority preloading for this song
180 +		requestPriorityPreload(song.filename);
181 +	}
182 +
183  	audio.play();
184  	isPlaying = true;
185  	updatePlayPauseButton();

285  	// If audio hasn't been loaded yet, load it but don't play
286  	if (!audio.src || audio.src === '') {
287  		const song = songs[currentSongIndex];
    -		audio.src = `tracks/${song.filename}`;
288 +
289 +		// Use preloaded blob if available, otherwise load from server
290 +		if (preloadedAudio[song.filename]) {
291 +			audio.src = preloadedAudio[song.filename].blobUrl;
292 +		} else {
293 +			audio.src = `tracks/${song.filename}`;
294 +		}
295  
296  		// Wait for metadata to be loaded before seeking
297  		audio.addEventListener('loadedmetadata', function setInitialTime() {

495  		nextSong();
496  	}
497  });
498 +
499 +// Pre-caching system
500 +function preloadResources() {
501 +	console.log('Preloading UI resources...');
502 +
503 +	const resources = [
504 +		'resources/play.png',
505 +		'resources/pause.png',
506 +		'resources/prev.png',
507 +		'resources/next.png'
508 +	];
509 +
510 +	const imagePromises = resources.map(src => {
511 +		return new Promise((resolve, reject) => {
512 +			const img = new Image();
513 +			img.onload = () => {
514 +				console.log(`Preloaded: ${src}`);
515 +				resolve();
516 +			};
517 +			img.onerror = () => {
518 +				console.error(`Failed to preload: ${src}`);
519 +				resolve(); // Resolve anyway to not block other resources
520 +			};
521 +			img.src = src;
522 +		});
523 +	});
524 +
525 +	return Promise.all(imagePromises).then(() => {
526 +		console.log('All UI resources preloaded');
527 +	});
528 +}
529 +
530 +function startPreloadingSongs() {
531 +	// Start with the first song
532 +	currentPreloadIndex = 0;
533 +	preloadNextSong();
534 +}
535 +
536 +function preloadNextSong() {
537 +	if (currentPreloadIndex >= songs.length) {
538 +		console.log('All songs preloaded');
539 +		return;
540 +	}
541 +
542 +	const song = songs[currentPreloadIndex];
543 +	const filename = song.filename;
544 +
545 +	// Skip if already preloaded
546 +	if (preloadedAudio[filename]) {
547 +		currentPreloadIndex++;
548 +		preloadNextSong();
549 +		return;
550 +	}
551 +
552 +	console.log(`Preloading: ${song.artist} - ${song.title}`);
553 +
554 +	// Use fetch to force full download of the entire file
555 +	fetch(`tracks/${filename}`)
556 +		.then(response => {
557 +			if (!response.ok) {
558 +				throw new Error(`HTTP error! status: ${response.status}`);
559 +			}
560 +			// Get total file size for progress tracking
561 +			const contentLength = response.headers.get('content-length');
562 +			console.log(`Downloading ${song.title} (${(contentLength / 1024 / 1024).toFixed(2)} MB)...`);
563 +
564 +			// Read the entire response as a blob
565 +			return response.blob();
566 +		})
567 +		.then(blob => {
568 +			// Create a blob URL that will persist in memory
569 +			const blobUrl = URL.createObjectURL(blob);
570 +
571 +			// Create audio element with the fully downloaded blob
572 +			const preloadAudio = new Audio();
573 +			preloadAudio.preload = 'auto';
574 +			preloadAudio.src = blobUrl;
575 +
576 +			// Wait for the audio element to fully buffer the blob
577 +			return new Promise((resolve) => {
578 +				const checkBuffered = () => {
579 +					// Check if the entire duration is buffered
580 +					if (preloadAudio.duration > 0 && preloadAudio.buffered.length > 0) {
581 +						const bufferedEnd = preloadAudio.buffered.end(preloadAudio.buffered.length - 1);
582 +						if (bufferedEnd >= preloadAudio.duration - 0.1) {
583 +							// Fully buffered!
584 +							resolve({ preloadAudio, blobUrl, blob });
585 +							return;
586 +						}
587 +					}
588 +					// Not fully buffered yet, check again soon
589 +					setTimeout(checkBuffered, 100);
590 +				};
591 +
592 +				// Start checking once metadata is loaded
593 +				preloadAudio.addEventListener('loadedmetadata', () => {
594 +					checkBuffered();
595 +				}, { once: true });
596 +
597 +				// Trigger the loading
598 +				preloadAudio.load();
599 +			});
600 +		})
601 +		.then(({ preloadAudio, blobUrl, blob }) => {
602 +			// Store both the audio element and blob URL
603 +			preloadedAudio[filename] = {
604 +				audio: preloadAudio,
605 +				blobUrl: blobUrl,
606 +				blob: blob
607 +			};
608 +
609 +			console.log(`✓ Fully preloaded: ${song.artist} - ${song.title}`);
610 +
611 +			// Move to next song
612 +			currentPreloadIndex++;
613 +			setTimeout(() => preloadNextSong(), 100);
614 +		})
615 +		.catch(error => {
616 +			console.error(`Failed to preload ${filename}:`, error);
617 +			currentPreloadIndex++;
618 +			preloadNextSong();
619 +		});
620 +}
621 +
622 +// Priority preloading system
623 +function requestPriorityPreload(filename) {
624 +	// Skip if already preloaded or already in priority queue
625 +	if (preloadedAudio[filename] || priorityPreloadQueue.includes(filename)) {
626 +		return;
627 +	}
628 +
629 +	console.log(`🔥 Priority preload requested: ${filename}`);
630 +	priorityPreloadQueue.push(filename);
631 +
632 +	// Start priority preloading if not already running
633 +	if (!isPreloadingPriority) {
634 +		processPriorityPreload();
635 +	}
636 +}
637 +
638 +function processPriorityPreload() {
639 +	if (priorityPreloadQueue.length === 0) {
640 +		isPreloadingPriority = false;
641 +		return;
642 +	}
643 +
644 +	isPreloadingPriority = true;
645 +	const filename = priorityPreloadQueue.shift();
646 +
647 +	// Check if already preloaded (might have finished during normal preloading)
648 +	if (preloadedAudio[filename]) {
649 +		processPriorityPreload();
650 +		return;
651 +	}
652 +
653 +	// Find the song info
654 +	const song = songs.find(s => s.filename === filename);
655 +	if (!song) {
656 +		processPriorityPreload();
657 +		return;
658 +	}
659 +
660 +	console.log(`🔥 Priority preloading: ${song.artist} - ${song.title}`);
661 +
662 +	// Use fetch to force full download of the entire file
663 +	fetch(`tracks/${filename}`)
664 +		.then(response => {
665 +			if (!response.ok) {
666 +				throw new Error(`HTTP error! status: ${response.status}`);
667 +			}
668 +			return response.blob();
669 +		})
670 +		.then(blob => {
671 +			// Create a blob URL that will persist in memory
672 +			const blobUrl = URL.createObjectURL(blob);
673 +
674 +			// Create audio element with the fully downloaded blob
675 +			const preloadAudio = new Audio();
676 +			preloadAudio.preload = 'auto';
677 +			preloadAudio.src = blobUrl;
678 +
679 +			// Wait for the audio element to fully buffer the blob
680 +			return new Promise((resolve) => {
681 +				const checkBuffered = () => {
682 +					// Check if the entire duration is buffered
683 +					if (preloadAudio.duration > 0 && preloadAudio.buffered.length > 0) {
684 +						const bufferedEnd = preloadAudio.buffered.end(preloadAudio.buffered.length - 1);
685 +						if (bufferedEnd >= preloadAudio.duration - 0.1) {
686 +							// Fully buffered!
687 +							resolve({ preloadAudio, blobUrl, blob });
688 +							return;
689 +						}
690 +					}
691 +					// Not fully buffered yet, check again soon
692 +					setTimeout(checkBuffered, 100);
693 +				};
694 +
695 +				// Start checking once metadata is loaded
696 +				preloadAudio.addEventListener('loadedmetadata', () => {
697 +					checkBuffered();
698 +				}, { once: true });
699 +
700 +				// Trigger the loading
701 +				preloadAudio.load();
702 +			});
703 +		})
704 +		.then(({ preloadAudio, blobUrl, blob }) => {
705 +			// Store both the audio element and blob URL
706 +			preloadedAudio[filename] = {
707 +				audio: preloadAudio,
708 +				blobUrl: blobUrl,
709 +				blob: blob
710 +			};
711 +
712 +			// Switch to the preloaded version if this is the current song
713 +			if (songs[currentSongIndex].filename === filename && audio.src !== blobUrl) {
714 +				const currentTime = audio.currentTime;
715 +				const wasPlaying = !audio.paused;
716 +				audio.src = blobUrl;
717 +				audio.currentTime = currentTime;
718 +				if (wasPlaying) {
719 +					audio.play().catch(err => {
720 +						// Ignore play interruption errors (expected during source switching)
721 +						if (err.name !== 'AbortError') {
722 +							console.error('Error resuming playback:', err);
723 +						}
724 +					});
725 +				}
726 +			}
727 +
728 +			// Process next priority request
729 +			processPriorityPreload();
730 +		})
731 +		.catch(error => {
732 +			// Ignore abort errors (happens when normal preload finishes first)
733 +			if (error.name === 'AbortError' || error.message.includes('aborted')) {
734 +				// This is expected - normal preloading probably finished first
735 +			} else {
736 +				console.error(`Failed to priority preload ${filename}:`, error);
737 +			}
738 +			processPriorityPreload();
739 +		});
740 +}