Unified Asset Loading Examples
This document demonstrates how to use the new unified asset loading architecture that brings images and audio in line with the beat loading pattern.
Core Concept
All assets now support the same "ask for it immediately, if not available then wait for it" pattern:
// Check cache first
if let asset = contentManager.getAsset(id) {
// Use immediately
} else {
// Load with async/await
asset = try await contentManager.loadAsset(id)
}
Asset Criticality Levels
Optional (Default)
Progressive loading without blocking navigation - current image behavior.
// Images load progressively in background
// Navigation proceeds without waiting
contentManager.setImageCriticality(.optional, for: "background_image.jpg")
Important
Aggressive preloading but doesn't block navigation.
// Preloaded with higher priority
// Navigation still proceeds if not ready
contentManager.setImageCriticality(.important, for: "character_portrait.jpg")
Critical
Blocks navigation until loaded - same as beat behavior.
// Navigation waits for this to load
// Shows "Loading content..." until ready
contentManager.setImageCriticality(.critical, for: "essential_choice_image.jpg")
contentManager.setAudioCriticality(.critical, for: "narrative_critical_sound.mp3")
Example Usage Patterns
1. Critical Choice Images
For images essential to story decisions:
// Mark choice images as critical
func configureChoiceImages() {
for beat in story.beats {
for page in beat.pages {
if page is ChoicePageType, let imageUrl = page.imageUrl {
contentManager.setImageCriticality(.critical, for: imageUrl)
}
}
}
}
// Navigation will now block until choice images are loaded
await changePage(to: choiceLocation)
// -> Shows "Loading content..." if image not ready
// -> Loads critical image
// -> Proceeds when ready
2. Immediate Audio Availability
For tap-triggered sounds or critical audio:
// Check if audio is ready before playing
func playTapSound(url: String) async {
if await contentManager.isAudioLoaded(url) {
// Play immediately
AudioManager.shared.playSoundEffect(named: url)
} else {
// Load first, then play
do {
_ = try await contentManager.loadAudio(url)
AudioManager.shared.playSoundEffect(named: url)
} catch {
AppLogger.debug("Failed to load tap audio: \(error)")
}
}
}
3. Smart Page Enter Audio
Audio respects criticality when triggered:
// In StoryReaderViewModel.checkAndTriggerSoundTools()
if trigger == "page_enter" {
if await contentManager.isAudioLoaded(url) {
// Play immediately if available
AudioManager.shared.playSoundEffect(named: url)
} else {
let criticality = contentManager.getAudioCriticality(for: url)
if criticality == .critical {
// Load critical audio and play
_ = try await contentManager.loadAudio(url)
AudioManager.shared.playSoundEffect(named: url)
} else {
// Skip non-critical audio if not ready
AppLogger.debug("Skipping non-critical audio: \(url)")
}
}
}
4. Unified Image Components
Use UnifiedAssetImageView for automatic criticality handling:
// Automatically respects criticality configuration
UnifiedAssetImageView(
imageUrl: page.imageUrl,
contentManager: viewModel.contentManager,
size: imageSize,
processing: .storyImage
)
// Critical images: Shows loading state, blocks until ready
// Optional images: Progressive loading with CachedAsyncImage
Configuration Examples
Story-Level Configuration
func configureStoryAssets() {
// Critical: Essential choice images
contentManager.setImageCriticality(.critical, for: "choice_path_split.jpg")
// Important: Character portraits (preload aggressively)
contentManager.setImageCriticality(.important, for: "red_riding_hood.jpg")
contentManager.setImageCriticality(.important, for: "grandmother.jpg")
// Critical: Narrative-essential audio
contentManager.setAudioCriticality(.critical, for: "wolf_howl.mp3")
contentManager.setAudioCriticality(.critical, for: "door_creak.mp3")
// Optional: Ambient sounds (nice to have)
contentManager.setAudioCriticality(.optional, for: "forest_ambience.mp3")
}
Page-Type Based Configuration
func configureByPageType(beat: PajamaScriptStoryBeat) {
for page in beat.pages {
// Critical images for choice pages
if page is PajamaScriptChoicePage, let imageUrl = page.imageUrl {
contentManager.setImageCriticality(.critical, for: imageUrl)
}
// Important images for story pages
if page is PajamaScriptTextPage, let imageUrl = page.imageUrl {
contentManager.setImageCriticality(.important, for: imageUrl)
}
// Configure audio based on triggers
if let tools = page.tools {
for tool in tools where tool.name == "sound_effect" {
if let soundUrl = tool.args?["sound_url"]?.stringValue,
let trigger = tool.args?["trigger"]?.stringValue {
switch trigger {
case "page_enter":
// Critical for narrative sounds
contentManager.setAudioCriticality(.critical, for: soundUrl)
case "tap":
// Important for interactive sounds
contentManager.setAudioCriticality(.important, for: soundUrl)
default:
// Optional for other triggers
contentManager.setAudioCriticality(.optional, for: soundUrl)
}
}
}
}
}
}
Navigation Integration
The system automatically integrates with navigation:
// In StoryReaderViewModel.changePage()
if await !contentManager.canNavigateToLocation(nextLocation) {
// Shows unified loading UI
await loadingStateManager.executeWithRetry(
description: "Loading content...",
beatId: nextLocation.beatId
) {
// Loads all critical assets for location
try await contentManager.loadAssetsForLocation(nextLocation, priority: .high)
}
}
// Proceeds with navigation when ready
await pageController?.executeNavigation(to: nextLocation, ...)
Benefits
1. Consistent Behavior
All asset types follow the same loading pattern as beats.
2. Configurable Blocking
Choose which assets are worth waiting for vs progressive loading.
3. Unified Loading UI
Same loading indicators for beats, images, and audio.
4. Better UX
- Critical assets: Guaranteed availability when needed
- Optional assets: Don't block user flow
- Immediate availability checks: No failed audio triggers
5. Future Extensibility
Easy to add new asset types with same pattern:
// Video assets
func getVideo(_ url: String) async -> AVAsset?
func loadVideo(_ url: String) async throws -> AVAsset
// 3D models
func getModel(_ url: String) async -> SCNNode?
func loadModel(_ url: String) async throws -> SCNNode
Migration Notes
From Old Pattern
// Old: Fire-and-forget audio
AudioManager.shared.preloadSounds(names: audioUrls, soundType: .effect)
// Hope it's ready later...
AudioManager.shared.playSoundEffect(named: url) // Might fail silently
// New: Explicit availability checks
if await contentManager.isAudioLoaded(url) {
AudioManager.shared.playSoundEffect(named: url)
} else {
_ = try await contentManager.loadAudio(url)
AudioManager.shared.playSoundEffect(named: url)
}
From CachedAsyncImage
// Old: Always progressive loading
CachedAsyncImage(url: url, imageLoader: service) { image in
// Display when ready
} placeholder: {
// Loading state
}
// New: Respects criticality
UnifiedAssetImageView(
imageUrl: url,
contentManager: contentManager,
size: size,
processing: .storyImage
)
// Critical: Blocks until loaded
// Optional: Progressive loading (same as before)
Performance Characteristics
Loading Performance
- Critical assets: Block navigation but guarantee availability
- Important assets: Preloaded aggressively without blocking
- Optional assets: Load progressively without blocking
Memory Management
- Same efficient caching as before
- Unified cache coordination across asset types
- Proper cancellation support for all assets
Network Optimization
- Concurrent loading of critical assets
- Priority-based loading (critical > important > optional)
- Request deduplication across asset types
The unified asset loading architecture provides the exact "ask for it immediately, if not available then wait for it" pattern you envisioned, with configurable criticality to balance performance and user experience.