1// Theoretical minimum browser support: Safari v6, Chrome v23, Firefox v42
2
3// Polyfill for old browsers.
4type PlaybackMode = "av1" | "hls-polyfill" | "hls-native" | "none";
5declare global {
6 function hydrateVideo(container: HTMLElement): void;
7 var ManagedMediaSource: typeof MediaSource | null;
8 var onCloverVideoInit: null | ((id: string, video: HTMLVideoElement) => void);
9}
10
11const forceMode = location.search.match(/(?:\?|&)force-player=(.*)/)?.[1] as
12 | PlaybackMode
13 | undefined;
14let mode: Promise<PlaybackMode> | null = null;
15let hls: Promise<typeof import("hls.js")> | null = null;
16
17Promise.allSettled ??= <T>(promises: Iterable<T | PromiseLike<T>>) =>
18 Promise.all(Array.from(promises, (p) =>
19 Promise.resolve(p)
20 .then(
21 (value) => ({ status: "fulfilled" as const, value }),
22 (reason) => ({ status: "rejected" as const, reason }),
23 )));
24
25globalThis.hydrateVideo = function hydrateVideo(container) {
26 mode ??= decidePlaybackMode();
27 mode.then((mode) => hydrateVideoInner(container, mode));
28};
29
30Array.from(
31 document.querySelectorAll<HTMLElement>(".clover-video"),
32 (container) => hydrateVideo(container),
33);
34
35function decidePlaybackMode(): Promise<PlaybackMode> {
36 if (forceMode) {
37 if (forceMode === "hls-polyfill") {
38 // @ts-expect-error
39 return (hls ??= import("/js/scripts/vendor/hls.js"))
40 .then(() => forceMode);
41 }
42 return Promise.resolve(forceMode);
43 }
44
45 return shouldPlayAv1().catch(() => ({ hardware: false, supported: false }))
46 .then((av1): PlaybackMode | Promise<PlaybackMode> => {
47 // Native AV1 with DASH Polyfill
48 // (Chrome v70, Safari v17 on M3/A17 chip, Firefox v67)
49 if (av1.hardware) return "av1";
50 if (av1.supported) {
51 // Software decoding is too slow to use, unfortunately.
52 // Consider a device like a 2014 two core laptop.
53 console.warn(`implements non-accelerated av1+opus`);
54
55 // but on my slow Windows 7 laptop on Chrome, the HLS polyfill lags
56 // even on stable internet, so the av1 encoder ends up being better.
57 if (navigator.platform === "Win32") {
58 const chromeVersion = /Chrome\/(\d+)/.exec(navigator.userAgent)?.[1];
59 if (chromeVersion && Number(chromeVersion) < 110) {
60 console.warn("using av1 since win32 chrome runs hls.js badly");
61 return "none";
62 }
63 }
64 } else {
65 console.warn(`does not implement av1+opus`);
66 }
67
68 return decideHlsMode();
69 })
70 .catch((e): PlaybackMode => {
71 console.warn(e);
72 console.warn("falling back to default player");
73 return "none";
74 })
75 .then((mode) => {
76 console.info(`selected playback mode: ${mode}`);
77 return mode;
78 });
79}
80
81// the hls fallback decision is also needed by av1-capable browsers when a
82// video has an hls manifest but no dash one (dash encodes can fail or lag)
83function decideHlsMode(): Promise<PlaybackMode> {
84 return canNativelyPlayHls().then(
85 (nativeHls): PlaybackMode | Promise<PlaybackMode> => {
86 if (nativeHls) return "hls-native";
87 // @ts-expect-error
88 return (hls ??= import("/js/scripts/vendor/hls.js"))
89 .then((hls): PlaybackMode => {
90 // Polyfill HLS (Chrome 23, Firefox 42, IE11 on Win8)
91 // TODO: ES modules have a minimum of Chrome 63 or Firefox 60
92 if (hls?.default?.isSupported?.() || hls?.isSupported()) {
93 return "hls-polyfill";
94 }
95 console.warn("does not support hls.js");
96 // Other browsers
97 return "none";
98 });
99 },
100 );
101}
102
103function hydrateVideoInner(
104 container: HTMLElement,
105 mode: PlaybackMode,
106) {
107 let video = container instanceof HTMLVideoElement
108 ? container
109 : container.querySelector("video")!;
110 const id = video.id;
111 if (video.hasAttribute("controls")) return;
112 let src = video.getAttribute("data-src");
113 if (!src) return;
114 src = `/file${src}`;
115 src = new URL(src, location.origin).href;
116
117 const dashFile = `${src}:/dash.mpd`;
118 const hlsFile = `${src}:/master.m3u8`;
119
120 // data-streams lists which manifests exist ("dash", "hls", or both). a
121 // manifest may be missing because the indexer has not finished the file
122 // yet ("pending"), it skipped it (short/tiny videos, "none"), or that
123 // encode failed. never pick a backend whose manifest would 404; fall back
124 // to playing the original file directly.
125 const streams = (video.getAttribute("data-streams") ?? "").split(",");
126 const hasDash = streams.includes("dash");
127 const hasHls = streams.includes("hls");
128 if (!hasDash && !hasHls) {
129 if (streams.includes("pending")) {
130 const note = document.createElement("p");
131 note.className = "clover-video-pending";
132 note.textContent = "this video is still being processed, so quality selection is "
133 + "unavailable. if it does not play, come back later :(";
134 (container.querySelector("figcaption") ?? video.parentElement)
135 ?.appendChild(note);
136 }
137 video.src = src;
138 video.controls = true;
139 onCloverVideoInit?.(id, video);
140 return;
141 }
142 if (mode === "av1" && !hasDash) {
143 // av1-capable browser, but only an hls manifest exists for this video
144 decideHlsMode().then((m) => hydrateVideoInner(container, m));
145 return;
146 }
147 if ((mode === "hls-native" || mode === "hls-polyfill") && !hasHls) {
148 // dash-only video on a browser that cannot decode av1: raw playback
149 mode = "none";
150 }
151
152 if (forceMode) {
153 const figcaption = container.querySelector("figcaption");
154 if (figcaption) {
155 figcaption.innerHTML += "&nbsp;(force player backend: " + forceMode + ")";
156 }
157 }
158
159 if (mode === "av1") {
160 // @ts-expect-error
161 import("/js/scripts/vendor/dash.js").then((dashjs) => {
162 let mainSource: HTMLSourceElement | null = null;
163 let mediaSource: MediaSource | null = null, objectUrl = "";
164 const hasThumbnail = video.hasAttribute("poster");
165
166 const player = dashjs.MediaPlayer().create();
167
168 // the world is so simple when there is no thumbnail because we are not
169 // working around a billion sharp edges.
170 if (!hasThumbnail) {
171 enableSafariAirplay();
172 player.initialize(video, dashFile, false);
173 video.controls = true;
174 // TODO: be less stupid about this
175 window.onCloverVideoInit?.(id, video);
176 return;
177 }
178
179 // With Dash.js, we get pranked. No matter if preloading is on or off,
180 // the first frame is always preloaded and will override the poster.
181 // To avoid this, the viewer is attached later. However, on Chrome the
182 // video is still not playable until metadata has loaded in, so a fake
183 // MediaSource with one invisible frame is used.
184 player.initialize(null, dashFile, false);
185 player.updateSettings({
186 streaming: { cacheInitSegments: true },
187 });
188 player.preload();
189
190 if (typeof MediaSource !== "undefined") {
191 const duration = new Promise<number>((resolve) => {
192 player.on(dashjs.MediaPlayer.events.MANIFEST_LOADED, (e) => {
193 const duration = e.data.mediaPresentationDuration;
194 resolve(duration); // seconds
195 });
196 });
197 mediaSource = new MediaSource();
198 mediaSource.addEventListener("sourceopen", () => {
199 duration.then((duration) => {
200 mediaSource = mediaSource!;
201 mediaSource.duration = duration;
202 const sb = mediaSource.addSourceBuffer("video/webm; codecs=\"vp8\"");
203 const oneFrame = Uint8Array.from(
204 atob(
205 "GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwEAAAAAAAITEU2bdLpNu4tTq4QVSalmU6yBoU27i1OrhBZUrmtTrIHWTbuMU6uEElTDZ1OsggEjTbuMU6uEHFO7a1OsggH97AEAAAAAAABZ"
206 + "A".repeat(119)
207 + "VSalmsCrXsYMPQkBNgIxMYXZmNjIuMy4xMDBXQYxMYXZmNjIuMy4xMDBEiYhAXgAAAAAAABZUrmvIrgEAAAAAAAA/14EBc8WIDV7YG1KxA/CcgQAitZyDdW5kiIEAhoVWX1ZQOIOBASPjg4QCYloA4JCwgQG6gQGagQJVsIRVuYEBElTDZ/tzc59jwIBnyJlFo4dFTkNPREVSRIeMTGF2ZjYyLjMuMTAwc3PWY8CLY8WIDV7YG1KxA/BnyKFFo4dFTkNPREVSRIeUTGF2YzYyLjExLjEwMCBsaWJ2cHhnyKFFo4hEVVJBVElPTkSHkzAwOjAwOjAwLjEyMDAwMDAwMAAfQ7Z11eeBAKOigQAAgBACAJ0BKgEAAQALxwiFhYiFhIg/ggAMDWAA/ua1AKOVgQAoALEBAC8R/AAYABhYL/QAJAAAo5WBAFAAsQEALxH8ABgAGFgv9AAkAAAcU7trkbuPs4EAt4r3gQHxggGj8IED",
208 ),
209 (x) => x.charCodeAt(0),
210 );
211 sb.appendBuffer(oneFrame);
212 });
213 });
214 objectUrl = URL.createObjectURL(mediaSource);
215
216 mainSource = document.createElement("source");
217 mainSource.src = objectUrl;
218 video.appendChild(mainSource);
219 }
220 video.addEventListener("play", function play() {
221 video.removeEventListener("play", play);
222
223 // Revoke Fake MediaSource
224 if (objectUrl) {
225 mainSource?.remove();
226 mainSource = null;
227 mediaSource?.endOfStream();
228 mediaSource = null;
229 URL.revokeObjectURL(objectUrl);
230
231 const newVideo = document.createElement("video");
232 newVideo.poster = video.poster;
233 newVideo.controls = true;
234 video.parentElement!.insertBefore(newVideo, video);
235 video.remove();
236 video = newVideo;
237 }
238
239 enableSafariAirplay();
240 player.attachView(video);
241 if (objectUrl) video.play();
242 onCloverVideoInit?.(id, video);
243 });
244 video.controls = true;
245 onCloverVideoInit?.(id, video);
246
247 // This proxying is for ManagedMediaSource, exposing the HLS stream for AirPlay
248 function enableSafariAirplay() {
249 Object.defineProperty(video, "src", {
250 get: () => false,
251 set: (v) => {
252 if (!mainSource) {
253 mainSource = document.createElement("source");
254 mainSource.src = v;
255 video.appendChild(mainSource);
256 } else {
257 mainSource.src = v;
258 }
259 },
260 });
261 Object.defineProperty(video, "disableRemotePlayback", {
262 get: () => false,
263 set: () => {
264 const source = document.createElement("source");
265 source.src = hlsFile;
266 video.appendChild(source);
267 },
268 });
269 }
270 });
271 } else if (mode === "hls-native") {
272 const hlsSource = document.createElement("source");
273 hlsSource.src = hlsFile;
274 video.appendChild(hlsSource);
275 video.controls = true;
276 onCloverVideoInit?.(id, video);
277 } else if (mode === "hls-polyfill") {
278 hls!.then((hls) => {
279 // @ts-expect-error suspicious
280 const player = new (hls.default ?? hls.Hls)();
281 player.loadSource(hlsFile);
282 player.attachMedia(video);
283 video.controls = true;
284 onCloverVideoInit?.(id, video);
285 });
286 } else {
287 video.src = src;
288 video.controls = true;
289 onCloverVideoInit?.(id, video);
290 }
291}
292
293function shouldPlayAv1(): Promise<{ hardware: boolean; supported: boolean }> {
294 const MediaSource = globalThis.ManagedMediaSource ?? globalThis.MediaSource;
295 if (!MediaSource.isTypeSupported("audio/webm;codecs=\"opus\"")) {
296 return Promise.resolve({
297 hardware: false,
298 supported: false,
299 });
300 }
301
302 if (navigator.vendor?.includes("Apple")) {
303 const supported = !!MediaSource?.isTypeSupported(
304 "video/mp4;codecs=\"av01.0.01M.08\"",
305 );
306 return Promise.resolve({
307 hardware: supported,
308 supported,
309 });
310 } else {
311 return navigator.mediaCapabilities.decodingInfo({
312 type: "media-source",
313 video: {
314 contentType: "video/mp4;codecs=\"av01.0.01M.08\"",
315 bitrate: 128_000,
316 width: 1920,
317 height: 1080,
318 framerate: 30,
319 },
320 }).then((info) => ({
321 hardware: !!info.powerEfficient,
322 supported: !!info.supported,
323 }));
324 }
325}
326
327function canNativelyPlayHls() {
328 const video = document.createElement("video");
329 const canPlay = video.canPlayType("application/x-mpegURL");
330
331 if (!canPlay) return Promise.resolve(false);
332
333 return new Promise((resolve) => {
334 video.src = "data:application/x-mpegURL,#EXTM3U";
335 video.load();
336
337 function cleanup() {
338 clearTimeout(timeout);
339 video.removeEventListener("error", onError);
340 video.removeEventListener("loadedmetadata", onLoad);
341 video.src = "";
342 }
343
344 const onError = () => (cleanup(), resolve(false));
345 const onLoad = () => (cleanup(), resolve(true));
346 const timeout = setTimeout(onError, 100);
347
348 video.addEventListener("error", onError);
349 video.addEventListener("loadedmetadata", onLoad);
350 });
351}