Skip to content

GuidePlayback

Remember user preferences

Save volume and caption preferences, restore them after loading, and adapt the pattern for quality selection.

Save preferences from player state to local storage or your backend. Video.js leaves storage and per-profile settings to your application.

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.

<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 '@videojs/html/ui/container';
  import { selectVolume } from '@videojs/html';

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

  // Guard storage access so playback still works when storage is blocked.
  function readSaved() {
    try {
      const value = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null');
      return Number.isFinite(value?.volume) && typeof value?.muted === 'boolean' ? value : 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);
      // Apply saved mute after setVolume, which unmutes above zero.
      if (saved.muted) v?.setMuted(true);
      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. The store batches notifications per microtask, so changes made in the same tick produce one callback. 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, setMuted, selectSubtitlesTrack, selectVideoRendition) rather than poking the media component, 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

  • Video.js does not write preferences to storage. Initial values come from the media component, track markup, and playback engine.
  • setVolume clamps to 0–1 and unmutes above zero. Restore volume first, then call setMuted(true) if the saved preference is muted. Calling setMuted(false) at zero raises the volume to 0.25.
  • The volume examples leave autoplay off. For muted autoplay, defer volume restoration until a user action: restoring a positive volume unmutes the media and can interrupt autoplay, even if you immediately mute it again.
  • setVolume and setMuted update store.state immediately. A React hook’s selected value updates on the next render.
  • Track and rendition lists arrive when the media loads, not when the player is created. Subscribe and restore a saved selection when its entry appears in the list.
  • On iOS Safari, volumeAvailability is 'unsupported' after attachment. Restoring the volume level has no effect there; mute can still be restored.
  • 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 because track ids can change between sources. Save 'off' when captions are off. Restore once when the first caption or subtitle list arrives, then save later selection changes. Do not wait for canPlay. With limited preloading, canPlay can stay false until playback starts. Repeated restoration would override a selection the user already made.

If the saved language is absent from that first list, leave the stored preference intact until the selection changes. This example does not retry restoration when more tracks arrive; adapt the initial loading step if your engine exposes languages in several batches. Browser selection changes after restoration are saved just like user changes.

Apply the same pattern in a store subscription:

  1. Inside store.subscribe, read the track state with selectTextTrack(store.state).
  2. When the first caption or subtitle list arrives, record the current selection and mark restoration complete. If the saved language is present, call selectSubtitlesTrack with that track’s id. If the saved value is 'off', pass null. Do not save during this first pass.
  3. After that, save selection changes. If the saved language was absent from the first list, keep the stored preference until the selection changes.
  4. Guard localStorage reads and writes, and restart restoration for each new source.

Omit default from the tracks when persisting caption preferences. A default track can turn on after restoration, causing the subscription to save its language over a stored 'off' preference.

Remember a quality selection

Adapt the same restore-then-save pattern to the quality feature:

  1. Save 'auto' or the resolution of the selected entry in videoRenditionList. Read selected; activeVideoRendition can change during automatic playback and does not identify a manual preference.
  2. Wait for the next source’s rendition list, then match the saved resolution to an entry. Rendition ids belong to that source, so do not reuse a saved id across sources.
  3. Call selectVideoRendition with the matching entry’s id, or 'auto' if no match exists. Every entry in videoRenditionList has an id; use it directly.
  4. Do not save until restoration finishes; otherwise the source’s default selection overwrites the saved choice. After that, save each change the user makes.

To remember a data-saving limit while preserving automatic switching, persist maxAutoResolution and pass it on the next source. It needs the hls.js engine; native HLS playback ignores it. See Options Video.js normalizes.

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.

Components

API

Guides