Performance
The player's performance machinery — priority-based preloading, LRU image caching with a memory budget, asset variant selection, and memory management.
The player is designed to keep reading fluid on slow networks and modest devices. Three mechanisms carry most of the load: the preload queue, the image cache, and asset variant selection. All class and function names below are real symbols from projects/player/src/lib/.
Preloading
PreloadService maintains a priority queue of PreloadItems ({ id, type: 'image' | 'audio' | 'video', url, priority }) and loads them with bounded concurrency:
- Priorities:
high,medium,low. The queue is re-sorted on every insert; duplicates (already loaded or already queued) are ignored. - Concurrency: default
maxConcurrent = 3, overridable viasetMaxConcurrent(). - Network awareness (on by default,
setNetworkAware(false)to disable): the service reads the Network Information API and adapts —
| Connection | Max concurrent loads |
|---|---|
4g | 5 |
3g | 2 |
2g / slow-2g | 1 |
saveData enabled | 0 — preloading stops and the queue is cleared |
- Idle scheduling:
low-priority items are deferred withrequestIdleCallback(2 s timeout,setTimeoutfallback) so they never compete with rendering. - Prediction:
preloadNext(currentIndex, panelUrls, count = 3)queues the next panels with descending priority (next =high, +2 =medium, +3 =low);predictAndPreload(currentIndex, totalPanels, direction, panelUrls)supportsforward,backward, andauto(forward 3 + backward 1). - Per-type loading: images go through
ImageCacheService.preload(); audio waits forcanplaythrough; video only fetches metadata (video.preload = 'metadata') to keep preloads cheap. - No gated assets (from the release after 1.2.0): an item whose
panelIdthe paywall locks for the reader is skipped, so neighbour warming never fetches artwork ahead of the gate. Once the lock lifts, the next preload call loads it. Items without a panel id, or with one the paywall doesn't know, load as before. - Observability:
status$emitsqueued/loading/loaded/errorper item;getQueueStatus()returns counts.
Image caching
ImageCacheService is an LRU cache for decoded images:
- Decode path:
fetch()→blob→createImageBitmap()where supported (decode off the main thread), falling back to anHTMLImageElementfrom an object URL. - Preload path (from the release after 1.2.0):
preload(url)— whatPreloadServiceuses — warms the image for the<img>that will show it: it loads it through anImageelement (the same request the<img>makes, so it works on storage without CORS headers), waits fordecode(), and keeps the last 48 warmed elements referenced, so the browser's memory cache hands the next<img src>decoded pixels and the panel appears in the frame it mounts. In 1.2.0 preloads went throughfetch()+ImageBitmap, which the<img>never reused and which failed on storage without CORS headers. - Memory budget: 100 MB (
MEMORY_BUDGET), adjustable at runtime withsetMemoryBudget(bytes). Entry size is estimated aswidth × height × 4bytes (RGBA). - LRU mechanics: the backing
Map's insertion order is the recency order — a cache hit re-inserts the entry at the end; eviction pops from the front until the new entry fits. Evicted and removedImageBitmaps are explicitly released withbitmap.close(). - Deduplication: concurrent
load()calls for the same URL share one in-flight promise. - API:
load(url),preload(url),preloadBatch(urls),has(url),remove(url),clear(),getCachedUrls(), andgetStats()(entries, memory usage, budget utilization, in-flight count).
If a single image exceeds the entire budget, the cache logs a warning and clears itself rather than thrashing — keep source images within reasonable dimensions and rely on variants (below) for large artwork.
Asset variant selection
Manifests can list multiple encodings/resolutions per asset (see Assets). The selection helpers live in utils/asset-utils.ts:
selectBestImageVariant(variants, targetWidth, pixelDensity = 1)— computes the required width astargetWidth × pixelDensity, sorts variants by width, and returns the smallest variant that meets or exceeds the requirement, or the largest available if none does. A 500 px slot on a 2× display therefore gets the smallest variant ≥ 1000 px.selectVariantByFormat(variants, preferredFormats)— walks a MIME preference list (e.g.['image/avif', 'image/webp', 'image/jpeg']) and returns the first match, falling back to the first variant.generateSrcSet(variants)— builds asrcsetstring ("hero-400.jpg 400w, hero-800.jpg 800w") for native responsive images.resolveAssetUrl(assetId, assetBase, category)— resolves relative asset references against the category-specific base URLs (imageBase,audioBase,videoBase,pluginsBase, withmediaBaseas the fallback); absolute URLs pass through untouched.pickAssetBase(assetBase, category)returns the base that applies.resolveManifestAssetUrl(src, assetBase, category, manifestUrl)— the full format rule: category base, thenmediaBase, then the URL the manifest was loaded from. Every image, video, poster, thumbnail, character portrait, extras file and plugin URL the player renders or preloads goes through it via the injectableAssetUrlService(resolve(src, category), exported from the package), so a manifest loaded withmanifestUrlcan reference its files relative to itself — an unzipped work archive plays straight from a static host.- Supporting utilities:
isAbsoluteUrl,getAssetFromCatalog,getFileExtension,guessMimeType,isMimeTypeSupported,calculateOptimalDimensions,estimateImageSize.
Variant-by-zoom in every view mode
Variant selection is not a one-time choice at load — the viewport binds a per-panel target width to every layer renderer and re-selects as display conditions change:
- Target width per view mode: panel view uses container width × zoom ×
devicePixelRatio; page view uses the panel placement's fraction of the page width ×devicePixelRatio; canvas view uses placement width × camera zoom ×devicePixelRatio. - Quantized to 256 px steps, so small zoom changes don't retrigger loads.
- Upgrade-only per mounted panel — once a higher-resolution variant is shown, the panel never downgrades back to a thumbnail while mounted; tracked widths are pruned on unmount.
- Settle-based recomputation: target widths recompute immediately on panel/page change, and after a 180 ms settle on zoom or resize — never per frame.
- Look-ahead preloading: in panel view, the player warms the artwork of the current panel's outgoing-edge target panels at the current target width via
PreloadService, honoringsettings.preload(strategynone/panelsAhead,maxConcurrent) and the network-awareness rules above. From the release after 1.2.0 it also warms what comes next in reading order — the next panels (panel view) or the next pages' panels (page view), continuing into the next chapter — each at the rendition it will mount with (priorityhighfor the first two, thenmedium, thenlow), and, while the cover is shown, the first panel or page behind it.
Together with the CMS-generated responsive size ladder in published manifests, this means an overview or phone reader downloads small WebP rungs while a zoomed-in 4K reader gets the full-resolution encode.
Rendering performance
- OnPush everywhere. Every component uses
ChangeDetectionStrategy.OnPush; state flows in through inputs and RxJS subscriptions. - Current panel first. The artwork of the panel on screen loads eagerly with
fetchpriority="high", so it is never queued behind lazily loaded neighbours (this is also what browsers measure as the largest contentful paint). Every other panel's images load lazily. - Viewport culling.
ViewportComponentcan skip off-screen panels in page view (enableViewportCullinginput) and supports lazy image loading (enableLazyLoading, default on). - Metrics output. The viewport emits
performanceMetrics(renderTime,panelCount,visiblePanelCount,culledPanelCount,transformCalculationTime) so hosts can watch real render cost. - Visibility-driven media.
VisibilityService(anIntersectionObserverwrapper with a 50% threshold,VISIBILITY_THRESHOLD = 0.5) gates page-view video playback, andUserGestureServiceattaches its global listeners outside the Angular zone to avoid change-detection churn on every pointer event.
Video and audio costs
- Preloading fetches video metadata only; full video data streams on demand.
VideoControllerServiceallows any number of muted videos but at most one unmuted video at a time, preempting the previous one — bounding both audio chaos and decode load.VideoSequencerServiceplays page-view videos sequentially (one panel-slot at a time) instead of all at once, and skips stalled entries after a configurable timeout (DEFAULT_STALL_TIMEOUT_MS = 10_000).AudioEngineService.cleanup()disconnects source nodes and emptiesHTMLAudioElements (src = ''+load()) when tracks stop, anddestroy()closes theAudioContext.
Memory checklist for contributors
When adding features, keep these invariants:
- Decoded images are owned by
ImageCacheService— do not hold long-lived references toImageBitmaps elsewhere, and never bypass the budget. - Anything you
observe()(visibility) or register (sequencer, controller) must be unobserved/unregistered inngOnDestroy. - Subscriptions in components follow the
takeUntil(destroy$)pattern. - Respect the user's constraints:
saveDatadisables preloading, andreducedMotion(input orprefers-reduced-motion) degrades videoon-viewautoplay toon-click.
Related: Player Architecture, Core Services, Configuration.