# Remember user preferences

Persist volume, caption, and quality preferences across sessions by subscribing to player state.

The player doesn’t persist preferences itself today. Player state gives you everything needed to wire persistence to the storage you choose — local storage, your backend, or per-profile settings.

## Recommended approach

Subscribe to the state you want to remember, write it to storage when it changes, and restore it once the store attaches to the media.

```html
<video-player>
  <media-container>
    <video src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4" playsinline></video>
  </media-container>
</video-player>
<script type="module">
  import '@videojs/html/video/player';
  import { selectVolume } from '@videojs/html';

  const STORAGE_KEY = 'player:volume';
  const player = document.querySelector('video-player');
  const store = player.store;

  // Local storage can be unavailable (private modes), so guard reads and writes.
  function readSaved() {
    try {
      return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null');
    } catch {
      return null;
    }
  }

  function save(prefs) {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
    } catch {
      // Storage unavailable; skip persistence.
    }
  }

  // Restore once the store attaches to the media; actions throw before that.
  const saved = readSaved();
  if (saved) {
    const restore = () => {
      if (!store.target) return false;
      const v = selectVolume(store.state);
      v?.setVolume(saved.volume);
      // setVolume above zero unmutes, so mute again if that's the saved preference.
      // At volume zero the player already counts as muted, and toggling would unmute.
      if (v && saved.muted && saved.volume > 0) v.toggleMuted();
      return true;
    };
    if (!restore()) {
      const unsubscribe = store.subscribe(() => {
        if (restore()) unsubscribe();
      });
    }
  }

  // Save on change. subscribe() fires on any state change, so diff what you care about.
  let last = saved ?? {};
  store.subscribe(() => {
    const v = selectVolume(store.state);
    if (!v || (v.volume === last.volume && v.muted === last.muted)) return;
    last = { volume: v.volume, muted: v.muted };
    save(last);
  });
</script>
```

## How it works

- Player state is the single source of truth: it already reflects every change, whatever caused it — your UI, keyboard shortcuts, native controls, or scripts.
- `store.subscribe(callback)` fires on any state change. Notifications are batched per microtask, so rapid changes (a volume drag) produce one callback per tick, which keeps storage writes cheap. There is no per-key subscription on the store: read the state and diff the slice you care about.
- Feature selectors exported by `@videojs/html` (`selectVolume`, `selectTextTrack`, `selectQuality`, and the rest) read a feature’s slice from any store, returning `undefined` when the feature isn’t present.
- Restore through the feature actions (`setVolume`, `toggleMuted`, `selectSubtitlesTrack`, `selectVideoRendition`) rather than poking the media element, so state and UI stay consistent.
- Actions need an attached media target: calling one before the store attaches throws. Check `store.target` and defer the restore until it is set, as the examples do.

## Availability and constraints

- Nothing persists by default: volume resets to the media element’s value, captions to the track markup, quality to automatic, on every load.
- `setVolume` clamps to 0–1 and unmutes when setting a value above zero; restore volume first, then toggle `muted` back on when the saved preference is muted, as the examples do. Skip the toggle when the saved volume is zero: the player treats volume zero as muted, so toggling would unmute and raise the volume. Don’t compare against store state right after calling an action: notifications batch per microtask, so that state is still stale.
- Restore track and quality selections after the media exposes them: track and rendition lists arrive when the media loads, not at player creation. Subscribe and apply when the saved entry appears in the list.
- Programmatic volume control is unavailable on some platforms (after attach, iOS Safari reports `volumeAvailability: 'unsupported'`); volume persistence quietly does nothing there.
- Local storage is per-origin and can be unavailable (private modes, embedded contexts); guard reads and writes accordingly.

## Common variations

### Persist caption preference

Save the showing track’s `language` (not its generated id, which can change between sources), or `'off'` when the user turns captions off. Restoring is two phases: while the media loads, assert the saved preference whenever the track modes drift from it, because browsers can auto-enable a track of their own during loading. Once the media can play (`canPlay`), treat every change as the user’s choice and save it. A restore that re-runs forever would keep overriding the user; one that runs only once can get overridden by the browser.

Apply the same pattern with `selectTextTrack(store.state)` inside `store.subscribe`: until `canPlay` is true, re-apply the saved preference by passing the matching track’s `id` (or `'off'`) to `selectSubtitlesTrack` whenever the modes drift; afterward, save the showing track’s `language`, or `'off'` when none is showing, on every change.

### Persist to your backend

Swap `localStorage` for an API call; the subscription pattern is identical. Debounce writes beyond the built-in microtask batching if your storage is remote.

## Troubleshooting

### The saved volume doesn’t apply

Restore runs before the store attaches, or the platform doesn’t allow programmatic volume (check `volumeAvailability`). Apply after `store.target` is set, as in the examples.

### The saved caption track doesn’t select

The track list wasn’t loaded yet when you restored, or you saved a generated track id instead of a stable property like `language`. React to `textTrackList` changes and match on language.

### Storage writes fire constantly while dragging the volume slider

Notifications batch per microtask, but a drag still produces many ticks. Debounce the write if the churn matters for your storage target.

## Related pages

### Components

- [media-volume-slider](../reference/components/volume-slider.md): A slider component for controlling media playback volume
- [media-captions-radio-group](../reference/components/captions-radio-group.md): A menu radio group for selecting caption and subtitle tracks
- [media-quality-radio-group](../reference/components/quality-radio-group.md): A menu radio group for selecting video quality

### API

- [PlayerController](../reference/api/player-controller.md): Reactive controller for accessing player store state in HTML custom elements
- [StoreController](../reference/api/store-controller.md): Reactive controller for subscribing to store state in HTML custom elements
- [Volume](../reference/api/feature-volume.md): Volume level and mute state for the player store
- [Text tracks](../reference/api/feature-text-tracks.md): Subtitles, captions, and chapter track state for the player store

### Guides

- [Show captions and subtitles](./captions.md): Show captions and subtitles, and let users turn them on and pick a language.
- [Add a quality selector](./quality.md): Read available renditions, let the engine adapt automatically, and offer manual quality selection.