Saving Progress
How the PanelWave Player persists state — variable scopes and lifetimes, localStorage keys, and how integrators implement resume.
Two kinds of state matter across a reading session: story variables (what happened in the narrative) and reading position (where the reader is). The player persists variables automatically and keeps a reader-set bookmark on the device; a full "continue where I stopped" resume, or anything that syncs across devices, is a small integrator task built on the shell's inputs and outputs.
Variable scopes and lifetimes
Variables live in the VariableStoreService in five scopes (see Variables for the concept):
| Scope | Lifetime | Storage |
|---|---|---|
global | Until the store is reset | In memory |
chapter | Per chapter id | In memory |
page | Per page id | In memory |
session | Current visit | In memory |
persistent | Across visits | localStorage |
Only the persistent scope survives a reload. Every write to it is immediately serialized to localStorage under the key:
pw-variables-persistent
and loaded back when the store is created (i.e. on the next visit). If localStorage is unavailable (SSR, privacy mode), the store degrades gracefully to in-memory behavior.
// Remember a story decision across visits
player.setVariable('endingUnlocked', true, 'persistent');
// Read it back on any later visit
const unlocked = player.getVariable('endingUnlocked', 'persistent');
Variable definitions from the manifest (variables section) initialize defaults, enforce types/ranges on writes, and mark variables readOnly or visibility: "public" (public ones are editable by readers in the settings modal).
Resetting: VariableStoreService.resetScope('persistent') clears the stored values and removes the localStorage entry; resetAll() wipes every scope and re-applies manifest defaults.
Reading position and resume
The reader's bookmark
Readers can set a bookmark from the toolbar. It is stored on the device per work (localStorage key pw-social) and, the next time the work opens, reading resumes there — as long as the host passes no initialChapterId / initialPanelId. A bookmark pointing at a panel that no longer exists in the manifest is ignored. Every change is emitted as bookmarkChange ({ workId, chapterId, panelId, bookmarked }), so you can store it server-side as well. See Like and bookmark.
Automatic resume
The player does not save the last-read position on its own — only the bookmark the reader sets deliberately. For "continue where you stopped", the host application (or the platform embedding the player, such as the CMS) owns the policy:
- Track position with the
panelChangeoutput (fires on every navigation with{ panel, chapter, panelId, previousPanelId? }— see Inputs & Outputs). - Restore position with the
initialChapterId/initialPanelIdinputs.
Passing an initial position takes precedence over the reader's bookmark. A complete localStorage-based resume implementation:
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { PlayerShellComponent } from '@panelwave/player';
import type { PanelWaveManifest, PlayerPanelChangeEvent } from '@panelwave/player';
interface SavedPosition {
chapterId: string;
panelId: string;
updatedAt: number;
}
@Component({
selector: 'app-reader',
standalone: true,
imports: [PlayerShellComponent],
template: `
@if (manifest) {
<pw-player-shell
[manifest]="manifest"
[initialChapterId]="resume?.chapterId"
[initialPanelId]="resume?.panelId"
(panelChange)="savePosition($event)">
</pw-player-shell>
}
`,
})
export class ReaderComponent implements OnInit {
private readonly workId = 'work-hello-panelwave';
manifest: PanelWaveManifest | null = null;
resume: SavedPosition | null = null;
constructor(private http: HttpClient) {}
ngOnInit(): void {
const raw = localStorage.getItem(`progress:${this.workId}`);
this.resume = raw ? (JSON.parse(raw) as SavedPosition) : null;
this.http
.get<PanelWaveManifest>('assets/my-story/panelwave.json')
.subscribe((m) => (this.manifest = m));
}
savePosition(event: PlayerPanelChangeEvent): void {
const position: SavedPosition = {
chapterId: event.chapter.id,
panelId: event.panelId, // the panel's key in chapter.panels
updatedAt: Date.now(),
};
localStorage.setItem(`progress:${this.workId}`, JSON.stringify(position));
}
}
panelId is part of the payload from player 1.2.0. On 1.1.0 and earlier the event carries only { panel, chapter }, and Panel.id is usually absent — resolve the id by identity instead: Object.entries(event.chapter.panels).find(([, p]) => p === event.panel)?.[0].
If a saved panel was removed in a newer manifest version, navigateToPanel fails and the shell emits error — validate the saved id against the loaded manifest (or fall back to the chapter start) before resuming. A saved panel that is now behind a paywall the reader hasn't unlocked raises the paywall (or age gate) instead of opening.
Helper types for richer progress models
The public API exports two types you can use as the shape of your own progress store — they are data contracts, not built-in persistence:
ReadingProgress—workId,lastChapterId,lastPanelId,panelsViewed: string[],percentComplete,lastUpdated.Bookmark—workId,chapterId,panelId,timestamp, optionalnote. (The built-in toolbar bookmark stores its own, smaller record — see below.)
Server-side progress (sync across devices, accounts) follows the same pattern: post the panelChange payload to your backend and feed the stored position back into the initial-position inputs.
What else touches localStorage
The complete list of localStorage keys in the current library build:
| Key | Written by | Content |
|---|---|---|
pw-variables-persistent | VariableStoreService | JSON of all persistent-scope variables |
pw-preferences | PlayerStateService | Reader preferences the reader set explicitly (speech/audio/SFX toggles, autoplay, seconds per panel, reduced motion, …); untouched preferences keep following the work's defaults |
pw-social | PlayerShellComponent | Per work (meta.id): { liked?, bookmark?: { chapterId, panelId, savedAt } } |
pw-age-verified | PlayerShellComponent | { age, verifiedAt } after the reader passed an age gate on this device |
Tracking consent is held in memory per session. Every read and write is wrapped so that unavailable storage (private mode, quota, SSR) degrades to in-memory behavior — the reader is simply asked again or starts fresh next time.