feat: implement unified AssetLoader service for prioritized image and audio loading

This commit is contained in:
Tomáš Mládek 2025-07-28 11:03:00 +02:00
parent 10526f560f
commit eb3855915c
3 changed files with 335 additions and 20 deletions

View file

@ -5,7 +5,7 @@
<script lang="ts">
import { defineComponent, PropType, ref, watch } from "vue";
import { BoundingBox } from "@/components/SVGContent.vue";
import { queueAudioForLoading } from "@/services/AudioLoader";
import { queueAudioForLoading } from "@/services/AssetLoader";
export default defineComponent({
name: "AudioArea",
@ -23,6 +23,7 @@ export default defineComponent({
const audio = ref<HTMLAudioElement | null>(null);
const audioSrc = ref<string>(""); // Ref to hold audio source after preloading
const isPreloaded = ref<boolean>(false);
const distance = ref<number>(0);
console.debug(`[AUDIOAREA] Initializing ${props.definition.src}...`);
console.debug(props.definition);
@ -31,24 +32,23 @@ export default defineComponent({
// Use the global audio loading queue to throttle concurrent loads
const preloadAudio = (src: string) => {
console.debug(`[AUDIOAREA] Queueing audio for preload: ${src}`);
// Set audioSrc to empty initially
audioSrc.value = "";
// Queue the audio for loading through our global service
// Our improved AudioLoader will cache and deduplicate requests
queueAudioForLoading(src)
.then((blobUrl) => {
queueAudioForLoading(
src,
() => distance.value,
(blobUrl) => {
// Use blob URL to avoid keeping connections open
audioSrc.value = blobUrl;
isPreloaded.value = true;
console.debug(`[AUDIOAREA] Successfully preloaded audio: ${src}`);
})
.catch((error) => {
},
(error) => {
console.error(`[AUDIOAREA] Error preloading audio: ${error}`);
// Fall back to original source if preloading fails
audioSrc.value = src;
});
}
);
};
// Start preloading when component is created
@ -64,12 +64,12 @@ export default defineComponent({
const x = props.bbox.x + props.bbox.w / 2;
const y = props.bbox.y + props.bbox.h / 2;
const distance = Math.sqrt(
Math.pow(x - props.definition.cx, 2) +
Math.pow(y - props.definition.cy, 2)
distance.value = Math.hypot(
x - props.definition.cx,
y - props.definition.cy
);
if (distance < props.definition.radius) {
if (distance.value < props.definition.radius) {
if (audio.value!.paused) {
console.debug(
`[AUDIOAREA] Entered audio area "${props.definition.src}", starting playback...`
@ -77,7 +77,7 @@ export default defineComponent({
audio.value!.play();
}
const volume =
(props.definition.radius - distance) / props.definition.radius;
(props.definition.radius - distance.value) / props.definition.radius;
audio.value!.volume =
volume * (props.bbox.z < 1 ? props.bbox.z * vol_x + vol_b : 1);
} else {

View file

@ -18,7 +18,7 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { rotate } from "@/utils";
import { queueImageForLoading } from "@/services/ImageLoader";
import { queueImageForLoading } from "@/services/AssetLoader";
export default defineComponent({
name: "VideoScroll",
@ -97,10 +97,19 @@ export default defineComponent({
element.classList.add("visible");
if (!element.src && element.dataset.src) {
// Queue the image for loading through the global service
const self = this;
queueImageForLoading(element, function() {
self.handleImageLoad(element);
});
// Calculate the image's position to prioritize closer images
queueImageForLoading(
element,
() => {
return Math.hypot(
element.getBoundingClientRect().left - window.innerWidth / 2,
element.getBoundingClientRect().top - window.innerHeight / 2
);
},
() => {
this.handleImageLoad(element);
}
);
// Add a fallback to show the image after a timeout even if not fully loaded
setTimeout(() => {

306
src/services/AssetLoader.ts Normal file
View file

@ -0,0 +1,306 @@
/**
* Unified Asset Loader service that handles both image and audio loading
* Uses a priority queue to prioritize assets based on type and proximity
*/
// Configuration
const MAX_CONCURRENT_IMAGE_LOADS = 5;
const MAX_CONCURRENT_AUDIO_LOADS = 1;
const RETRY_DELAY_MS = 200;
// Types
export enum AssetType {
IMAGE = "image",
AUDIO = "audio",
}
export interface ImageAsset {
type: AssetType.IMAGE;
element: HTMLImageElement;
getDistance: () => number;
onComplete: () => void;
}
export interface AudioAsset {
type: AssetType.AUDIO;
src: string;
getDistance: () => number;
onComplete: (blobUrl: string) => void;
onError: (error: Error) => void;
}
type Asset = ImageAsset | AudioAsset;
// State
let activeImageLoads = 0;
let activeAudioLoads = 0;
const assetQueue: Asset[] = [];
const loadedAudioCache: Record<string, string> = {};
const pendingAudioLoads: Set<string> = new Set();
/**
* Check if there are any assets queued or actively loading
*/
export function hasQueuedAssets(): boolean {
return assetQueue.length > 0 || activeImageLoads > 0 || activeAudioLoads > 0;
}
/**
* Check if there are any images queued or actively loading
*/
export function hasQueuedImages(): boolean {
return (
assetQueue.some((asset) => asset.type === AssetType.IMAGE) ||
activeImageLoads > 0
);
}
/**
* Queue an image for loading, respecting the global concurrent loading limit
*/
export function queueImageForLoading(
element: HTMLImageElement,
getDistance: () => number,
onComplete?: () => void
) {
if (!element.dataset.src) {
console.warn("[AssetLoader] Element has no data-src attribute");
return;
}
// Add to queue
assetQueue.push({
type: AssetType.IMAGE,
element,
onComplete: onComplete || (() => {}),
getDistance,
});
// Try to process queue
processQueue();
}
/**
* Queue an audio file for loading, respecting the global concurrent loading limit
* Returns a promise that resolves with the blob URL when loading is complete
*/
export function queueAudioForLoading(
src: string,
getDistance: () => number,
onComplete?: (blobUrl: string) => void,
onError?: (error: Error) => void
): Promise<string> {
// Return cached result immediately if available
if (loadedAudioCache[src]) {
console.debug(`[AssetLoader] Using cached audio for ${src}`);
const blobUrl = loadedAudioCache[src];
if (onComplete) setTimeout(() => onComplete(blobUrl), 0);
return Promise.resolve(blobUrl);
}
// Create promise for this request
return new Promise((resolve, reject) => {
// If this source is already being loaded, add to queue with same source
// It will be grouped during loading
const asset: AudioAsset = {
type: AssetType.AUDIO,
src,
onComplete: (blobUrl) => {
if (onComplete) onComplete(blobUrl);
resolve(blobUrl);
},
onError: (error) => {
if (onError) onError(error);
reject(error);
},
getDistance,
};
// Add to queue
assetQueue.push(asset);
// Track pending loads
if (!pendingAudioLoads.has(src)) {
pendingAudioLoads.add(src);
}
// Try to process queue
processQueue();
});
}
/**
* Sort the queue based on priority
*/
function sortQueue(): void {
assetQueue.sort((a, b) => {
return a.getDistance() - b.getDistance();
});
}
/**
* Process the next items in the queue if we have capacity
*/
function processQueue(): void {
sortQueue();
// First, attempt to process image assets
while (
activeImageLoads < MAX_CONCURRENT_IMAGE_LOADS &&
assetQueue.length > 0
) {
// Look for the highest priority image
const imageIndex = assetQueue.findIndex(
(asset) => asset.type === AssetType.IMAGE
);
if (imageIndex === -1) break; // No more images in the queue
const asset = assetQueue.splice(imageIndex, 1)[0] as ImageAsset;
loadImage(asset.element, asset.onComplete);
}
// Only process audio if we have capacity and no images are prioritized
if (hasQueuedImages()) {
// Schedule a retry after a delay to check again for audio processing
setTimeout(processQueue, RETRY_DELAY_MS);
return;
}
// Process audio assets
const pendingAudios: Record<
string,
Array<{
onComplete: (blobUrl: string) => void;
onError: (error: Error) => void;
}>
> = {};
// Continue loading audio while respecting MAX_CONCURRENT_AUDIO_LOADS
while (
activeAudioLoads < MAX_CONCURRENT_AUDIO_LOADS &&
assetQueue.length > 0
) {
const audioIndex = assetQueue.findIndex(
(asset) => asset.type === AssetType.AUDIO
);
if (audioIndex === -1) break; // No more audio in the queue
const asset = assetQueue.splice(audioIndex, 1)[0] as AudioAsset;
// If already cached, complete immediately without consuming a slot
if (loadedAudioCache[asset.src]) {
asset.onComplete(loadedAudioCache[asset.src]);
continue;
}
// Group by src to avoid duplicate loads
if (!pendingAudios[asset.src]) {
pendingAudios[asset.src] = [];
activeAudioLoads++;
}
pendingAudios[asset.src].push({
onComplete: asset.onComplete,
onError: asset.onError,
});
}
// Start loading each unique audio file
Object.entries(pendingAudios).forEach(([src, handlers]) => {
loadAudio(src, handlers);
});
// If we have more items in the queue, schedule another check
if (assetQueue.length > 0) {
setTimeout(processQueue, RETRY_DELAY_MS);
}
}
/**
* Internal function to handle the actual image loading
*/
function loadImage(element: HTMLImageElement, onComplete: () => void) {
// Increment active loads counter
activeImageLoads++;
const src = element.dataset.src;
console.debug(`[AssetLoader] Loading image ${src}`);
// Start loading the image
element.src = src!;
// Handle load completion
const handleCompletion = () => {
activeImageLoads--;
onComplete();
processQueue();
};
// Set handlers
element.onload = () => {
console.debug(`[AssetLoader] Loaded image ${src}`);
handleCompletion();
};
element.onerror = () => {
console.error(`[AssetLoader] Failed to load image ${src}`);
handleCompletion();
};
}
/**
* Internal function to handle the actual audio loading using fetch API
* This ensures the entire audio file is downloaded
* @param src The source URL to load
* @param handlers Array of handlers to call when loading completes or fails
*/
async function loadAudio(
src: string,
handlers: Array<{
onComplete: (blobUrl: string) => void;
onError: (error: Error) => void;
}>
) {
console.debug(
`[AssetLoader] Loading audio ${src} (${handlers.length} listeners)`
);
try {
// Fetch the entire audio file
const response = await fetch(src);
if (!response.ok) {
throw new Error(`Failed to load audio: ${response.statusText}`);
}
// Convert to blob to ensure full download
const blob = await response.blob();
// Create a blob URL to use as the audio source
const blobUrl = URL.createObjectURL(blob);
// Store in cache
loadedAudioCache[src] = blobUrl;
console.debug(`[AssetLoader] Successfully loaded audio ${src}`);
// Call all completion handlers
handlers.forEach((handler) => handler.onComplete(blobUrl));
} catch (error) {
console.error(`[AssetLoader] Error loading audio: ${error}`);
// Call all error handlers
handlers.forEach((handler) => handler.onError(error as Error));
} finally {
// Remove from pending loads
pendingAudioLoads.delete(src);
// Decrement counter when audio is loaded (or failed)
activeAudioLoads--;
// Process next asset in queue if available
processQueue();
}
}