@banou/media-player 0.8.19 → 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.
- package/README.md +58 -0
- package/dist/index.js +123 -105
- package/dist/react/video-player.d.ts +10 -0
- package/dist/remote/index.d.ts +137 -0
- package/dist/remote/index.js +382 -0
- package/dist/remote/protocol.d.ts +91 -0
- package/package.json +10 -1
- package/src/lib/react/video-player.tsx +42 -1
- package/src/lib/remote/index.ts +629 -0
- package/src/lib/remote/protocol.ts +152 -0
|
@@ -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
|
+
})
|