Using the PlayerSaving & Progress

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):

ScopeLifetimeStorage
globalUntil the store is resetIn memory
chapterPer chapter idIn memory
pagePer page idIn memory
sessionCurrent visitIn memory
persistentAcross visitslocalStorage

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 panelChange output (fires on every navigation with { panel, chapter, panelId, previousPanelId? } — see Inputs & Outputs).
  • Restore position with the initialChapterId / initialPanelId inputs.

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, optional note. (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:

KeyWritten byContent
pw-variables-persistentVariableStoreServiceJSON of all persistent-scope variables
pw-preferencesPlayerStateServiceReader preferences the reader set explicitly (speech/audio/SFX toggles, autoplay, seconds per panel, reduced motion, …); untouched preferences keep following the work's defaults
pw-socialPlayerShellComponentPer work (meta.id): { liked?, bookmark?: { chapterId, panelId, savedAt } }
pw-age-verifiedPlayerShellComponent{ 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.