);
-}
-
-const noState: State = {
- playhead: "idle",
- started: false,
- time: 0,
- duration: NaN,
- volume: 0,
- autoQuality: false,
- qualities: [],
- audioTracks: [],
- subtitleTracks: [],
-};
-
-export function pipeState(
- prop: P,
- asset: Asset | null,
-): State[P] {
- if (!asset) {
- return noState[prop];
- }
- return asset.state[prop];
-}
diff --git a/packages/player/src/facade/index.ts b/packages/player/src/facade/index.ts
deleted file mode 100644
index 2f6a2d55..00000000
--- a/packages/player/src/facade/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from "./facade";
-export * from "./types";
diff --git a/packages/player/src/facade/media-manager.ts b/packages/player/src/facade/media-manager.ts
deleted file mode 100644
index 0f126117..00000000
--- a/packages/player/src/facade/media-manager.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import type { HlsAssetPlayer } from "hls.js";
-
-export class MediaManager {
- /**
- * All additional media elements besides the primary.
- */
- private mediaElements_: HTMLMediaElement[] = [];
-
- private index_ = 0;
-
- constructor(
- private media_: HTMLMediaElement,
- private multiple_: boolean,
- ) {
- if (this.multiple_) {
- this.createMediaElements_();
- }
- }
-
- attachMedia(player: HlsAssetPlayer) {
- if (!this.multiple_) {
- // We do not want to use multiple video elements.
- return;
- }
-
- // Grab a media element from the pool and bump index for the next.
- const media = this.mediaElements_[this.index_];
- this.index_ += 1;
- this.index_ %= this.mediaElements_.length;
-
- player.attachMedia(media);
- }
-
- setActive(player: HlsAssetPlayer) {
- if (
- !player.media ||
- // This is not a mediaElement that we created.
- !this.mediaElements_.includes(player.media)
- ) {
- return;
- }
- this.forwardMedia_(player.media);
- }
-
- reset() {
- // Set the primary element back to front.
- this.forwardMedia_(this.media_);
- }
-
- setVolume(volume: number) {
- this.media_.volume = volume;
- for (const media of this.mediaElements_) {
- media.volume = volume;
- }
- }
-
- private createMediaElements_() {
- if (this.media_.parentElement?.tagName !== "DIV") {
- throw new Error("The parent of the media element is not a div.");
- }
-
- const container = this.media_.parentElement as HTMLDivElement;
- // Create 2 video elements so we can transition smoothly from one
- // interstitial to the other.
- this.mediaElements_.push(createVideoElement(), createVideoElement());
- container.prepend(...this.mediaElements_);
-
- this.forwardMedia_(this.media_);
- }
-
- /**
- * Bring a media element to foreground.
- * @param target
- */
- private forwardMedia_(target: HTMLMediaElement) {
- let found = false;
- for (const media of this.mediaElements_) {
- if (target === media) {
- media.style.zIndex = "0";
- found = true;
- } else {
- media.style.zIndex = "-1";
- }
- }
-
- // If we found a sub media element, we hide primary.
- this.media_.style.zIndex = found ? "-1" : "0";
- }
-}
-
-function createVideoElement() {
- const el = document.createElement("video");
- el.style.position = "absolute";
- el.style.inset = "0";
- el.style.width = "100%";
- el.style.height = "100%";
- return el;
-}
diff --git a/packages/player/src/facade/state-observer.ts b/packages/player/src/facade/state-observer.ts
deleted file mode 100644
index 0987d643..00000000
--- a/packages/player/src/facade/state-observer.ts
+++ /dev/null
@@ -1,356 +0,0 @@
-import Hls from "hls.js";
-import { assert } from "./assert";
-import { EventManager } from "./event-manager";
-import { getLang, preciseFloat, updateActive } from "./helpers";
-import { Timer } from "./timer";
-import { Events } from "./types";
-import type {
- AudioTrack,
- HlsFacadeListeners,
- Playhead,
- Quality,
- State,
- SubtitleTrack,
-} from "./types";
-import type { Level, MediaPlaylist } from "hls.js";
-
-export type StateObserverEmit = (
- hls: Hls,
- event: E,
- eventObj: Parameters[0],
-) => void;
-
-export class StateObserver {
- private eventManager_ = new EventManager();
-
- private mediaEventManager_: EventManager | null = null;
-
- state: State = {
- playhead: "idle",
- started: false,
- time: 0,
- duration: NaN,
- volume: 0,
- autoQuality: true,
- qualities: [],
- audioTracks: [],
- subtitleTracks: [],
- };
-
- private timeTick_ = new Timer(() => this.onTimeTick_());
-
- constructor(
- public hls: Hls,
- private emit_: StateObserverEmit,
- ) {
- const listen = this.eventManager_.listen(hls);
-
- listen(Hls.Events.MANIFEST_LOADED, this.onManifestLoaded_, this);
- listen(Hls.Events.BUFFER_CREATED, this.onBufferCreated_, this);
- listen(Hls.Events.LEVELS_UPDATED, this.onLevelsUpdated_, this);
- listen(Hls.Events.LEVEL_SWITCHING, this.onLevelSwitching_, this);
- listen(Hls.Events.MEDIA_ATTACHED, this.onMediaAttached_, this);
- listen(Hls.Events.MEDIA_DETACHED, this.onMediaDetached_, this);
- listen(
- Hls.Events.SUBTITLE_TRACKS_UPDATED,
- this.onSubtitleTracksUpdated_,
- this,
- );
- listen(Hls.Events.SUBTITLE_TRACK_SWITCH, this.onSubtitleTrackSwitch_, this);
- listen(Hls.Events.AUDIO_TRACKS_UPDATED, this.onAudioTracksUpdated_, this);
- listen(Hls.Events.AUDIO_TRACK_SWITCHING, this.onAudioTrackSwitching_, this);
-
- if (hls.media) {
- // Looks like we already have media attached, bind listeners immediately.
- this.onMediaAttached_();
- }
- }
-
- private onManifestLoaded_() {
- this.onLevelsUpdated_();
- }
-
- private onBufferCreated_() {
- this.timeTick_.tickNow();
- }
-
- private onAudioTracksUpdated_() {
- const tracks = this.hls.allAudioTracks.map((track, index) => {
- let label = getLang(track.lang);
- if (track.channels === "6") {
- label += " 5.1";
- }
- return {
- id: index,
- active: this.isAudioTrackActive_(track),
- label,
- track,
- };
- });
-
- this.state.audioTracks = tracks;
-
- this.dispatchEvent_(Events.AUDIO_TRACKS_CHANGE, { audioTracks: tracks });
- }
-
- private isAudioTrackActive_(t: MediaPlaylist) {
- if (!this.hls.audioTracks.includes(t)) {
- return false;
- }
- return t.id === this.hls.audioTrack;
- }
-
- private isSubtitleTrackActive_(t: MediaPlaylist) {
- if (!this.hls.subtitleTracks.includes(t)) {
- return false;
- }
- return t.id === this.hls.subtitleTrack;
- }
-
- private onAudioTrackSwitching_() {
- const newTracks = updateActive(this.state.audioTracks, (t) =>
- this.isAudioTrackActive_(t.track),
- );
-
- if (newTracks === this.state.audioTracks) {
- return;
- }
-
- this.state.audioTracks = newTracks;
-
- this.dispatchEvent_(Events.AUDIO_TRACKS_CHANGE, {
- audioTracks: newTracks,
- });
- }
-
- private onSubtitleTracksUpdated_() {
- const tracks = this.hls.allSubtitleTracks.map(
- (track, index) => ({
- id: index,
- active: this.isSubtitleTrackActive_(track),
- label: getLang(track.lang),
- track,
- }),
- );
-
- this.state.subtitleTracks = tracks;
-
- this.dispatchEvent_(Events.SUBTITLE_TRACKS_CHANGE, {
- subtitleTracks: tracks,
- });
- }
-
- private onSubtitleTrackSwitch_() {
- const newTracks = updateActive(this.state.subtitleTracks, (t) =>
- this.isSubtitleTrackActive_(t.track),
- );
-
- if (newTracks === this.state.subtitleTracks) {
- return;
- }
-
- this.state.subtitleTracks = newTracks;
-
- this.dispatchEvent_(Events.SUBTITLE_TRACKS_CHANGE, {
- subtitleTracks: newTracks,
- });
- }
-
- setQuality(height: number | null) {
- if (height === null) {
- this.hls.nextLevel = -1;
- } else {
- const loadLevel = this.hls.levels[this.hls.loadLevel];
- assert(loadLevel, "No level found for loadLevel index");
-
- const idx = this.hls.levels.findIndex((level) => {
- return (
- level.height === height &&
- level.audioCodec?.substring(0, 4) ===
- loadLevel.audioCodec?.substring(0, 4)
- );
- });
-
- if (idx < 0) {
- throw new Error("Could not find matching level");
- }
-
- this.hls.nextLevel = idx;
- this.onLevelSwitching_();
- }
-
- const newAutoQuality = this.hls.autoLevelEnabled;
- if (newAutoQuality !== this.state.autoQuality) {
- this.state.autoQuality = newAutoQuality;
- this.dispatchEvent_(Events.AUTO_QUALITY_CHANGE, {
- autoQuality: newAutoQuality,
- });
- }
- }
-
- setSubtitleTrack(id: number | null) {
- if (id === null) {
- this.hls.subtitleTrack = -1;
- return;
- }
- const subtitleTrack = this.hls.allSubtitleTracks[id];
- this.hls.setSubtitleOption({
- lang: subtitleTrack.lang,
- name: subtitleTrack.name,
- });
- }
-
- setAudioTrack(id: number) {
- const audioTrack = this.hls.allAudioTracks[id];
- this.hls.setAudioOption({
- lang: audioTrack.lang,
- channels: audioTrack.channels,
- name: audioTrack.name,
- });
- }
-
- destroy() {
- this.eventManager_.removeAll();
-
- this.mediaEventManager_?.removeAll();
- this.mediaEventManager_ = null;
-
- this.timeTick_.stop();
- }
-
- private onLevelsUpdated_() {
- const map: Record = {};
- for (const level of this.hls.levels) {
- if (!map[level.height]) {
- map[level.height] = [];
- }
- map[level.height].push(level);
- }
-
- const level = this.hls.levels[this.hls.nextLoadLevel];
- const mapEntries = Object.entries(map);
- const qualities = mapEntries.reduce((acc, [key, levels]) => {
- acc.push({
- height: +key,
- active: +key === level?.height,
- levels,
- });
- return acc;
- }, []);
-
- qualities.sort((a, b) => b.height - a.height);
-
- this.state.qualities = qualities;
- this.dispatchEvent_(Events.QUALITIES_CHANGE, { qualities });
- }
-
- private onLevelSwitching_() {
- const level = this.hls.levels[this.hls.nextLoadLevel];
-
- const newQualities = updateActive(
- this.state.qualities,
- (q) => q.height === level.height,
- );
-
- if (newQualities === this.state.qualities) {
- return;
- }
-
- this.state.qualities = newQualities;
- this.dispatchEvent_(Events.QUALITIES_CHANGE, {
- qualities: this.state.qualities,
- });
- }
-
- private onMediaAttached_() {
- assert(this.hls.media);
-
- const media = this.hls.media;
- const state = this.state;
-
- // Set initial state when we have media attached.
- this.state.volume = media.volume;
-
- this.mediaEventManager_ = new EventManager();
- const listen = this.mediaEventManager_.listen(media);
-
- listen("play", () => this.setPlayhead_("play"));
-
- listen("playing", () => {
- this.timeTick_.tickNow().tickEvery(0.25);
- this.setPlayhead_("playing");
- });
-
- listen("pause", () => {
- this.timeTick_.tickNow();
- this.setPlayhead_("pause");
- });
-
- listen("volumechange", () => {
- state.volume = media.volume;
- this.dispatchEvent_(Events.VOLUME_CHANGE, { volume: media.volume });
- });
-
- listen("ended", () => this.setPlayhead_("ended"));
- }
-
- private onMediaDetached_() {
- this.timeTick_.stop();
-
- this.mediaEventManager_?.removeAll();
- this.mediaEventManager_ = null;
- }
-
- private onTimeTick_() {
- let time = 0;
- let duration = NaN;
-
- if (this.hls.media) {
- time = this.hls.media.currentTime;
- duration = this.hls.media.duration;
- }
-
- const oldTime = this.state.time;
- this.state.time = preciseFloat(time);
-
- const oldDuration = this.state.duration;
- this.state.duration = preciseFloat(duration);
-
- if (isNaN(duration)) {
- return;
- }
-
- if (oldTime === this.state.time && oldDuration === this.state.duration) {
- return;
- }
-
- this.dispatchEvent_(Events.TIME_CHANGE, {
- time: this.state.time,
- duration: this.state.duration,
- });
- }
-
- private setPlayhead_(playhead: Playhead) {
- this.state.playhead = playhead;
-
- if (playhead === "playing") {
- this.state.started = true;
- }
-
- this.dispatchEvent_(Events.PLAYHEAD_CHANGE, {
- playhead,
- started: this.state.started,
- });
- }
-
- private dispatchEvent_(
- event: E,
- eventObj: Parameters[0],
- ) {
- this.emit_(this.hls, event, eventObj);
- }
-
- requestTimeTick() {
- this.timeTick_.tickNow();
- }
-}
diff --git a/packages/player/src/facade/timer.ts b/packages/player/src/facade/timer.ts
deleted file mode 100644
index 7f676946..00000000
--- a/packages/player/src/facade/timer.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-export class Timer {
- private timerId_?: number;
-
- constructor(private onTick_: () => void) {}
-
- tickNow() {
- this.stop();
-
- this.onTick_();
-
- return this;
- }
-
- tickAfter(seconds: number) {
- this.stop();
-
- this.timerId_ = window.setTimeout(() => {
- this.onTick_();
- }, seconds * 1000);
-
- return this;
- }
-
- tickEvery(seconds: number) {
- this.stop();
-
- this.timerId_ = window.setTimeout(() => {
- this.onTick_();
- this.tickEvery(seconds);
- }, seconds * 1000);
-
- return this;
- }
-
- stop() {
- clearTimeout(this.timerId_);
- }
-}
diff --git a/packages/player/src/facade/types.ts b/packages/player/src/facade/types.ts
deleted file mode 100644
index c816fc3d..00000000
--- a/packages/player/src/facade/types.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import type {
- HlsAssetPlayer,
- InterstitialAssetItem,
- Level,
- MediaPlaylist,
-} from "hls.js";
-
-/**
- * A custom type for each `ASSET`.
- */
-export type CustomInterstitialType = "ad" | "bumper";
-
-/**
- * Defines an in-band subtitle track.
- */
-export interface SubtitleTrack {
- id: number;
- active: boolean;
- label: string;
- track: MediaPlaylist;
-}
-
-/**
- * Defines an audio track.
- */
-export interface AudioTrack {
- id: number;
- active: boolean;
- label: string;
- track: MediaPlaylist;
-}
-
-/**
- * Defines a quality level.
- */
-export interface Quality {
- height: number;
- active: boolean;
- levels: Level[];
-}
-
-/**
- * State of playhead across all assets.
- */
-export type Playhead = "idle" | "play" | "playing" | "pause" | "ended";
-
-/**
- * Defines an interstitial, which is not the primary content.
- */
-export interface Interstitial {
- time: number;
- duration: number;
- player: HlsAssetPlayer;
- asset: InterstitialAssetItem;
- type?: CustomInterstitialType;
-}
-
-/**
- * State variables.
- */
-export interface State {
- playhead: Playhead;
- started: boolean;
- time: number;
- duration: number;
- volume: number;
- autoQuality: boolean;
- qualities: Quality[];
- audioTracks: AudioTrack[];
- subtitleTracks: SubtitleTrack[];
-}
-
-/**
- * List of events.
- */
-export enum Events {
- RESET = "reset",
- READY = "ready",
- PLAYHEAD_CHANGE = "playheadChange",
- TIME_CHANGE = "timeChange",
- VOLUME_CHANGE = "volumeChange",
- QUALITIES_CHANGE = "qualitiesChange",
- AUDIO_TRACKS_CHANGE = "audioTracksChange",
- SUBTITLE_TRACKS_CHANGE = "subtitleTracksChange",
- AUTO_QUALITY_CHANGE = "autoQualityChange",
- INTERSTITIAL_CHANGE = "interstitialChange",
-}
-
-export interface PlayheadChangeEventData {
- playhead: Playhead;
- started: boolean;
-}
-
-export interface TimeChangeEventData {
- time: number;
- duration: number;
-}
-
-export interface VolumeChangeEventData {
- volume: number;
-}
-
-export interface QualitiesChangeEventData {
- qualities: Quality[];
-}
-
-export interface AudioTracksChangeEventData {
- audioTracks: AudioTrack[];
-}
-
-export interface SubtitleTracksChangeEventData {
- subtitleTracks: SubtitleTrack[];
-}
-
-export interface AutoQualityChangeEventData {
- autoQuality: boolean;
-}
-
-export interface InterstitialChangeEventData {
- interstitial: Interstitial | null;
-}
-
-/**
- * List of events with their respective event handlers.
- */
-export interface HlsFacadeListeners {
- "*": () => void;
- [Events.RESET]: () => void;
- [Events.READY]: () => void;
- [Events.PLAYHEAD_CHANGE]: (data: PlayheadChangeEventData) => void;
- [Events.TIME_CHANGE]: (data: TimeChangeEventData) => void;
- [Events.VOLUME_CHANGE]: (data: VolumeChangeEventData) => void;
- [Events.QUALITIES_CHANGE]: (data: QualitiesChangeEventData) => void;
- [Events.AUDIO_TRACKS_CHANGE]: (data: AudioTracksChangeEventData) => void;
- [Events.SUBTITLE_TRACKS_CHANGE]: (
- data: SubtitleTracksChangeEventData,
- ) => void;
- [Events.AUTO_QUALITY_CHANGE]: (data: AutoQualityChangeEventData) => void;
- [Events.INTERSTITIAL_CHANGE]: (data: InterstitialChangeEventData) => void;
-}
diff --git a/packages/player/src/facade/lang-map.ts b/packages/player/src/helpers.ts
similarity index 91%
rename from packages/player/src/facade/lang-map.ts
rename to packages/player/src/helpers.ts
index 396b709b..dcc3d1b3 100644
--- a/packages/player/src/facade/lang-map.ts
+++ b/packages/player/src/helpers.ts
@@ -1,6 +1,18 @@
+export function preciseFloat(value: number) {
+ return Math.round((value + Number.EPSILON) * 100) / 100;
+}
+
+export function getLangCode(key?: string) {
+ const value = key ? langCodes[key]?.split(",")[0] : null;
+ if (!value) {
+ return "Unknown";
+ }
+ return `${value[0].toUpperCase()}${value.slice(1)}`;
+}
+
// Inspired by iso-language-codes.
// See https://github.com/pubcore/iso-language-codes/blob/master/src/data.ts
-export const langMap: Record = {
+const langCodes: Record = {
sr: "српски језик",
ro: "Română",
ii: "ꆈꌠ꒿ Nuosuhxop",
@@ -35,8 +47,8 @@ export const langMap: Record = {
qu: "Runa Simi, Kichwa",
sc: "sardu",
sw: "Kiswahili",
- uz: "Oʻzbek, Ўзбек, ",
- za: "Saɯ cueŋƅ, Saw cuengh",
+ uz: "O'zbek, Ўзбек, ",
+ za: "Saw cueŋƅ, Saw cuengh",
bi: "Bislama",
nb: "Norsk Bokmål",
nn: "Norsk Nynorsk",
diff --git a/packages/player/src/hls-player.ts b/packages/player/src/hls-player.ts
new file mode 100644
index 00000000..aab13f60
--- /dev/null
+++ b/packages/player/src/hls-player.ts
@@ -0,0 +1,420 @@
+import Hls from "hls.js";
+import { assert } from "shared/assert";
+import { EventEmitter } from "tseep";
+import { EventManager } from "./event-manager";
+import { getLangCode } from "./helpers";
+import { getState, State } from "./state";
+import type {
+ AudioTrack,
+ HlsPlayerEventMap,
+ Quality,
+ SubtitleTrack,
+} from "./types";
+import type { Level } from "hls.js";
+
+export class HlsPlayer {
+ private media_: HTMLMediaElement;
+
+ private eventManager_ = new EventManager();
+
+ private hls_: Hls | null = null;
+
+ private state_: State | null = null;
+
+ private emitter_ = new EventEmitter();
+
+ constructor(public container: HTMLDivElement) {
+ this.media_ = this.createMedia_();
+
+ // Make sure we're in unload state.
+ this.unload();
+ }
+
+ private createMedia_() {
+ const media = document.createElement("video");
+ this.container.appendChild(media);
+
+ media.style.position = "absolute";
+ media.style.inset = "0";
+ media.style.width = "100%";
+ media.style.height = "100%";
+
+ return media;
+ }
+
+ load(url: string) {
+ this.bindMediaListeners_();
+ const hls = this.createHls_();
+
+ this.state_ = new State({
+ emitter: this.emitter_,
+ getTiming: () => ({
+ primary: hls.interstitialsManager?.primary ?? hls.media,
+ asset: hls.interstitialsManager?.playerQueue.find(
+ (player) =>
+ player.assetItem === hls.interstitialsManager?.playingAsset,
+ ),
+ }),
+ });
+
+ hls.attachMedia(this.media_);
+ hls.loadSource(url);
+
+ this.hls_ = hls;
+ }
+
+ unload() {
+ this.eventManager_.removeAll();
+ this.state_ = null;
+
+ if (this.hls_) {
+ this.hls_.destroy();
+ this.hls_ = null;
+ }
+ }
+
+ destroy() {
+ this.emitter_.removeAllListeners();
+ this.unload();
+ }
+
+ on = this.emitter_.on.bind(this.emitter_);
+ off = this.emitter_.off.bind(this.emitter_);
+ once = this.emitter_.once.bind(this.emitter_);
+
+ playOrPause() {
+ if (!this.state_) {
+ return;
+ }
+ const shouldPause =
+ this.state_.playhead === "play" || this.state_.playhead === "playing";
+ if (shouldPause) {
+ this.media_.pause();
+ } else {
+ this.media_.play();
+ }
+ }
+
+ seekTo(time: number) {
+ assert(this.hls_);
+
+ if (this.hls_.interstitialsManager) {
+ this.hls_.interstitialsManager.primary.seekTo(time);
+ } else {
+ this.media_.currentTime = time;
+ }
+ }
+
+ setQuality(height: number | null) {
+ assert(this.hls_);
+
+ if (height === null) {
+ this.hls_.nextLevel = -1;
+ } else {
+ const loadLevel = this.hls_.levels[this.hls_.loadLevel];
+ assert(loadLevel, "No level found for loadLevel index");
+
+ const idx = this.hls_.levels.findIndex((level) => {
+ return (
+ level.height === height &&
+ level.audioCodec?.substring(0, 4) ===
+ loadLevel.audioCodec?.substring(0, 4)
+ );
+ });
+
+ if (idx < 0) {
+ throw new Error("Could not find matching level");
+ }
+
+ this.hls_.nextLevel = idx;
+ }
+
+ this.updateQualities_();
+ }
+
+ setAudioTrack(id: number) {
+ assert(this.hls_);
+
+ const audioTrack = this.state_?.audioTracks.find(
+ (track) => track.id === id,
+ );
+ assert(audioTrack);
+
+ this.hls_.setAudioOption({
+ lang: audioTrack.track.lang,
+ channels: audioTrack.track.channels,
+ name: audioTrack.track.name,
+ });
+ }
+
+ setSubtitleTrack(id: number | null) {
+ assert(this.hls_);
+
+ if (id === null) {
+ this.hls_.subtitleTrack = -1;
+ return;
+ }
+
+ const subtitleTrack = this.state_?.subtitleTracks.find(
+ (track) => track.id === id,
+ );
+ assert(subtitleTrack);
+
+ this.hls_.setSubtitleOption({
+ lang: subtitleTrack.track.lang,
+ name: subtitleTrack.track.name,
+ });
+ }
+
+ setVolume(volume: number) {
+ this.media_.volume = volume;
+ this.media_.muted = volume === 0;
+ this.state_?.setVolume(volume);
+ }
+
+ get ready() {
+ return getState(this.state_, "ready");
+ }
+
+ get playhead() {
+ return getState(this.state_, "playhead");
+ }
+
+ get started() {
+ return getState(this.state_, "started");
+ }
+
+ get time() {
+ return getState(this.state_, "time");
+ }
+
+ get duration() {
+ return getState(this.state_, "duration");
+ }
+
+ get seeking() {
+ return getState(this.state_, "seeking");
+ }
+
+ get interstitial() {
+ return getState(this.state_, "interstitial");
+ }
+
+ get qualities() {
+ return getState(this.state_, "qualities");
+ }
+
+ get autoQuality() {
+ return getState(this.state_, "autoQuality");
+ }
+
+ get audioTracks() {
+ return getState(this.state_, "audioTracks");
+ }
+
+ get subtitleTracks() {
+ return getState(this.state_, "subtitleTracks");
+ }
+
+ get volume() {
+ return getState(this.state_, "volume");
+ }
+
+ get seekableStart() {
+ if (this.hls_) {
+ return this.hls_.interstitialsManager?.primary?.seekableStart ?? 0;
+ }
+ return NaN;
+ }
+
+ get live() {
+ return this.hls_?.levels[this.hls_.currentLevel]?.details?.live ?? false;
+ }
+
+ get cuePoints() {
+ return getState(this.state_, "cuePoints");
+ }
+
+ private createHls_() {
+ const hls = new Hls();
+
+ const listen = this.eventManager_.listen(hls);
+
+ listen(Hls.Events.MANIFEST_LOADED, () => {
+ this.updateQualities_();
+ this.updateAudioTracks_();
+ this.updateSubtitleTracks_();
+ });
+
+ listen(Hls.Events.INTERSTITIAL_STARTED, () => {
+ this.state_?.setInterstitial({
+ asset: null,
+ });
+ });
+
+ listen(Hls.Events.INTERSTITIAL_ASSET_STARTED, (_, data) => {
+ const listResponseAsset = data.event.assetListResponse?.ASSETS[
+ data.assetListIndex
+ ] as {
+ "SPRS-KIND"?: "ad" | "bumper";
+ };
+
+ this.state_?.setAsset({
+ type: listResponseAsset["SPRS-KIND"],
+ });
+ });
+
+ listen(Hls.Events.INTERSTITIAL_ASSET_ENDED, () => {
+ this.state_?.setAsset(null);
+ });
+
+ listen(Hls.Events.INTERSTITIAL_ENDED, () => {
+ this.state_?.setInterstitial(null);
+ });
+
+ listen(Hls.Events.LEVELS_UPDATED, () => {
+ this.updateQualities_();
+ });
+
+ listen(Hls.Events.LEVEL_SWITCHING, () => {
+ this.updateQualities_();
+ });
+
+ listen(Hls.Events.AUDIO_TRACKS_UPDATED, () => {
+ this.updateAudioTracks_();
+ });
+
+ listen(Hls.Events.AUDIO_TRACK_SWITCHING, () => {
+ this.updateAudioTracks_();
+ });
+
+ listen(Hls.Events.SUBTITLE_TRACKS_UPDATED, () => {
+ this.updateSubtitleTracks_();
+ });
+
+ listen(Hls.Events.SUBTITLE_TRACK_SWITCH, () => {
+ this.updateSubtitleTracks_();
+ });
+
+ listen(Hls.Events.INTERSTITIALS_UPDATED, (_, data) => {
+ const cuePoints = data.schedule.reduce((acc, item) => {
+ if (item.event) {
+ acc.push(item.start);
+ }
+ return acc;
+ }, []);
+ this.state_?.setCuePoints(cuePoints);
+ });
+
+ return hls;
+ }
+
+ private updateQualities_() {
+ assert(this.hls_);
+
+ const group: {
+ height: number;
+ levels: Level[];
+ }[] = [];
+
+ for (const level of this.hls_.levels) {
+ let item = group.find((item) => item.height === level.height);
+ if (!item) {
+ item = {
+ height: level.height,
+ levels: [],
+ };
+ group.push(item);
+ }
+ item.levels.push(level);
+ }
+
+ const level = this.hls_.levels[this.hls_.nextLoadLevel];
+
+ const qualities = group.map((item) => {
+ return {
+ ...item,
+ active: item.height === level.height,
+ };
+ });
+
+ qualities.sort((a, b) => b.height - a.height);
+
+ const autoQuality = this.hls_.autoLevelEnabled;
+ this.state_?.setQualities(qualities, autoQuality);
+ }
+
+ private updateAudioTracks_() {
+ assert(this.hls_);
+
+ const tracks = this.hls_.allAudioTracks.map((track, index) => {
+ let label = getLangCode(track.lang);
+ if (track.channels === "6") {
+ label += " 5.1";
+ }
+ return {
+ id: index,
+ active: this.hls_?.audioTracks.includes(track)
+ ? track.id === this.hls_.audioTrack
+ : false,
+ label,
+ track,
+ };
+ });
+
+ this.state_?.setAudioTracks(tracks);
+ }
+
+ private updateSubtitleTracks_() {
+ assert(this.hls_);
+
+ const tracks = this.hls_.allSubtitleTracks.map(
+ (track, index) => {
+ return {
+ id: index,
+ active: this.hls_?.subtitleTracks.includes(track)
+ ? track.id === this.hls_.subtitleTrack
+ : false,
+ label: getLangCode(track.lang),
+ track,
+ };
+ },
+ );
+
+ this.state_?.setSubtitleTracks(tracks);
+ }
+
+ private bindMediaListeners_() {
+ const listen = this.eventManager_.listen(this.media_);
+
+ listen("canplay", () => {
+ this.state_?.setReady();
+ });
+
+ listen("play", () => {
+ this.state_?.setPlayhead("play");
+ });
+
+ listen("playing", () => {
+ this.state_?.setStarted();
+
+ this.state_?.setPlayhead("playing");
+ });
+
+ listen("pause", () => {
+ this.state_?.setPlayhead("pause");
+ });
+
+ listen("volumechange", () => {
+ this.state_?.setVolume(this.media_.volume);
+ });
+
+ listen("seeking", () => {
+ this.state_?.setSeeking(true);
+ });
+
+ listen("seeked", () => {
+ this.state_?.setSeeking(false);
+ });
+ }
+}
diff --git a/packages/player/src/index.ts b/packages/player/src/index.ts
new file mode 100644
index 00000000..d88bf8f4
--- /dev/null
+++ b/packages/player/src/index.ts
@@ -0,0 +1,10 @@
+export { HlsPlayer } from "./hls-player";
+export { Events } from "./types";
+
+export type {
+ Playhead,
+ HlsPlayerEventMap,
+ Quality,
+ AudioTrack,
+ SubtitleTrack,
+} from "./types";
diff --git a/packages/player/src/react/ControllerProvider.tsx b/packages/player/src/react/ControllerProvider.tsx
deleted file mode 100644
index 6d17cb49..00000000
--- a/packages/player/src/react/ControllerProvider.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { createContext } from "react";
-import type { Controller } from "./hooks/useController";
-import type { ReactNode } from "react";
-
-export const ControllerContext = createContext(
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
- {} as Controller,
-);
-
-interface ControllerProviderProps {
- children: ReactNode;
- controller: Controller;
-}
-
-export function ControllerProvider({
- children,
- controller,
-}: ControllerProviderProps) {
- return (
-
- {children}
-
- );
-}
diff --git a/packages/player/src/react/controls/Controls.tsx b/packages/player/src/react/controls/Controls.tsx
deleted file mode 100644
index 75aa5762..00000000
--- a/packages/player/src/react/controls/Controls.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Playback } from "./components/Playback";
-import { Start } from "./components/Start";
-import { AppStoreProvider } from "./context/AppStoreProvider";
-import { ParamsProvider } from "./context/ParamsProvider";
-import type { Lang, Metadata } from "./types";
-
-export interface ControlsProps {
- metadata?: Metadata;
- lang?: Lang;
-}
-
-export function Controls({ metadata, lang }: ControlsProps) {
- return (
-
-
-
-
-
-
- );
-}
diff --git a/packages/player/src/react/controls/components/BottomControls.tsx b/packages/player/src/react/controls/components/BottomControls.tsx
deleted file mode 100644
index 24d27081..00000000
--- a/packages/player/src/react/controls/components/BottomControls.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import { FullscreenButton } from "./FullscreenButton";
-import { Label } from "./Label";
-import { PlayPauseButton } from "./PlayPauseButton";
-import { SqButton } from "./SqButton";
-import { VolumeButton } from "./VolumeButton";
-import { useFacade, useSelector } from "../..";
-import { useAppStore } from "../hooks/useAppStore";
-import { useFakeTime } from "../hooks/useFakeTime";
-import { useSeekTo } from "../hooks/useSeekTo";
-import { useShowTextAudio } from "../hooks/useShowTextAudio";
-import ForwardIcon from "../icons/forward.svg";
-import SettingsIcon from "../icons/settings.svg";
-import SubtitlesIcon from "../icons/subtitles.svg";
-import type { SetAppSettings } from "../hooks/useAppSettings";
-import type { MouseEventHandler } from "react";
-
-interface BottomControlsProps {
- nudgeVisible(): void;
- setAppSettings: SetAppSettings;
- toggleFullscreen: MouseEventHandler;
-}
-
-export function BottomControls({
- nudgeVisible,
- setAppSettings,
- toggleFullscreen,
-}: BottomControlsProps) {
- const facade = useFacade();
-
- const interstitial = useSelector((facade) => facade.interstitial);
- const volume = useSelector((facade) => facade.volume);
-
- const settings = useAppStore((state) => state.settings);
-
- const seekTo = useSeekTo();
- const fakeTime = useFakeTime();
- const showTextAudio = useShowTextAudio();
-
- return (
-
-
-
{
- seekTo(fakeTime + 10);
- }}
- >
-
-
-
facade.setVolume(volume)}
- />
-
-
- {showTextAudio ? (
- setAppSettings("text-audio")}
- onIdle={() => setAppSettings("text-audio", true)}
- selected={
- settings?.mode === "text-audio" && settings.entry === "explicit"
- }
- data-sprs-settings-action
- >
-
-
- ) : null}
- setAppSettings("quality")}
- onIdle={() => setAppSettings("quality", true)}
- selected={settings?.mode === "quality" && settings.entry === "explicit"}
- data-sprs-settings-action
- >
-
-
-
-
- );
-}
diff --git a/packages/player/src/react/controls/components/Center.tsx b/packages/player/src/react/controls/components/Center.tsx
deleted file mode 100644
index 4bf3d4a3..00000000
--- a/packages/player/src/react/controls/components/Center.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { useRef } from "react";
-import { CenterIconPop } from "./CenterIconPop";
-import { useFacade } from "../..";
-import type { CenterIconPopRef } from "./CenterIconPop";
-import type { MouseEventHandler } from "react";
-
-interface CenterProps {
- onDoubleClick?: MouseEventHandler;
-}
-
-export function Center({ onDoubleClick }: CenterProps) {
- const facade = useFacade();
- const centerIconPopRef = useRef(null);
-
- return (
- {
- facade.playOrPause();
- centerIconPopRef.current?.playOrPause();
- }}
- onDoubleClick={onDoubleClick}
- >
-
-
- );
-}
diff --git a/packages/player/src/react/controls/components/CenterIconPop.tsx b/packages/player/src/react/controls/components/CenterIconPop.tsx
deleted file mode 100644
index 92da95b1..00000000
--- a/packages/player/src/react/controls/components/CenterIconPop.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import {
- forwardRef,
- useImperativeHandle,
- useLayoutEffect,
- useRef,
- useState,
-} from "react";
-import { useSelector } from "../..";
-import PauseIcon from "../icons/pause.svg";
-import PlayIcon from "../icons/play.svg";
-import type { ReactNode } from "react";
-
-export interface CenterIconPopRef {
- playOrPause(): void;
-}
-
-export const CenterIconPop = forwardRef((_, ref) => {
- const elementRef = useRef(null);
- const playhead = useSelector((facade) => facade.playhead);
- const [nudge, setNudge] = useState(null);
-
- useLayoutEffect(() => {
- const el = elementRef.current;
- if (!el) {
- return;
- }
-
- let timerId: number;
-
- const stage = (timeout: number) =>
- new Promise((resolve) => {
- timerId = window.setTimeout(() => {
- resolve(undefined);
- }, timeout);
- });
-
- const runStages = async () => {
- el.style.transform = "";
- el.style.transition = "";
- el.style.opacity = "0";
- await stage(10);
- el.style.opacity = "1";
- await stage(50);
- el.style.transition = "all 500ms ease-out";
- el.style.opacity = "0";
- el.style.transform = "scale(2)";
- };
- runStages();
-
- return () => {
- clearTimeout(timerId);
- };
- }, [nudge]);
-
- useImperativeHandle(ref, () => {
- return {
- playOrPause() {
- setNudge(
- playhead === "pause" ? (
-
- ) : (
-
- ),
- );
- },
- };
- }, [playhead]);
-
- return (
-
- );
-});
diff --git a/packages/player/src/react/controls/components/CheckList.tsx b/packages/player/src/react/controls/components/CheckList.tsx
deleted file mode 100644
index 98ad031f..00000000
--- a/packages/player/src/react/controls/components/CheckList.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import cn from "clsx";
-
-export interface CheckListItem {
- id: number | null;
- label: React.ReactNode;
- checked: boolean;
-}
-
-interface CheckListProps {
- onSelect(id: CheckListItem["id"]): void;
- items: CheckListItem[];
-}
-
-export function CheckList({ items, onSelect }: CheckListProps) {
- return (
-
- {items.map((item) => (
- - onSelect(item.id)}
- className={cn(
- "h-8 flex items-center rounded-md px-4 transition-colors cursor-pointer",
- item.checked && "bg-white/20",
- )}
- >
- {item.label}
-
- ))}
-
- );
-}
diff --git a/packages/player/src/react/controls/components/FullscreenButton.tsx b/packages/player/src/react/controls/components/FullscreenButton.tsx
deleted file mode 100644
index 3fe2d3f0..00000000
--- a/packages/player/src/react/controls/components/FullscreenButton.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { SqButton } from "./SqButton";
-import { useAppStore } from "../hooks/useAppStore";
-import FullscreenExitIcon from "../icons/fullscreen-exit.svg";
-import FullscreenIcon from "../icons/fullscreen.svg";
-import type { MouseEventHandler } from "react";
-
-interface FullscreenButtonProps {
- toggleFullscreen: MouseEventHandler;
-}
-
-export function FullscreenButton({ toggleFullscreen }: FullscreenButtonProps) {
- const fullscreen = useAppStore((state) => state.fullscreen);
-
- return (
-
- {fullscreen ? (
-
- ) : (
-
- )}
-
- );
-}
diff --git a/packages/player/src/react/controls/components/InterstitialAdProgress.tsx b/packages/player/src/react/controls/components/InterstitialAdProgress.tsx
deleted file mode 100644
index f60a0cfa..00000000
--- a/packages/player/src/react/controls/components/InterstitialAdProgress.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { useSelector } from "../..";
-
-export function InterstitialAdProgress() {
- const time = useSelector((facade) => facade.interstitial?.time);
- const duration = useSelector((facade) => facade.interstitial?.duration);
-
- if (time === undefined || duration === undefined) {
- return null;
- }
-
- return (
-
- );
-}
diff --git a/packages/player/src/react/controls/components/InterstitialProgress.tsx b/packages/player/src/react/controls/components/InterstitialProgress.tsx
deleted file mode 100644
index a6e27d33..00000000
--- a/packages/player/src/react/controls/components/InterstitialProgress.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { InterstitialAdProgress } from "./InterstitialAdProgress";
-import type { Interstitial } from "../..";
-
-interface InterstitialProgressProps {
- interstitial: Interstitial;
-}
-export function InterstitialProgress({
- interstitial,
-}: InterstitialProgressProps) {
- if (interstitial.type === "ad") {
- return ;
- }
-
- return null;
-}
diff --git a/packages/player/src/react/controls/components/Label.tsx b/packages/player/src/react/controls/components/Label.tsx
deleted file mode 100644
index 232698f9..00000000
--- a/packages/player/src/react/controls/components/Label.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { useSelector } from "../..";
-import { useParams } from "../hooks/useParams";
-
-export function Label() {
- const { metadata } = useParams();
- const interstitial = useSelector((facade) => facade.interstitial);
-
- if (interstitial?.type === "ad") {
- return (
-
- Ad
-
- );
- }
-
- if (metadata?.title) {
- return (
-
- {metadata.subtitle ? (
- <>
- {metadata.title}{" "}
- {metadata.subtitle}
- >
- ) : (
- metadata.title
- )}
-
- );
- }
-
- return null;
-}
diff --git a/packages/player/src/react/controls/components/Pane.tsx b/packages/player/src/react/controls/components/Pane.tsx
deleted file mode 100644
index c2b974a0..00000000
--- a/packages/player/src/react/controls/components/Pane.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-interface PaneProps {
- title: string;
- children: React.ReactNode;
-}
-
-export function Pane({ title, children }: PaneProps) {
- return (
-
- );
-}
diff --git a/packages/player/src/react/controls/components/PlayPauseButton.tsx b/packages/player/src/react/controls/components/PlayPauseButton.tsx
deleted file mode 100644
index 0eb0b1af..00000000
--- a/packages/player/src/react/controls/components/PlayPauseButton.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { SqButton } from "./SqButton";
-import { useFacade, useSelector } from "../..";
-import PauseIcon from "../icons/pause.svg";
-import PlayIcon from "../icons/play.svg";
-
-interface PlayPauseButtonProps {
- nudgeVisible(): void;
-}
-
-export function PlayPauseButton({ nudgeVisible }: PlayPauseButtonProps) {
- const facade = useFacade();
- const playhead = useSelector((facade) => facade.playhead);
-
- const canPause = playhead === "play" || playhead === "playing";
-
- return (
- {
- facade.playOrPause();
- nudgeVisible();
- }}
- tooltip={canPause ? "button.pause" : "button.play"}
- tooltipPlacement="left"
- >
- {canPause ? (
-
- ) : (
-
- )}
-
- );
-}
diff --git a/packages/player/src/react/controls/components/Playback.tsx b/packages/player/src/react/controls/components/Playback.tsx
deleted file mode 100644
index 66493347..00000000
--- a/packages/player/src/react/controls/components/Playback.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import cn from "clsx";
-import { BottomControls } from "./BottomControls";
-import { Center } from "./Center";
-import { Seekbar } from "./Seekbar";
-import { Settings } from "./Settings";
-import { useSelector } from "../..";
-import { useAppFullscreen } from "../hooks/useAppFullscreen";
-import { useAppSettings } from "../hooks/useAppSettings";
-import { useAppVisible } from "../hooks/useAppVisible";
-import { useVisibleControls } from "../hooks/useVisibleControls";
-
-export function Playback() {
- const ready = useSelector((facade) => facade.ready);
- const [ref, nudgeVisible] = useAppVisible();
- const setAppSettings = useAppSettings();
- const toggleFullscreen = useAppFullscreen();
- const visibleControls = useVisibleControls();
-
- return (
-
- );
-}
diff --git a/packages/player/src/react/controls/components/Progress.tsx b/packages/player/src/react/controls/components/Progress.tsx
deleted file mode 100644
index 20c383c0..00000000
--- a/packages/player/src/react/controls/components/Progress.tsx
+++ /dev/null
@@ -1,168 +0,0 @@
-import cn from "clsx";
-import { useCallback, useEffect, useRef, useState } from "react";
-import { useSelector } from "../..";
-import { useAppStore } from "../hooks/useAppStore";
-import { useFakeTime } from "../hooks/useFakeTime";
-import { useSeekTo } from "../hooks/useSeekTo";
-import { toHMS } from "../utils";
-import type { PointerEventHandler } from "react";
-
-export function Progress() {
- const ref = useRef(null);
- const tooltipRef = useRef(null);
-
- const [hover, setHover] = useState(false);
- const [value, setValue] = useState(0);
-
- const duration = useSelector((facade) => facade.duration);
- const cuePoints = useSelector((facade) => facade.cuePoints);
-
- const setSeeking = useAppStore((state) => state.setSeeking);
- const seeking = useAppStore((state) => state.seeking);
-
- const seekTo = useSeekTo();
- const fakeTime = useFakeTime();
-
- const updateValue = useCallback(
- (event: PointerEvent | React.PointerEvent) => {
- const rect = ref.current!.getBoundingClientRect();
- let x = (event.pageX - (rect.left + window.scrollX)) / rect.width;
- x = Math.min(Math.max(x, 0), 1);
- x *= duration;
-
- setValue(x);
-
- return x;
- },
- [duration],
- );
-
- const onPointerDown: PointerEventHandler = (event) => {
- event.preventDefault();
- updateValue(event);
- setSeeking(true);
- };
-
- const onPointerEnter = () => {
- setHover(true);
- };
-
- const onPointerLeave = () => {
- setHover(false);
- };
-
- useEffect(() => {
- const onPointerMove = (event: PointerEvent) => {
- updateValue(event);
- };
-
- window.addEventListener("pointermove", onPointerMove);
-
- return () => {
- window.removeEventListener("pointermove", onPointerMove);
- };
- }, [updateValue]);
-
- useEffect(() => {
- if (!seeking) {
- return;
- }
-
- const onPointerUp = (event: PointerEvent) => {
- const time = updateValue(event);
- seekTo(time);
- setSeeking(false);
- };
-
- window.addEventListener("pointerup", onPointerUp);
-
- return () => {
- window.removeEventListener("pointerup", onPointerUp);
- };
- }, [updateValue, seeking]);
-
- const active = seeking || hover;
- const progress = seeking ? value : fakeTime;
-
- return (
-
-
- {toHMS(value)}
-
-
- {active ? (
-
- ) : null}
-
-
- {cuePoints.map((cuePoint) => {
- if (cuePoint === 0) {
- // Do not show preroll.
- return null;
- }
- return (
-
- );
- })}
-
- );
-}
-
-function calculateTooltipLeft(
- value: number,
- duration: number,
- element: HTMLDivElement | null,
- tooltipElement: HTMLDivElement | null,
-) {
- let percentage = value / duration;
-
- if (element && tooltipElement) {
- const half = tooltipElement.clientWidth / 2;
- const bound = half / element.clientWidth;
- if (percentage < bound) {
- percentage = bound;
- } else if (percentage > 1 - bound) {
- percentage = 1 - bound;
- }
- }
-
- return `${percentage * 100}%`;
-}
diff --git a/packages/player/src/react/controls/components/QualitiesPane.tsx b/packages/player/src/react/controls/components/QualitiesPane.tsx
deleted file mode 100644
index 3fe5c42a..00000000
--- a/packages/player/src/react/controls/components/QualitiesPane.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import cn from "clsx";
-import { CheckList } from "./CheckList";
-import { Pane } from "./Pane";
-import { useFacade, useSelector } from "../..";
-import { useI18n } from "../hooks/useI18n";
-import type { Quality } from "../..";
-import type { CheckListItem } from "./CheckList";
-
-export function QualitiesPane() {
- const facade = useFacade();
- const qualities = useSelector((facade) => facade.qualities);
- const autoQuality = useSelector((facade) => facade.autoQuality);
- const l = useI18n();
-
- const qualityItems = qualities.map((it) => ({
- id: it.height,
- label: `${it.height}p`,
- checked: !autoQuality && it.active,
- }));
-
- qualityItems.push({
- id: null,
- label: getAutoLabel(qualities, autoQuality),
- checked: autoQuality,
- });
-
- return (
-
- facade.setQuality(id)}
- items={qualityItems}
- />
-
- );
-}
-
-function getAutoLabel(qualities: Quality[], autoQuality: boolean) {
- const l = useI18n();
- const height = qualities.find((it) => it.active)?.height ?? 0;
-
- return (
-
- {l("settings.quality.auto")}
- {`${height}p`}
-
- );
-}
diff --git a/packages/player/src/react/controls/components/Seekbar.tsx b/packages/player/src/react/controls/components/Seekbar.tsx
deleted file mode 100644
index dd5cc16b..00000000
--- a/packages/player/src/react/controls/components/Seekbar.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import cn from "clsx";
-import { InterstitialProgress } from "./InterstitialProgress";
-import { Progress } from "./Progress";
-import { TimeStat } from "./TimeStat";
-import { useSelector } from "../..";
-import { useAppStore } from "../hooks/useAppStore";
-
-export function Seekbar() {
- const interstitial = useSelector((facade) => facade.interstitial);
- const settings = useAppStore((state) => state.settings);
-
- return (
-
- {interstitial ? (
-
-
-
- ) : (
-
- )}
-
- );
-}
diff --git a/packages/player/src/react/controls/components/Settings.tsx b/packages/player/src/react/controls/components/Settings.tsx
deleted file mode 100644
index a2935d06..00000000
--- a/packages/player/src/react/controls/components/Settings.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import cn from "clsx";
-import { useEffect, useLayoutEffect, useRef } from "react";
-import { QualitiesPane } from "./QualitiesPane";
-import { SettingsPane } from "./SettingsPane";
-import { TextAudioPane } from "./TextAudioPane";
-import { useAppStore } from "../hooks/useAppStore";
-import usePrevious from "../hooks/usePrevious";
-import type { SettingsMode } from "../hooks/useAppSettings";
-
-export function Settings() {
- const settings = useAppStore((state) => state.settings);
- const ref = useRef(null);
-
- const mode = settings?.mode ?? null;
-
- const lastModeRef = useRef();
- if (mode !== null) {
- lastModeRef.current = mode;
- }
-
- const modePrev = usePrevious(mode);
-
- useEffect(() => {
- if (mode === null && modePrev) {
- lastModeRef.current = undefined;
-
- if (ref.current) {
- ref.current.style.width = "";
- ref.current.style.height = "";
- }
- }
- }, [modePrev, mode]);
-
- useLayoutEffect(() => {
- const element = ref.current;
- if (!element) {
- return;
- }
-
- const paneElements = element.querySelectorAll(
- "[data-sprs-settings-pane]",
- );
-
- Array.from(paneElements).map((el) => {
- el.style.width = "";
- el.style.height = "";
- el.style.position = "fixed";
-
- const rect = el.getBoundingClientRect();
-
- el.style.width = `${rect.width}px`;
- el.style.height = `${rect.height}px`;
- el.style.position = "absolute";
- });
-
- const activePane = element.querySelector(
- "[data-sprs-settings-pane=active]",
- );
- if (activePane) {
- const rect = activePane.getBoundingClientRect();
- element.style.width = `${rect.width}px`;
- element.style.height = `${rect.height}px`;
- }
- });
-
- const lastMode = lastModeRef.current;
-
- return (
-
-
-
-
-
-
-
-
- );
-}
diff --git a/packages/player/src/react/controls/components/SettingsPane.tsx b/packages/player/src/react/controls/components/SettingsPane.tsx
deleted file mode 100644
index 84e91d77..00000000
--- a/packages/player/src/react/controls/components/SettingsPane.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import cn from "clsx";
-
-interface SettingsPaneProps {
- children: React.ReactNode;
- active: boolean;
-}
-
-export function SettingsPane({ children, active }: SettingsPaneProps) {
- return (
-
- {children}
-
- );
-}
diff --git a/packages/player/src/react/controls/components/SqButton.tsx b/packages/player/src/react/controls/components/SqButton.tsx
deleted file mode 100644
index 956b59c7..00000000
--- a/packages/player/src/react/controls/components/SqButton.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import cn from "clsx";
-import { useRef } from "react";
-import { SqButtonTooltip } from "./SqButtonTooltip";
-import type { LangKey } from "../i18n";
-import type { MouseEventHandler } from "react";
-
-interface SqButtonProps {
- children: React.ReactNode;
- onClick: MouseEventHandler;
- onIdle?: () => void;
- idleTime?: number;
- selected?: boolean;
- disabled?: boolean;
- tooltip?: LangKey;
- tooltipPlacement?: "left" | "right";
-}
-
-export function SqButton({
- children,
- onClick,
- onIdle,
- idleTime,
- selected,
- disabled,
- tooltip,
- tooltipPlacement,
- ...rest
-}: SqButtonProps) {
- const timerRef = useRef();
-
- const onMouseEnter = () => {
- clearTimeout(timerRef.current);
- timerRef.current = window.setTimeout(() => {
- onIdle?.();
- }, idleTime ?? 200);
- };
-
- return (
-
- );
-}
diff --git a/packages/player/src/react/controls/components/SqButtonTooltip.tsx b/packages/player/src/react/controls/components/SqButtonTooltip.tsx
deleted file mode 100644
index e879c9cf..00000000
--- a/packages/player/src/react/controls/components/SqButtonTooltip.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import cn from "clsx";
-import { useSelector } from "../..";
-import { useI18n } from "../hooks/useI18n";
-import type { LangKey } from "../i18n";
-
-interface SqButtonTooltipProps {
- value: LangKey;
- placement?: "left" | "right";
-}
-
-export function SqButtonTooltip({ value, placement }: SqButtonTooltipProps) {
- const l = useI18n();
- const interstitial = useSelector((facade) => facade.interstitial);
-
- // We have a progress bar shown when interstitial is an ad, or
- // if we have no interstitial at all.
- const progress = interstitial?.type === "ad" || !interstitial;
-
- return (
-
- );
-}
diff --git a/packages/player/src/react/controls/components/Start.tsx b/packages/player/src/react/controls/components/Start.tsx
deleted file mode 100644
index ef3651af..00000000
--- a/packages/player/src/react/controls/components/Start.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import cn from "clsx";
-import { useFacade, useSelector } from "../..";
-import LoaderIcon from "../icons/loader.svg";
-import PlayIcon from "../icons/play.svg";
-
-export function Start() {
- const facade = useFacade();
- const ready = useSelector((facade) => facade.ready);
- const started = useSelector((facade) => facade.started);
- const play = useSelector((facade) => facade.playhead === "play");
-
- let hidden = started;
- if (!ready) {
- hidden = true;
- }
-
- const loading = play && !started;
-
- return (
-
- );
-}
diff --git a/packages/player/src/react/controls/components/TextAudioPane.tsx b/packages/player/src/react/controls/components/TextAudioPane.tsx
deleted file mode 100644
index 1b7d4b01..00000000
--- a/packages/player/src/react/controls/components/TextAudioPane.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { CheckList } from "./CheckList";
-import { Pane } from "./Pane";
-import { useFacade, useSelector } from "../..";
-import { useI18n } from "../hooks/useI18n";
-import type { CheckListItem } from "./CheckList";
-
-export function TextAudioPane() {
- const facade = useFacade();
- const subtitleTracks = useSelector((facade) => facade.subtitleTracks);
- const audioTracks = useSelector((facade) => facade.audioTracks);
- const l = useI18n();
-
- const subtitleItems = subtitleTracks.map((it) => ({
- id: it.id,
- label: it.label,
- checked: it.active,
- }));
-
- subtitleItems.push({
- id: null,
- label: l("settings.subtitle.none"),
- checked: !subtitleTracks.some((it) => it.active),
- });
-
- return (
-
- {subtitleItems.length ? (
-
- facade.setSubtitleTrack(id)}
- items={subtitleItems}
- />
-
- ) : null}
- {audioTracks.length ? (
-
- {
- if (id !== null) {
- facade.setAudioTrack(id);
- }
- }}
- items={audioTracks.map((it) => ({
- id: it.id,
- label: it.label,
- checked: it.active,
- }))}
- />
-
- ) : null}
-
- );
-}
diff --git a/packages/player/src/react/controls/components/TimeStat.tsx b/packages/player/src/react/controls/components/TimeStat.tsx
deleted file mode 100644
index ebeaa0f6..00000000
--- a/packages/player/src/react/controls/components/TimeStat.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { useSelector } from "../..";
-import { useFakeTime } from "../hooks/useFakeTime";
-import { toHMS } from "../utils";
-
-export function TimeStat() {
- const fakeTime = useFakeTime();
- const duration = useSelector((facade) => facade.duration);
- const ended = useSelector((facade) => facade.playhead === "ended");
-
- let remaining = Math.ceil(duration - fakeTime);
- if (ended) {
- remaining = 0;
- }
-
- const hms = toHMS(remaining);
-
- return (
-
- {hms}
-
- );
-}
diff --git a/packages/player/src/react/controls/components/VolumeButton.tsx b/packages/player/src/react/controls/components/VolumeButton.tsx
deleted file mode 100644
index 9806bdb0..00000000
--- a/packages/player/src/react/controls/components/VolumeButton.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import cn from "clsx";
-import { useRef, useState } from "react";
-import { SqButton } from "./SqButton";
-import Volume0Icon from "../icons/volume-0.svg";
-import Volume1Icon from "../icons/volume-1.svg";
-import Volume2Icon from "../icons/volume-2.svg";
-import VolumeMutedIcon from "../icons/volume-muted.svg";
-import type { CSSProperties, ReactEventHandler } from "react";
-
-const rangeStyle: CSSProperties = {
- writingMode: "vertical-lr",
- direction: "rtl",
- verticalAlign: "middle",
- WebkitAppearance: navigator.vendor?.includes("Apple")
- ? "slider-vertical"
- : undefined,
-};
-
-interface VolumeButtonProps {
- volume: number;
- setVolume(volume: number): void;
-}
-
-export function VolumeButton({ volume, setVolume }: VolumeButtonProps) {
- const [visible, setVisible] = useState(false);
- const volumeRef = useRef();
-
- const onChange: ReactEventHandler = (event) => {
- setVolume(event.currentTarget.valueAsNumber);
- };
-
- const onPointerLeave: ReactEventHandler = () => {
- if (visible) {
- setVisible(false);
- }
- };
-
- let Icon;
- const iconStyle: CSSProperties = {};
- if (volume > 0.66) {
- Icon = Volume2Icon;
- iconStyle.width = iconStyle.height = "1.6rem";
- } else if (volume > 0.33) {
- Icon = Volume1Icon;
- iconStyle.width = iconStyle.height = "1.4rem";
- iconStyle.left = "-0.1rem";
- } else if (volume > 0) {
- Icon = Volume0Icon;
- iconStyle.width = iconStyle.height = "1.07rem";
- iconStyle.left = "-0.48rem";
- } else {
- Icon = VolumeMutedIcon;
- iconStyle.width = iconStyle.height = "1.3rem";
- iconStyle.left = "-0.16rem";
- }
-
- return (
-
-
-
{
- if (volume !== 0) {
- volumeRef.current = volume;
- setVolume(0);
- } else {
- setVolume(volumeRef.current ?? 1);
- }
- }}
- onIdle={() => setVisible(true)}
- idleTime={0}
- >
-
-
-
- );
-}
diff --git a/packages/player/src/react/controls/context/AppStoreProvider.tsx b/packages/player/src/react/controls/context/AppStoreProvider.tsx
deleted file mode 100644
index 47de4a24..00000000
--- a/packages/player/src/react/controls/context/AppStoreProvider.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-import { createContext, useContext, useEffect, useState } from "react";
-import { createStore } from "zustand";
-import { ControllerContext, Events } from "../..";
-import type { Settings } from "../hooks/useAppSettings";
-import type { ReactNode } from "react";
-
-type AppStore = ReturnType;
-
-export interface AppState {
- seeking: boolean;
- setSeeking(value: boolean): void;
- targetTime: number | null;
- setTargetTime(value: number | null): void;
- visible: boolean;
- setVisible(value: boolean): void;
- settings: Settings | null;
- setSettings(value: Settings | null): void;
- fullscreen: boolean;
- setFullscreen(value: boolean): void;
-}
-
-export const StoreContext = createContext(
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
- {} as AppStore,
-);
-
-interface StoreProviderProps {
- children: ReactNode;
-}
-
-export function AppStoreProvider({ children }: StoreProviderProps) {
- const [store] = useState(createAppStore);
- const controller = useContext(ControllerContext);
-
- useEffect(() => {
- let prevTime = 0;
-
- const onChange = () => {
- const { targetTime, setTargetTime } = store.getState();
-
- if (targetTime === null) {
- return;
- }
-
- const { time, playhead } = controller.facade;
- const delta = time - prevTime;
- prevTime = time;
-
- if ((delta > 0 && time > targetTime) || playhead === "ended") {
- setTargetTime(null);
- }
- };
-
- return controller.subscribe(onChange);
- }, [controller]);
-
- useEffect(() => {
- const onReset = () => {
- const initialState = store.getInitialState();
- store.setState(initialState, true);
- };
- controller.facade.on(Events.RESET, onReset);
- return () => {
- controller.facade.off(Events.RESET, onReset);
- };
- }, [controller.facade]);
-
- return (
- {children}
- );
-}
-
-function createAppStore() {
- return createStore((set) => ({
- seeking: false,
- setSeeking: (seeking) => set({ seeking }),
- targetTime: null,
- setTargetTime: (targetTime) => set({ targetTime }),
- visible: false,
- setVisible: (visible) => set({ visible }),
- settings: null,
- setSettings: (settings) => set({ settings }),
- fullscreen: false,
- setFullscreen: (fullscreen) => set({ fullscreen }),
- }));
-}
diff --git a/packages/player/src/react/controls/context/ParamsProvider.tsx b/packages/player/src/react/controls/context/ParamsProvider.tsx
deleted file mode 100644
index c63f2eb5..00000000
--- a/packages/player/src/react/controls/context/ParamsProvider.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { createContext } from "react";
-import type { Lang, Metadata } from "../types";
-import type { ReactNode } from "react";
-
-export interface Params {
- metadata?: Metadata;
- lang?: Lang;
-}
-
-export const ParamsContext = createContext(
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
- {} as Params,
-);
-
-type ParamsProviderProps = {
- children: ReactNode;
-} & Params;
-
-export function ParamsProvider({
- children,
- metadata,
- lang,
-}: ParamsProviderProps) {
- return (
-
- {children}
-
- );
-}
diff --git a/packages/player/src/react/controls/hooks/useAppFullscreen.ts b/packages/player/src/react/controls/hooks/useAppFullscreen.ts
deleted file mode 100644
index e21a3de0..00000000
--- a/packages/player/src/react/controls/hooks/useAppFullscreen.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { useCallback, useEffect } from "react";
-import screenfull from "screenfull";
-import { useAppStore } from "../hooks/useAppStore";
-import type { MouseEventHandler } from "react";
-
-export function useAppFullscreen() {
- const setFullscreen = useAppStore((state) => state.setFullscreen);
-
- useEffect(() => {
- screenfull.on("change", () => {
- setFullscreen(screenfull.isFullscreen);
- });
- }, [screenfull, setFullscreen]);
-
- const onClick: MouseEventHandler = useCallback(
- (event) => {
- const element = event.target as HTMLElement;
- const container = element.closest("[data-sprs-container]");
- if (container) {
- screenfull.toggle(container);
- }
- },
- [screenfull],
- );
-
- return onClick;
-}
diff --git a/packages/player/src/react/controls/hooks/useAppSettings.ts b/packages/player/src/react/controls/hooks/useAppSettings.ts
deleted file mode 100644
index 547f6510..00000000
--- a/packages/player/src/react/controls/hooks/useAppSettings.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { useCallback, useEffect, useRef } from "react";
-import { useAppStore } from "../hooks/useAppStore";
-
-export type SettingsMode = "text-audio" | "quality";
-
-export interface Settings {
- entry: "hover" | "explicit";
- mode: SettingsMode;
-}
-
-export type SetAppSettings = ReturnType;
-
-export function useAppSettings() {
- const timerRef = useRef();
- const settings = useAppStore((state) => state.settings);
- const setSettings = useAppStore((state) => state.setSettings);
-
- useEffect(() => {
- if (!settings) {
- return;
- }
-
- if (settings.entry === "hover") {
- const onPointerMove = (event: PointerEvent) => {
- const isOver = isOverSettings(event.target);
-
- if (isOver && timerRef.current !== undefined) {
- clearTimeout(timerRef.current);
- timerRef.current = undefined;
- }
-
- if (!isOver && timerRef.current === undefined) {
- timerRef.current = window.setTimeout(() => {
- setSettings(null);
- timerRef.current = undefined;
- }, 200);
- }
- };
-
- window.addEventListener("pointermove", onPointerMove);
-
- return () => {
- window.removeEventListener("pointermove", onPointerMove);
- clearTimeout(timerRef.current);
- };
- }
-
- if (settings.entry === "explicit") {
- const onPointerDown = (event: PointerEvent) => {
- const isOver = isOverSettings(event.target);
- if (!isOver) {
- setSettings(null);
- }
- };
-
- window.addEventListener("pointerdown", onPointerDown);
-
- return () => {
- window.removeEventListener("pointerdown", onPointerDown);
- };
- }
- }, [settings]);
-
- const setAppSettings = useCallback(
- (mode: SettingsMode | null, hoverEntry?: boolean) => {
- if (
- mode === settings?.mode &&
- hoverEntry &&
- settings?.entry === "explicit"
- ) {
- return;
- }
-
- if (settings?.entry === "explicit" && settings.mode === mode) {
- setSettings(null);
- return;
- }
-
- if (mode === null) {
- setSettings(null);
- } else {
- setSettings({
- mode,
- entry: hoverEntry ? "hover" : "explicit",
- });
- }
- },
- [setSettings, settings],
- );
-
- return setAppSettings;
-}
-
-function matchElement(target: EventTarget | null, attr: string) {
- if (!target) {
- return null;
- }
- const element = target as HTMLElement;
- if (element.hasAttribute(attr)) {
- return element;
- }
- const parent = element.closest(`[${attr}]`);
- if (parent) {
- return parent as HTMLElement;
- }
- return null;
-}
-
-function isOverSettings(target: EventTarget | null) {
- let isOver = false;
-
- const container = matchElement(target, "data-sprs-container");
- if (container?.querySelector("[data-sprs-settings")?.matches(":hover")) {
- isOver = true;
- }
-
- const element = matchElement(target, "data-sprs-settings-action");
- if (element?.matches(":hover")) {
- isOver = true;
- }
-
- return isOver;
-}
diff --git a/packages/player/src/react/controls/hooks/useAppStore.ts b/packages/player/src/react/controls/hooks/useAppStore.ts
deleted file mode 100644
index fb38e058..00000000
--- a/packages/player/src/react/controls/hooks/useAppStore.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { useContext } from "react";
-import { useStore } from "zustand";
-import { StoreContext } from "../context/AppStoreProvider";
-import type { AppState } from "../context/AppStoreProvider";
-
-export function useAppStore(selector: (state: AppState) => T) {
- const store = useContext(StoreContext);
- return useStore(store, selector);
-}
diff --git a/packages/player/src/react/controls/hooks/useAppVisible.ts b/packages/player/src/react/controls/hooks/useAppVisible.ts
deleted file mode 100644
index 643d19b6..00000000
--- a/packages/player/src/react/controls/hooks/useAppVisible.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { useCallback, useEffect, useRef } from "react";
-import { useAppStore } from "../hooks/useAppStore";
-
-export function useAppVisible() {
- const timerRef = useRef();
- const elementRef = useRef(null);
- const setVisible = useAppStore((state) => state.setVisible);
-
- const onPointerMove = useCallback(() => {
- clearTimeout(timerRef.current);
-
- setVisible(true);
- timerRef.current = window.setTimeout(() => {
- setVisible(false);
- }, 3000);
- }, [setVisible]);
-
- const nudge = useCallback(() => {
- onPointerMove();
- }, [onPointerMove]);
-
- useEffect(() => {
- const el = elementRef.current?.closest("[data-sprs-container]");
- if (!el) {
- return;
- }
-
- const container = el as HTMLDivElement;
-
- const onPointerLeave = () => {
- clearTimeout(timerRef.current);
- setVisible(false);
- };
-
- container.addEventListener("pointerdown", nudge);
- container.addEventListener("pointermove", onPointerMove);
- container.addEventListener("pointerleave", onPointerLeave);
-
- return () => {
- container.removeEventListener("pointerdown", nudge);
- container.removeEventListener("pointermove", onPointerMove);
- container.removeEventListener("pointerleave", onPointerLeave);
- };
- }, [onPointerMove, setVisible, nudge]);
-
- return [elementRef, nudge] as const;
-}
diff --git a/packages/player/src/react/controls/hooks/useDelta.ts b/packages/player/src/react/controls/hooks/useDelta.ts
deleted file mode 100644
index c99445c3..00000000
--- a/packages/player/src/react/controls/hooks/useDelta.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import usePrevious from "./usePrevious";
-
-export function useDelta(value: number) {
- const prev = usePrevious(value);
- return prev === undefined ? 0 : value - prev;
-}
diff --git a/packages/player/src/react/controls/hooks/useFakeTime.ts b/packages/player/src/react/controls/hooks/useFakeTime.ts
deleted file mode 100644
index 203454a4..00000000
--- a/packages/player/src/react/controls/hooks/useFakeTime.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { useSelector } from "../..";
-import { useAppStore } from "../hooks/useAppStore";
-
-export function useFakeTime() {
- const time = useSelector((facade) => facade.time);
- const targetTime = useAppStore((state) => state.targetTime);
-
- let fakeTime = time;
- if (targetTime !== null) {
- fakeTime = targetTime;
- }
-
- return fakeTime;
-}
diff --git a/packages/player/src/react/controls/hooks/useI18n.ts b/packages/player/src/react/controls/hooks/useI18n.ts
deleted file mode 100644
index d8fce6ed..00000000
--- a/packages/player/src/react/controls/hooks/useI18n.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { useCallback, useContext } from "react";
-import { ParamsContext } from "../context/ParamsProvider";
-import { defaultLangMap, langMap } from "../i18n";
-import type { LangKey } from "../i18n";
-
-export function useI18n() {
- const { lang } = useContext(ParamsContext);
-
- const getText = useCallback(
- (key: LangKey) => langMap[lang ?? "eng"][key] ?? defaultLangMap[key],
- [lang, langMap],
- );
-
- return getText;
-}
diff --git a/packages/player/src/react/controls/hooks/useParams.ts b/packages/player/src/react/controls/hooks/useParams.ts
deleted file mode 100644
index df01957e..00000000
--- a/packages/player/src/react/controls/hooks/useParams.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { useContext } from "react";
-import { ParamsContext } from "../context/ParamsProvider";
-
-export function useParams() {
- return useContext(ParamsContext);
-}
diff --git a/packages/player/src/react/controls/hooks/usePrevious.ts b/packages/player/src/react/controls/hooks/usePrevious.ts
deleted file mode 100644
index 290ec241..00000000
--- a/packages/player/src/react/controls/hooks/usePrevious.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { useEffect, useRef } from "react";
-
-/**
- * Tracks previous state of a value.
- *
- * @param value Props, state or any other calculated value.
- * @returns Value from the previous render of the enclosing component.
- *
- * @example
- * function Component() {
- * const [count, setCount] = useState(0);
- * const prevCount = usePrevious(count);
- * // ...
- * return `Now: ${count}, before: ${prevCount}`;
- * }
- */
-export default function usePrevious(value: T): T | undefined {
- // Source: https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state
- const ref = useRef();
- useEffect(() => {
- ref.current = value;
- });
- return ref.current;
-}
diff --git a/packages/player/src/react/controls/hooks/useSeekTo.ts b/packages/player/src/react/controls/hooks/useSeekTo.ts
deleted file mode 100644
index d331c370..00000000
--- a/packages/player/src/react/controls/hooks/useSeekTo.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { useCallback } from "react";
-import { useFacade } from "../..";
-import { useAppStore } from "../hooks/useAppStore";
-
-export function useSeekTo() {
- const facade = useFacade();
- const setTargetTime = useAppStore((state) => state.setTargetTime);
-
- const seekTo = useCallback(
- (targetTime: number) => {
- facade.seekTo(targetTime);
- setTargetTime(targetTime);
- },
- [facade, setTargetTime],
- );
-
- return seekTo;
-}
diff --git a/packages/player/src/react/controls/hooks/useShowTextAudio.ts b/packages/player/src/react/controls/hooks/useShowTextAudio.ts
deleted file mode 100644
index 5711d7c8..00000000
--- a/packages/player/src/react/controls/hooks/useShowTextAudio.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { useSelector } from "../..";
-
-export function useShowTextAudio() {
- const hasAudioTracks = useSelector((facade) => facade.audioTracks.length > 0);
- const hasSubtitleTracks = useSelector(
- (facade) => facade.subtitleTracks.length > 0,
- );
- return hasAudioTracks || hasSubtitleTracks;
-}
diff --git a/packages/player/src/react/controls/hooks/useVisibleControls.ts b/packages/player/src/react/controls/hooks/useVisibleControls.ts
deleted file mode 100644
index 9467f4d3..00000000
--- a/packages/player/src/react/controls/hooks/useVisibleControls.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { useSelector } from "../..";
-import { useAppStore } from "../hooks/useAppStore";
-
-export function useVisibleControls() {
- const started = useSelector((facade) => facade.started);
-
- const visible = useAppStore((state) => state.visible);
- const settings = useAppStore((state) => state.settings);
- const seeking = useAppStore((state) => state.seeking);
-
- if (!started) {
- return false;
- }
-
- return visible || settings !== null || seeking;
-}
diff --git a/packages/player/src/react/controls/i18n.ts b/packages/player/src/react/controls/i18n.ts
deleted file mode 100644
index 3ff25269..00000000
--- a/packages/player/src/react/controls/i18n.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-type OverrideMap = Partial>;
-
-export type LangKey = keyof typeof defaultLangMap;
-
-export const defaultLangMap = {
- "button.play": "Play",
- "button.pause": "Pause",
- "button.fullscreen": "Fullscreen",
- "button.exit-fullscreen": "Exit fullscreen",
- "settings.quality.title": "Quality",
- "settings.quality.auto": "Auto",
- "settings.subtitle.none": "None",
- "settings.subtitle.title": "Subtitles",
- "settings.audio.title": "Audio",
-} as const;
-
-export const langMap: Record<"eng" | "nld", OverrideMap> = {
- eng: defaultLangMap,
- nld: {
- "button.play": "Afspelen",
- "button.pause": "Pauseren",
- "settings.quality.title": "Kwaliteit",
- "settings.subtitle.none": "Geen",
- "settings.subtitle.title": "Ondertitels",
- },
-} as const;
diff --git a/packages/player/src/react/controls/icons/forward.svg b/packages/player/src/react/controls/icons/forward.svg
deleted file mode 100644
index 0beb06f5..00000000
--- a/packages/player/src/react/controls/icons/forward.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
\ No newline at end of file
diff --git a/packages/player/src/react/controls/icons/fullscreen-exit.svg b/packages/player/src/react/controls/icons/fullscreen-exit.svg
deleted file mode 100644
index cf5859ea..00000000
--- a/packages/player/src/react/controls/icons/fullscreen-exit.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
\ No newline at end of file
diff --git a/packages/player/src/react/controls/icons/fullscreen.svg b/packages/player/src/react/controls/icons/fullscreen.svg
deleted file mode 100644
index 5994322c..00000000
--- a/packages/player/src/react/controls/icons/fullscreen.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
\ No newline at end of file
diff --git a/packages/player/src/react/controls/icons/loader.svg b/packages/player/src/react/controls/icons/loader.svg
deleted file mode 100644
index 6e189635..00000000
--- a/packages/player/src/react/controls/icons/loader.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
\ No newline at end of file
diff --git a/packages/player/src/react/controls/icons/pause.svg b/packages/player/src/react/controls/icons/pause.svg
deleted file mode 100644
index 2d4f8036..00000000
--- a/packages/player/src/react/controls/icons/pause.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
\ No newline at end of file
diff --git a/packages/player/src/react/controls/icons/play.svg b/packages/player/src/react/controls/icons/play.svg
deleted file mode 100644
index e20d7e22..00000000
--- a/packages/player/src/react/controls/icons/play.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
\ No newline at end of file
diff --git a/packages/player/src/react/controls/icons/settings.svg b/packages/player/src/react/controls/icons/settings.svg
deleted file mode 100644
index 303302fb..00000000
--- a/packages/player/src/react/controls/icons/settings.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
\ No newline at end of file
diff --git a/packages/player/src/react/controls/icons/subtitles.svg b/packages/player/src/react/controls/icons/subtitles.svg
deleted file mode 100644
index 1b6be655..00000000
--- a/packages/player/src/react/controls/icons/subtitles.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
\ No newline at end of file
diff --git a/packages/player/src/react/controls/icons/volume-0.svg b/packages/player/src/react/controls/icons/volume-0.svg
deleted file mode 100644
index 792ab0e2..00000000
--- a/packages/player/src/react/controls/icons/volume-0.svg
+++ /dev/null
@@ -1,2 +0,0 @@
-
-
\ No newline at end of file
diff --git a/packages/player/src/react/controls/icons/volume-1.svg b/packages/player/src/react/controls/icons/volume-1.svg
deleted file mode 100644
index 3d09c205..00000000
--- a/packages/player/src/react/controls/icons/volume-1.svg
+++ /dev/null
@@ -1,2 +0,0 @@
-
-
\ No newline at end of file
diff --git a/packages/player/src/react/controls/icons/volume-2.svg b/packages/player/src/react/controls/icons/volume-2.svg
deleted file mode 100644
index fc2f535c..00000000
--- a/packages/player/src/react/controls/icons/volume-2.svg
+++ /dev/null
@@ -1,2 +0,0 @@
-
-
\ No newline at end of file
diff --git a/packages/player/src/react/controls/icons/volume-muted.svg b/packages/player/src/react/controls/icons/volume-muted.svg
deleted file mode 100644
index a8f457c7..00000000
--- a/packages/player/src/react/controls/icons/volume-muted.svg
+++ /dev/null
@@ -1,2 +0,0 @@
-
-
\ No newline at end of file
diff --git a/packages/player/src/react/controls/index.tsx b/packages/player/src/react/controls/index.tsx
deleted file mode 100644
index 85a49e4e..00000000
--- a/packages/player/src/react/controls/index.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-import { insertGenericStyle } from "./utils";
-
-export * from "./Controls";
-export * from "./types";
-
-insertGenericStyle();
diff --git a/packages/player/src/react/controls/types.ts b/packages/player/src/react/controls/types.ts
deleted file mode 100644
index a075bd9f..00000000
--- a/packages/player/src/react/controls/types.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import type { langMap } from "./i18n";
-
-export interface Metadata {
- title?: string;
- subtitle?: string;
-}
-
-export type Lang = keyof typeof langMap;
diff --git a/packages/player/src/react/controls/utils.ts b/packages/player/src/react/controls/utils.ts
deleted file mode 100644
index 0f256b6c..00000000
--- a/packages/player/src/react/controls/utils.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-export function toHMS(seconds: number) {
- const pad = (value: number) =>
- (10 ** 2 + Math.floor(value)).toString().substring(1);
-
- seconds = Math.floor(seconds);
- if (seconds < 0) {
- seconds = 0;
- }
-
- let result = "";
-
- const h = Math.trunc(seconds / 3600) % 24;
- if (h) {
- result += `${pad(h)}:`;
- }
-
- const m = Math.trunc(seconds / 60) % 60;
- result += `${pad(m)}:`;
-
- const s = Math.trunc(seconds % 60);
- result += `${pad(s)}`;
-
- return result;
-}
-
-export function insertGenericStyle() {
- const style = document.createElement("style");
- style.setAttribute("data-sprs-style", "");
- style.innerText = `
- [data-sprs-container] video::-webkit-media-text-track-container {
- transform: scale(0.95);
- }
-`;
- document.head.appendChild(style);
-}
diff --git a/packages/player/src/react/hooks/useController.ts b/packages/player/src/react/hooks/useController.ts
deleted file mode 100644
index 08495db6..00000000
--- a/packages/player/src/react/hooks/useController.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import { HlsFacade } from "..";
-import type { HlsFacadeOptions } from "..";
-import type Hls from "hls.js";
-
-type MediaRefCallback = (media: HTMLMediaElement | null) => void;
-
-export type Controller = ReturnType;
-
-export function useController(
- hls: Hls,
- userOptions?: Partial,
-) {
- const [facade] = useState(() => new HlsFacade(hls, userOptions));
- const lastMediaRef = useRef(null);
-
- useEffect(() => {
- return () => {
- if (document.body.contains(lastMediaRef.current)) {
- // If element is still in DOM, don't destroy. Omits strict mode too.
- return;
- }
- facade.destroy();
- };
- }, [facade]);
-
- const mediaRef = useCallback((media: HTMLMediaElement | null) => {
- lastMediaRef.current = media;
- if (media) {
- hls.attachMedia(media);
- } else {
- hls.detachMedia();
- }
- }, []);
-
- const controllerRef = useRef();
- if (!controllerRef.current) {
- controllerRef.current = createController(facade, mediaRef);
- }
-
- return controllerRef.current;
-}
-
-function createController(facade: HlsFacade, mediaRef: MediaRefCallback) {
- const listeners = new Set<() => void>();
-
- let lastFacade = [facade];
-
- facade.on("*", () => {
- lastFacade = [facade];
- for (const listener of listeners) {
- listener();
- }
- });
-
- const subscribe = (listener: () => void) => {
- listeners.add(listener);
- return () => {
- listeners.delete(listener);
- };
- };
-
- const getSnapshot = () => lastFacade;
-
- return {
- facade,
- mediaRef,
- subscribe,
- getSnapshot,
- };
-}
diff --git a/packages/player/src/react/hooks/useFacade.ts b/packages/player/src/react/hooks/useFacade.ts
deleted file mode 100644
index 3bffcd03..00000000
--- a/packages/player/src/react/hooks/useFacade.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { useContext } from "react";
-import { ControllerContext } from "../ControllerProvider";
-
-export function useFacade() {
- const { facade } = useContext(ControllerContext);
- return facade;
-}
diff --git a/packages/player/src/react/hooks/useSelector.ts b/packages/player/src/react/hooks/useSelector.ts
deleted file mode 100644
index 617f6125..00000000
--- a/packages/player/src/react/hooks/useSelector.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { useCallback, useContext } from "react";
-import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
-import { ControllerContext } from "../ControllerProvider";
-import type { HlsFacade } from "..";
-
-export function useSelector(selector: (snapshot: HlsFacade) => Sel) {
- const controller = useContext(ControllerContext);
-
- type Snapshot = ReturnType;
- const unwrapSelector = useCallback(
- ([facade]: Snapshot) => selector(facade),
- [],
- );
-
- return useSyncExternalStoreWithSelector(
- controller.subscribe,
- controller.getSnapshot,
- controller.getSnapshot,
- unwrapSelector,
- isEqual,
- );
-}
-
-function isEqual(objA: T, objB: T) {
- if (Object.is(objA, objB)) {
- return true;
- }
- if (
- typeof objA !== "object" ||
- objA === null ||
- typeof objB !== "object" ||
- objB === null
- ) {
- return false;
- }
-
- if (objA instanceof Map && objB instanceof Map) {
- if (objA.size !== objB.size) return false;
-
- for (const [key, value] of objA) {
- if (!Object.is(value, objB.get(key))) {
- return false;
- }
- }
- return true;
- }
-
- if (objA instanceof Set && objB instanceof Set) {
- if (objA.size !== objB.size) return false;
-
- for (const value of objA) {
- if (!objB.has(value)) {
- return false;
- }
- }
- return true;
- }
-
- const keysA = Object.keys(objA);
- if (keysA.length !== Object.keys(objB).length) {
- return false;
- }
- for (const key of keysA) {
- if (
- !Object.prototype.hasOwnProperty.call(objB, key as string) ||
- !Object.is(objA[key as keyof T], objB[key as keyof T])
- ) {
- return false;
- }
- }
- return true;
-}
diff --git a/packages/player/src/react/index.tsx b/packages/player/src/react/index.tsx
deleted file mode 100644
index 2af92bb7..00000000
--- a/packages/player/src/react/index.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-export * from "./controls";
-export * from "./ControllerProvider";
-export * from "./hooks/useController";
-export * from "./hooks/useSelector";
-export * from "./hooks/useFacade";
-export * from "../facade";
diff --git a/packages/player/src/state.ts b/packages/player/src/state.ts
new file mode 100644
index 00000000..5bc5d6b6
--- /dev/null
+++ b/packages/player/src/state.ts
@@ -0,0 +1,255 @@
+import { preciseFloat } from "./helpers";
+import { Events } from "./types";
+import type {
+ Asset,
+ AudioTrack,
+ HlsPlayerEventMap,
+ Interstitial,
+ Playhead,
+ Quality,
+ SubtitleTrack,
+} from "./types";
+import type { EventEmitter } from "tseep";
+
+interface MediaShim {
+ currentTime: number;
+ duration: number;
+}
+
+interface StateParams {
+ emitter: EventEmitter;
+ getTiming(): {
+ primary?: MediaShim | null;
+ asset?: MediaShim | null;
+ };
+}
+
+interface StateProperties {
+ ready: boolean;
+ playhead: Playhead;
+ started: boolean;
+ time: number;
+ duration: number;
+ interstitial: Interstitial | null;
+ qualities: Quality[];
+ autoQuality: boolean;
+ audioTracks: AudioTrack[];
+ subtitleTracks: SubtitleTrack[];
+ volume: number;
+ seeking: boolean;
+ cuePoints: number[];
+}
+
+const noState: StateProperties = {
+ playhead: "idle",
+ ready: false,
+ started: false,
+ time: 0,
+ duration: NaN,
+ interstitial: null,
+ qualities: [],
+ autoQuality: false,
+ audioTracks: [],
+ subtitleTracks: [],
+ volume: 1,
+ seeking: false,
+ cuePoints: [],
+};
+
+export class State implements StateProperties {
+ private timerId_: number | undefined;
+
+ constructor(private params_: StateParams) {
+ this.requestTimingSync();
+ }
+
+ setReady() {
+ if (this.ready) {
+ return;
+ }
+ this.ready = true;
+ this.requestTimingSync();
+ this.emit_(Events.READY);
+ }
+
+ setPlayhead(playhead: Playhead) {
+ if (playhead === this.playhead) {
+ return;
+ }
+
+ this.playhead = playhead;
+
+ if (playhead === "pause") {
+ this.requestTimingSync();
+ }
+
+ this.emit_(Events.PLAYHEAD_CHANGE);
+ }
+
+ setStarted() {
+ if (this.started) {
+ return;
+ }
+ this.started = true;
+ this.emit_(Events.STARTED);
+ }
+
+ setInterstitial(interstitial: Interstitial | null) {
+ this.interstitial = interstitial;
+ this.emit_(Events.INTERSTITIAL_CHANGE);
+ }
+
+ setAsset(asset: Omit | null) {
+ if (!this.interstitial) {
+ return;
+ }
+ if (asset) {
+ this.interstitial.asset = {
+ time: 0,
+ duration: NaN,
+ ...asset,
+ };
+ this.requestTimingSync();
+ } else {
+ this.interstitial.asset = null;
+ }
+ }
+
+ setQualities(qualities: Quality[], autoQuality: boolean) {
+ const diff = (items: Quality[]) =>
+ items.find((item) => item.active)?.height;
+
+ if (diff(this.qualities) !== diff(qualities)) {
+ this.qualities = qualities;
+ this.emit_(Events.QUALITIES_CHANGE);
+ }
+
+ if (autoQuality !== this.autoQuality) {
+ this.autoQuality = autoQuality;
+ this.emit_(Events.AUTO_QUALITY_CHANGE);
+ }
+ }
+
+ setAudioTracks(audioTracks: AudioTrack[]) {
+ const diff = (items: AudioTrack[]) => items.find((item) => item.active)?.id;
+
+ if (diff(this.audioTracks) !== diff(audioTracks)) {
+ this.audioTracks = audioTracks;
+ this.emit_(Events.AUDIO_TRACKS_CHANGE);
+ }
+ }
+
+ setSubtitleTracks(subtitleTracks: SubtitleTrack[]) {
+ const diff = (items: AudioTrack[]) => items.find((item) => item.active)?.id;
+
+ if (
+ // TODO: Come up with a generic logical check.
+ (!this.subtitleTracks.length && subtitleTracks.length) ||
+ diff(this.subtitleTracks) !== diff(subtitleTracks)
+ ) {
+ this.subtitleTracks = subtitleTracks;
+ this.emit_(Events.SUBTITLE_TRACKS_CHANGE);
+ }
+ }
+
+ setVolume(volume: number) {
+ if (volume === this.volume) {
+ return;
+ }
+ this.volume = volume;
+ this.emit_(Events.VOLUME_CHANGE);
+ }
+
+ setSeeking(seeking: boolean) {
+ if (seeking === this.seeking) {
+ return;
+ }
+ this.seeking = seeking;
+ this.emit_(Events.SEEKING_CHANGE);
+ }
+
+ setCuePoints(cuePoints: number[]) {
+ this.cuePoints = cuePoints;
+ this.emit_(Events.CUEPOINTS_CHANGE);
+ }
+
+ requestTimingSync() {
+ clearTimeout(this.timerId_);
+ this.timerId_ = window.setTimeout(() => {
+ this.requestTimingSync();
+ }, 250);
+
+ const timing = this.params_.getTiming();
+
+ let shouldEmit = false;
+
+ if (this.updateTimeDuration_(this, timing.primary)) {
+ shouldEmit = true;
+ }
+
+ if (
+ this.interstitial?.asset &&
+ this.updateTimeDuration_(this.interstitial.asset, timing.asset)
+ ) {
+ shouldEmit = true;
+ }
+
+ if (shouldEmit) {
+ this.emit_(Events.TIME_CHANGE);
+ }
+ }
+
+ private updateTimeDuration_(
+ target: {
+ time: number;
+ duration: number;
+ seekableStart?: number;
+ },
+ shim?: MediaShim | null,
+ ) {
+ if (!shim) {
+ return false;
+ }
+ if (!Number.isFinite(shim.duration)) {
+ return false;
+ }
+
+ const oldTime = target.time;
+ target.time = preciseFloat(shim.currentTime);
+
+ const oldDuration = target.duration;
+ target.duration = preciseFloat(shim.duration);
+
+ if (target.time > target.duration) {
+ target.time = target.duration;
+ }
+
+ return oldTime !== target.time || oldDuration !== target.duration;
+ }
+
+ private emit_(event: Events) {
+ this.params_.emitter.emit(event);
+ this.params_.emitter.emit("*", event);
+ }
+
+ ready = noState.ready;
+ playhead = noState.playhead;
+ started = noState.started;
+ time = noState.time;
+ duration = noState.duration;
+ interstitial = noState.interstitial;
+ qualities = noState.qualities;
+ autoQuality = noState.autoQuality;
+ audioTracks = noState.audioTracks;
+ subtitleTracks = noState.subtitleTracks;
+ volume = noState.volume;
+ seeking = noState.seeking;
+ cuePoints = noState.cuePoints;
+}
+
+export function getState(
+ state: State | null,
+ name: N,
+) {
+ return state?.[name] ?? noState[name];
+}
diff --git a/packages/player/src/svg.d.ts b/packages/player/src/svg.d.ts
deleted file mode 100644
index 48f2414e..00000000
--- a/packages/player/src/svg.d.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-declare module "*.svg" {
- import type * as React from "react";
-
- const ReactComponent: React.FunctionComponent>;
-
- export default ReactComponent;
-}
diff --git a/packages/player/src/types.ts b/packages/player/src/types.ts
new file mode 100644
index 00000000..45baa2cc
--- /dev/null
+++ b/packages/player/src/types.ts
@@ -0,0 +1,65 @@
+import type { Level, MediaPlaylist } from "hls.js";
+
+export type Playhead = "idle" | "play" | "playing" | "pause" | "ended";
+
+export enum Events {
+ READY = "ready",
+ STARTED = "started",
+ PLAYHEAD_CHANGE = "playheadChange",
+ TIME_CHANGE = "timeChange",
+ VOLUME_CHANGE = "volumeChange",
+ QUALITIES_CHANGE = "qualitiesChange",
+ AUDIO_TRACKS_CHANGE = "audioTracksChange",
+ SUBTITLE_TRACKS_CHANGE = "subtitleTracksChange",
+ AUTO_QUALITY_CHANGE = "autoQualityChange",
+ INTERSTITIAL_CHANGE = "interstitialChange",
+ SEEKING_CHANGE = "seekingChange",
+ CUEPOINTS_CHANGE = "cuePointsChange",
+}
+
+export type HlsPlayerEventMap = {
+ [Events.READY]: () => void;
+ [Events.STARTED]: () => void;
+ [Events.PLAYHEAD_CHANGE]: () => void;
+ [Events.TIME_CHANGE]: () => void;
+ [Events.VOLUME_CHANGE]: () => void;
+ [Events.QUALITIES_CHANGE]: () => void;
+ [Events.AUDIO_TRACKS_CHANGE]: () => void;
+ [Events.SUBTITLE_TRACKS_CHANGE]: () => void;
+ [Events.AUTO_QUALITY_CHANGE]: () => void;
+ [Events.INTERSTITIAL_CHANGE]: () => void;
+ [Events.SEEKING_CHANGE]: () => void;
+ [Events.CUEPOINTS_CHANGE]: () => void;
+} & {
+ "*": (event: Events) => void;
+};
+
+export interface Quality {
+ height: number;
+ active: boolean;
+ levels: Level[];
+}
+
+export interface AudioTrack {
+ id: number;
+ active: boolean;
+ label: string;
+ track: MediaPlaylist;
+}
+
+export interface SubtitleTrack {
+ id: number;
+ active: boolean;
+ label: string;
+ track: MediaPlaylist;
+}
+
+export interface Asset {
+ time: number;
+ duration: number;
+ type?: "ad" | "bumper";
+}
+
+export interface Interstitial {
+ asset: Asset | null;
+}
diff --git a/packages/player/tsup.config.ts b/packages/player/tsup.config.ts
index f009511b..40b823a9 100644
--- a/packages/player/tsup.config.ts
+++ b/packages/player/tsup.config.ts
@@ -1,15 +1,13 @@
-import svgr from "esbuild-plugin-svgr";
import { defineConfig } from "tsup";
export default defineConfig({
entry: {
- index: "./src/facade/index.ts",
- react: "./src/react/index.tsx",
+ index: "./src/index.ts",
},
splitting: false,
sourcemap: true,
clean: false,
dts: true,
format: "esm",
- esbuildPlugins: [svgr()],
+ noExternal: ["shared", "tseep"],
});
diff --git a/packages/stitcher/src/session.ts b/packages/stitcher/src/session.ts
index 965fb72d..2236278d 100644
--- a/packages/stitcher/src/session.ts
+++ b/packages/stitcher/src/session.ts
@@ -60,10 +60,8 @@ export async function createSession(params: {
appendInterstitials(session.interstitials, interstitials);
}
- // We'll initially store the session for 10 minutes, if it's not been consumed
- // within the timeframe, it's gone.
const value = JSON.stringify(session);
- await kv.set(`session:${id}`, value, 60 * 10);
+ await kv.set(`session:${id}`, value, session.expiry);
return session;
}