Skip to content

Commit b397859

Browse files
committed
feat(player): new storageKey player prop
1 parent 1b7389c commit b397859

9 files changed

Lines changed: 199 additions & 11 deletions

File tree

packages/vidstack/mangle.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -628,5 +628,10 @@
628628
"_getEndTime": "Hk",
629629
"_appendMediaFragment": "Ik",
630630
"_clipEnded": "Kk",
631-
"_pausedSeeking": "Jk"
631+
"_pausedSeeking": "Jk",
632+
"_attachLoadPlayListener": "Mk",
633+
"_handleLoadPlayStrategy": "Lk",
634+
"_onLoadPoster": "Ok",
635+
"_onPreconnect": "Pk",
636+
"_saveTime": "Nk"
632637
}

packages/vidstack/src/components/player.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { MediaPlayerDelegate } from '../core/state/media-player-delegate';
5151
import { MediaRequestContext, MediaRequestManager } from '../core/state/media-request-manager';
5252
import { MediaStateManager } from '../core/state/media-state-manager';
5353
import { MediaStateSync } from '../core/state/media-state-sync';
54+
import { MediaStorage } from '../core/storage';
5455
import { TextTrackSymbol } from '../core/tracks/text/symbols';
5556
import { canFullscreen } from '../foundation/fullscreen/controller';
5657
import { Logger } from '../foundation/logger/controller';
@@ -134,11 +135,15 @@ export class MediaPlayer
134135

135136
new MediaStateSync();
136137

138+
const mediaStorageKey = computed(this._computeMediaKey.bind(this)),
139+
storage = new MediaStorage(this.$props.storageKey, mediaStorageKey);
140+
137141
const context = {
138142
player: this,
139143
scope: getScope(),
140144
qualities: new VideoQualityList(),
141145
audioTracks: new AudioTrackList(),
146+
storage,
142147
$provider: signal<MediaProvider | null>(null),
143148
$providerSetup: signal(false),
144149
$props: this.$props,
@@ -156,7 +161,7 @@ export class MediaPlayer
156161
context.remote = new MediaRemoteControl(__DEV__ ? context.logger : undefined);
157162
context.remote.setPlayer(this);
158163
context.$iosControls = computed(this._isIOSControls.bind(this));
159-
context.textTracks = new TextTrackList();
164+
context.textTracks = new TextTrackList(storage);
160165
context.textTracks[TextTrackSymbol._crossorigin] = this.$state.crossorigin;
161166
context.textRenderers = new TextRenderers(context);
162167
context.ariaKeys = {};
@@ -243,6 +248,14 @@ export class MediaPlayer
243248
this.canPlayQueue._reset();
244249
}
245250

