feat: implement unified AssetLoader service for prioritized image and audio loading
This commit is contained in:
parent
10526f560f
commit
eb3855915c
3 changed files with 335 additions and 20 deletions
|
@ -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 {
|
||||
|
|
|
@ -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
306
src/services/AssetLoader.ts
Normal 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();
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue