| 1 | // Theoretical minimum browser support: Safari v6, Chrome v23, Firefox v42 |
| 2 | |
| 3 | // Polyfill for old browsers. |
| 4 | type PlaybackMode = "av1" | "hls-polyfill" | "hls-native" | "none"; |
| 5 | declare global { |
| 6 | function hydrateVideo(container: HTMLElement): void; |
| 7 | var ManagedMediaSource: typeof MediaSource | null; |
| 8 | var onCloverVideoInit: null | ((id: string, video: HTMLVideoElement) => void); |
| 9 | } |
| 10 | |
| 11 | const forceMode = location.search.match(/(?:\?|&)force-player=(.*)/)?.[1] as |
| 12 | | PlaybackMode |
| 13 | | undefined; |
| 14 | let mode: Promise<PlaybackMode> | null = null; |
| 15 | let hls: Promise<typeof import("hls.js")> | null = null; |
| 16 | |
| 17 | Promise.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 | |
| 25 | globalThis.hydrateVideo = function hydrateVideo(container) { |
| 26 | mode ??= decidePlaybackMode(); |
| 27 | mode.then((mode) => hydrateVideoInner(container, mode)); |
| 28 | }; |
| 29 | |
| 30 | Array.from( |
| 31 | document.querySelectorAll<HTMLElement>(".clover-video"), |
| 32 | (container) => hydrateVideo(container), |
| 33 | ); |
| 34 | |
| 35 | function 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) |
| 83 | function 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 | |
| 103 | function 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 | |
| 293 | function 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 | |
| 327 | function 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 | } |