251+
private _computeMediaKey() {
252+
const { storageKey, clipStartTime, clipEndTime } = this.$props,
253+
{ source } = this.$state;
254+
return storageKey() && source().src
255+
? `${storageKey()}:${source().src}:${clipStartTime()}:${clipEndTime()}`
256+
: null;
257+
}
258+
246259
private _skipTitleUpdate = false;
247260
private _watchTitle() {
248261
if (this._skipTitleUpdate) {

packages/vidstack/src/core/api/media-context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { MediaKeyShortcuts } from '../keyboard/types';
1414
import type { VideoQualityList } from '../quality/video-quality';
1515
import type { MediaPlayerDelegate } from '../state/media-player-delegate';
1616
import type { MediaRemoteControl } from '../state/remote-control';
17+
import type { MediaStorage } from '../storage';
1718
import type { AudioTrackList } from '../tracks/audio-tracks';
1819
import type { TextRenderers } from '../tracks/text/render/text-renderer';
1920
import type { TextTrackList } from '../tracks/text/text-tracks';
@@ -23,6 +24,7 @@ import type { PlayerStore } from './player-state';
2324
export interface MediaContext {
2425
player: MediaPlayer;
2526
scope: Scope;
27+
storage: MediaStorage;
2628
remote: MediaRemoteControl;
2729
delegate: MediaPlayerDelegate;
2830
qualities: VideoQualityList;

packages/vidstack/src/core/api/player-props.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export const mediaPlayerProps: MediaPlayerProps = {
3636
keyDisabled: false,
3737
keyTarget: 'player',
3838
keyShortcuts: MEDIA_KEY_SHORTCUTS,
39+
storageKey: null,
3940
};
4041

4142
export interface MediaStateAccessors
@@ -190,4 +191,9 @@ export interface MediaPlayerProps
190191
* ```
191192
*/
192193
keyShortcuts: MediaKeyShortcuts;
194+
/**
195+
* Determines whether volume, time, and captions settings should be saved to local storage
196+
* and used when initializing media.
197+
*/
198+
storageKey: string | null;
193199
}

packages/vidstack/src/core/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type { MediaPlayerProps, MediaStateAccessors, PlayerSrc } from './api/pla
99
export type { MediaPlayerEvents } from './api/player-events';
1010
export { MediaRemoteControl } from './state/remote-control';
1111
export { MediaControls } from './controls';
12+
export type { MediaStorage } from './storage';
1213
export * from './tracks/text/render/text-renderer';
1314
export * from './tracks/text/render/libass-text-renderer';
1415
export * from './tracks/text/text-track';

packages/vidstack/src/core/state/media-player-delegate.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,15 @@ export class MediaPlayerDelegate {
6161
}
6262

6363
const provider = peek(this._media.$provider),
64-
{ muted, volume, playsinline, clipStartTime } = this._media.$props;
64+
{ storage } = this._media,
65+
{ muted, volume, playsinline, clipStartTime } = this._media.$props,
66+
startTime = storage.data.time ?? clipStartTime();
6567

6668
if (provider) {
67-
provider.setVolume(peek(volume));
68-
provider.setMuted(peek(muted));
69+
provider.setVolume(storage.data.volume ?? peek(volume));
70+
provider.setMuted(storage.data.muted ?? peek(muted));
6971
provider.setPlaysinline?.(peek(playsinline));
70-
if (peek(clipStartTime) > 0) provider.setCurrentTime(peek(clipStartTime));
72+
if (startTime > 0) provider.setCurrentTime(startTime);
7173
}
7274

7375
if ($state.canPlay() && $state.autoplay() && !$state.started()) {

packages/vidstack/src/core/state/media-state-manager.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -596,14 +596,30 @@ export class MediaStateManager extends MediaPlayerController {
596596
this._clipEnded = true;
597597
this.dispatch('media-pause-request', { trigger: event });
598598
}
599+
600+
this._saveTime();
601+
}
602+
603+
private _saveTime() {
604+
const { storage } = this._media,
605+
{ canPlay, realCurrentTime } = this.$state;
606+
607+
if (!canPlay()) return;
608+
609+
storage.time = realCurrentTime();
599610
}
600611

601612
['volume-change'](event: ME.MediaVolumeChangeEvent) {
602-
const { volume, muted } = this.$state,
613+
const { storage } = this._media,
614+
{ volume, muted } = this.$state,
603615
detail = event.detail;
616+
604617
volume.set(detail.volume);
605618
muted.set(detail.muted || detail.volume === 0);
606619
this._satisfyRequest('volume', event);
620+
621+
storage.volume = volume();
622+
storage.muted = muted();
607623
}
608624

609625
['seeking'] = throttle(
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import throttle from 'just-throttle';
2+
import { effect, type ReadSignal } from 'maverick.js';
3+
import { noop } from 'maverick.js/std';
4+
5+
export class MediaStorage {
6+
private _data!: SavedMediaData;
7+
8+
constructor(
9+
private _playerKey: ReadSignal<string | null>,
10+
private _mediaKey: ReadSignal<string | null>,
11+
) {
12+
effect(this._load.bind(this));
13+
}
14+
15+
get playerKey() {
16+
return this._playerKey();
17+
}
18+
19+
get mediaKey() {
20+
return this._mediaKey();
21+
}
22+
23+
get data(): Readonly<SavedMediaData> {
24+
return this._data;
25+
}
26+
27+
set volume(volume: number) {
28+
this._data.volume = volume;
29+
this._save();
30+
}
31+
32+
set muted(muted: boolean) {
33+
this._data.muted = muted;
34+
this._save();
35+
}
36+
37+
set time(time: number) {
38+
this._data.time = time;
39+
this._saveTime();
40+
}
41+
42+
set lang(lang: string | null) {
43+
this._data.lang = lang;
44+
this._save();
45+
}
46+
47+
set captions(enabled: boolean) {
48+
this._data.captions = enabled;
49+
this._save();
50+
}
51+
52+
private _load() {
53+
const playerKey = this._playerKey(),
54+
mediaKey = this._mediaKey(),
55+
savedData = playerKey ? localStorage.getItem(playerKey) : null,
56+
savedTime = mediaKey ? localStorage.getItem(mediaKey) : null;
57+
58+
this._data = {
59+
volume: null,
60+
muted: null,
61+
lang: null,
62+
captions: null,
63+
...(savedData ? JSON.parse(savedData) : {}),
64+
time: savedTime ? +savedTime : null,
65+
};
66+
}
67+
68+
private _save() {
69+
if (__SERVER__) return;
70+
71+
const playerKey = this._playerKey();
72+
if (!playerKey) return;
73+
74+
const data = JSON.stringify({ ...this._data, time: undefined });
75+
localStorage.setItem(playerKey, data);
76+
}
77+
78+
private _saveTime = __SERVER__
79+
? noop
80+
: throttle(() => {
81+
const mediaKey = this._mediaKey();
82+
if (!mediaKey) return;
83+
const data = this._data.time + '';
84+
localStorage.setItem(mediaKey, data);
85+
}, 1000);
86+
}
87+
88+
export interface SavedMediaData {
89+
volume: number | null;
90+
muted: boolean | null;
91+
time: number | null;
92+
lang: string | null;
93+
captions: boolean | null;
94+
}

packages/vidstack/src/core/tracks/text/text-tracks.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import debounce from 'just-debounce-it';
12
import { DOMEvent } from 'maverick.js/std';
23

34
import { List, type ListReadonlyChangeEvent } from '../../../foundation/list/list';
45
import { ListSymbol } from '../../../foundation/list/symbols';
6+
import type { MediaStorage } from '../../storage';
57
import { TextTrackSymbol } from './symbols';
68
import {
79
isTrackCaptionKind,
@@ -16,29 +18,47 @@ import {
1618
export class TextTrackList extends List<TextTrack, TextTrackListEvents> {
1719
private _canLoad = false;
1820
private _defaults: Record<string, TextTrack | undefined> = {};
21+
private _preferredLang: string | null = null;
1922

2023
/** @internal */
2124
[TextTrackSymbol._crossorigin]?: () => string | null;
2225

26+
constructor(private readonly _storage?: MediaStorage) {
27+
super();
28+
}
29+
2330
get selected() {
2431
const track = this._items.find((t) => t.mode === 'showing' && isTrackCaptionKind(t));
2532
return track ?? null;
2633
}
2734

35+
get preferredLang() {
36+
return this._preferredLang;
37+
}
38+
39+
set preferredLang(lang: string | null) {
40+
this._preferredLang = lang;
41+
}
42+
2843
add(init: TextTrackInit | TextTrack, trigger?: Event) {
2944
const isTrack = init instanceof TextTrack,
30-
track = isTrack ? init : new TextTrack(init);
45+
track = isTrack ? init : new TextTrack(init),
46+
kind = init.kind === 'captions' || init.kind === 'subtitles' ? 'captions' : init.kind;
3147

32-
if (this._defaults[init.kind] && init.default) delete init.default;
48+
if (this._defaults[kind] && init.default) delete init.default;
3349

3450
track.addEventListener('mode-change', this._onTrackModeChangeBind);
3551
this[ListSymbol._add](track, trigger);
3652
track[TextTrackSymbol._crossorigin] = this[TextTrackSymbol._crossorigin];
3753
if (this._canLoad) track[TextTrackSymbol._canLoad]();
3854

3955
if (init.default) {
40-
this._defaults[init.kind] = track;
41-
track.mode = 'showing';
56+
this._defaults[kind] = track;
57+
if (kind !== 'captions') {
58+
track.mode = 'showing';
59+
} else {
60+
this._selectCaptions();
61+
}
4262
}
4363

4464
return this;
@@ -78,10 +98,39 @@ export class TextTrackList extends List<TextTrack, TextTrackListEvents> {
7898
this._canLoad = true;
7999
}
80100

101+
private _selectCaptions = debounce(() => {
102+
if (this.selected || this._storage?.data.captions === false) return;
103+
104+
if (!this._preferredLang && this._storage) {
105+
this._preferredLang = this._storage.data.lang;
106+
}
107+
108+
const preferredTrack =
109+
this._preferredLang &&
110+
this._items.find(
111+
(track) => isTrackCaptionKind(track) && track.language === this._preferredLang,
112+
),
113+
defaultTrack = this._defaults.captions;
114+
115+
if (preferredTrack) {
116+
preferredTrack.mode = 'showing';
117+
} else if (defaultTrack) {
118+
if (defaultTrack) defaultTrack.mode = 'showing';
119+
}
120+
121+
if (this._storage) {
122+
this._storage.lang = this._preferredLang ?? defaultTrack?.language ?? null;
123+
}
124+
}, 300);
125+
81126
private _onTrackModeChangeBind = this._onTrackModeChange.bind(this);
82127
private _onTrackModeChange(event: TextTrackModeChangeEvent) {
83128
const track = event.detail;
84129

130+
if (this._storage && isTrackCaptionKind(track)) {
131+
this._storage.captions = track.mode === 'showing';
132+
}
133+
85134
if (track.mode === 'showing') {
86135
const kinds = isTrackCaptionKind(track) ? ['captions', 'subtitles'] : [track.kind];
87136
for (const t of this._items) {

0 commit comments

Comments
 (0)