@banou/media-player 0.8.18 → 0.8.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ // What crosses between a player and whoever embeds it. Pure and osra-free so the snapshot and the
2
+ // mirror's arithmetic can be tested on values; ./index.ts is the half that talks.
3
+
4
+ import type { PlayerMedia, TimeRangesLike } from '../react/media'
5
+
6
+ /**
7
+ * Everything readable about a player, as one plain object.
8
+ *
9
+ * A media's properties are SYNCHRONOUS (`currentTime` is a getter that must answer now) and every
10
+ * cross-document transport is not, so the far side is mirrored: the player's document sends one of
11
+ * these with every event it raises, and the embedder answers reads from the latest one it holds.
12
+ * Plain pairs for the ranges, because a `TimeRanges` has nothing to hand across a boundary.
13
+ */
14
+ export type PlayerSnapshot = {
15
+ currentTime: number
16
+ duration: number
17
+ paused: boolean
18
+ ended: boolean
19
+ seeking: boolean
20
+ readyState: number
21
+ volume: number
22
+ muted: boolean
23
+ playbackRate: number
24
+ src: string
25
+ currentSrc: string
26
+ buffered: [number, number][]
27
+ seekable: [number, number][]
28
+ error: { code: number, message: string } | null
29
+ videoWidth: number
30
+ videoHeight: number
31
+ }
32
+
33
+ /**
34
+ * The events a player raises that an embedder can hear, each carrying a fresh snapshot.
35
+ *
36
+ * Every event video.js's own features sync on is here, `emptied` and `loadstart` included: a store
37
+ * attached to the mirror resets on those, and an entry costs nothing until it fires.
38
+ */
39
+ export const PLAYER_EVENTS = [
40
+ 'loadstart', 'loadedmetadata', 'loadeddata', 'durationchange', 'timeupdate', 'play', 'playing', 'pause',
41
+ 'seeking', 'seeked', 'progress', 'ratechange', 'volumechange', 'ended', 'waiting', 'stalled', 'suspend',
42
+ 'canplay', 'canplaythrough', 'emptied', 'resize', 'error',
43
+ ] as const
44
+ export type PlayerEvent = typeof PLAYER_EVENTS[number]
45
+
46
+ /** The properties an embedder may set. Everything else is read-only on a media, here as there. */
47
+ export const WRITABLE = ['currentTime', 'volume', 'muted', 'playbackRate'] as const
48
+ export type Writable = typeof WRITABLE[number]
49
+
50
+ /** The methods an embedder may call. */
51
+ export const CALLABLE = ['play', 'pause', 'load'] as const
52
+ export type Callable = typeof CALLABLE[number]
53
+
54
+ /** The one channel both sides default to, so a page can run other osra channels over the same window. */
55
+ export const PLAYER_CHANNEL = 'banou-media-player'
56
+
57
+ /**
58
+ * Which player, for a document that serves more than one.
59
+ *
60
+ * A document with one player never says it; both sides default to this and find each other. A
61
+ * document with several gives each an id and an embedder asks for the one it wants. The ids are the
62
+ * serving document's to choose and the embedder has to know them, the same way it knows the url it
63
+ * framed.
64
+ */
65
+ export const DEFAULT_PLAYER_ID = 'default'
66
+
67
+ /** What rides the event port: the event's name and the state right after it. */
68
+ export type PlayerUpdate = { event: PlayerEvent, snapshot: PlayerSnapshot }
69
+
70
+ /**
71
+ * What the player's document serves, one document at a time, addressed by player id. Internal: an
72
+ * embedder never sees it, `mediaPlayer` wraps it.
73
+ *
74
+ * `subscribe` takes a MessagePort rather than a function on purpose. Events are ONE-WAY: the player
75
+ * posts every update on the port and never waits for an answer, so an embedder that vanished without
76
+ * closing (a tab killed, a frame torn out) pins nothing in the player's document, where a function
77
+ * handle called across a dead connection would leave a promise pending per event, unbounded.
78
+ *
79
+ * There is no `snapshot`: subscribing is what answers with the state, in one round trip instead of
80
+ * two, and it cannot miss what happened between them. Subscribing to an id nothing is serving is
81
+ * allowed and says nothing back until something is, which is how an embedder waits for a player that
82
+ * has not mounted yet.
83
+ */
84
+ export type PlayerService = {
85
+ subscribe: (id: string, port: MessagePort) => void
86
+ set: (id: string, name: Writable, value: number | boolean) => void
87
+ call: (id: string, name: Callable) => Promise<void>
88
+ }
89
+
90
+ /**
91
+ * The events that bring a store attached to the mirror up to date from a snapshot alone, in the
92
+ * order an element would raise them on load. Dispatched after every snapshot that arrives with no
93
+ * event of its own: the first one, and the one a new player connection answers with.
94
+ */
95
+ export const syncEventsFor = (snapshot: PlayerSnapshot): PlayerEvent[] => [
96
+ 'loadstart',
97
+ ...snapshot.readyState >= 1 ? ['loadedmetadata', 'durationchange'] as const : [],
98
+ ...snapshot.readyState >= 2 ? ['loadeddata'] as const : [],
99
+ ...snapshot.readyState >= 3 ? ['canplay'] as const : [],
100
+ 'volumechange', 'ratechange', 'progress', 'resize',
101
+ snapshot.paused ? 'pause' : 'playing',
102
+ // The terminal states, which a catch-up has to be able to express: a store learns about a failure
103
+ // from the `error` event alone, so a mirror of a media that had already failed before anyone
104
+ // subscribed would otherwise sit on a spinner with `player.error` set and nothing to read it.
105
+ ...snapshot.ended ? ['ended'] as const : [],
106
+ ...snapshot.error ? ['error'] as const : [],
107
+ ]
108
+
109
+ const pairs = (ranges: TimeRangesLike | undefined): [number, number][] => {
110
+ if (!ranges) return []
111
+ const out: [number, number][] = []
112
+ for (let index = 0; index < ranges.length; index++) out.push([ranges.start(index), ranges.end(index)])
113
+ return out
114
+ }
115
+
116
+ /** A media as a snapshot. A NaN duration, which a bare element answers before it has metadata, is sent as 0. */
117
+ export const snapshotOf = (media: PlayerMedia): PlayerSnapshot => ({
118
+ currentTime: finite(media.currentTime),
119
+ duration: finite(media.duration),
120
+ paused: media.paused,
121
+ ended: media.ended ?? false,
122
+ seeking: media.seeking,
123
+ readyState: media.readyState,
124
+ volume: media.volume ?? 1,
125
+ muted: media.muted ?? false,
126
+ playbackRate: media.playbackRate ?? 1,
127
+ src: media.src ?? '',
128
+ currentSrc: media.currentSrc ?? '',
129
+ buffered: pairs(media.buffered),
130
+ seekable: pairs(media.seekable),
131
+ error: media.error ? { code: media.error.code, message: media.error.message } : null,
132
+ videoWidth: media.videoWidth ?? 0,
133
+ videoHeight: media.videoHeight ?? 0,
134
+ })
135
+
136
+ // NaN only. A live media reports `duration: Infinity` and means it, so sending 0 for it would mirror
137
+ // an endless stream as a zero-length one; NaN is the element saying it does not know yet.
138
+ const finite = (value: number): number => Number.isNaN(value) ? 0 : value
139
+
140
+ /** The snapshot an embedder holds before the player's document has said anything. */
141
+ export const EMPTY_SNAPSHOT: PlayerSnapshot = {
142
+ currentTime: 0, duration: 0, paused: true, ended: false, seeking: false, readyState: 0,
143
+ volume: 1, muted: false, playbackRate: 1, src: '', currentSrc: '',
144
+ buffered: [], seekable: [], error: null, videoWidth: 0, videoHeight: 0,
145
+ }
146
+
147
+ /** Plain pairs back into the structural `TimeRanges` the player reads. */
148
+ export const toTimeRanges = (ranges: [number, number][]): TimeRangesLike => ({
149
+ length: ranges.length,
150
+ start: index => ranges[index]?.[0] ?? 0,
151
+ end: index => ranges[index]?.[1] ?? 0,
152
+ })