This is the code that runs https://bandolier.learnjsthehardway.com/ for you to review. It uses the https://git.learnjsthehardway.com/learn-javascript-the-hard-way/bandolier-template to create the documentation for the project.
https://bandolier.learnjsthehardway.com/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
309 lines
8.6 KiB
309 lines
8.6 KiB
<script>
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import Spinner from "$/client/components/Spinner.svelte";
|
|
import Countdown from "$/client/components/Countdown.svelte";
|
|
import Icon from '$/client/components/Icon.svelte';
|
|
import FSM from '$/client/fsm.js';
|
|
import assert from "$/client/assert.js";
|
|
import { log } from "$/client/logging.js";
|
|
|
|
export let aspect_ratio = "16/9";
|
|
export let source = "";
|
|
export let content_type = "application/x-mpegURL";
|
|
export let poster = "";
|
|
export let preload = "";
|
|
export let playsinline = false;
|
|
export let poll_tick = 10000;
|
|
export let poll_limit = 10;
|
|
export let background_color = "var(--color-accent)";
|
|
export let starts_on = false;
|
|
export let spinner = true;
|
|
export let controls = true;
|
|
export let muted = false;
|
|
export let autoplay = false;
|
|
let video_config = {video_ready: false};
|
|
|
|
let poll_count = 0;
|
|
let poll_timer;
|
|
let fsm;
|
|
let hls;
|
|
export let video_tag;
|
|
|
|
const supports_hls_native = () => {
|
|
let video = document.getElementById("video-player");
|
|
return video.canPlayType('application/vnd.apple.mpegURL') != "" || video.canPlayType('application/x-mpegURL') != "";
|
|
}
|
|
|
|
const load_hlsjs_script = () => {
|
|
let script = document.createElement("script");
|
|
script.setAttribute('src', "/js/hls.js");
|
|
document.body.appendChild(script);
|
|
script.onload = async () => {
|
|
// NOTE: might have to revisit this as it might not reliably keep hls.js loaded
|
|
if(!video_config.hlsjs_configured) {
|
|
video_config.hlsjs_configured = true;
|
|
await fsm.do("hlsjs_loaded");
|
|
}
|
|
}
|
|
}
|
|
|
|
/* You pass it the state it should return on success, will
|
|
* return HLS_ERROR if there's a problem.
|
|
*/
|
|
const recover_hls = (state) => {
|
|
log.debug("HLS error", video_config.hls_error_type, "in state", state);
|
|
switch(video_config.hls_error_type) {
|
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
|
hls.startLoad();
|
|
return state;
|
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
|
hls.recoverMediaError();
|
|
return state;
|
|
default:
|
|
log.debug("HLS error is unknown type");
|
|
hls.destroy();
|
|
return "HLS_ERROR";
|
|
}
|
|
}
|
|
|
|
const check_video = async () => {
|
|
let test = await fetch(source);
|
|
poll_count += poll_count;
|
|
|
|
if(test.status == 200) {
|
|
await fsm.do("video_ready");
|
|
clearInterval(poll_timer);
|
|
} else if(poll_count > poll_limit) {
|
|
clearInterval(poll_timer);
|
|
await fsm.do("poll_limit");
|
|
}
|
|
|
|
return test.status == 200;
|
|
}
|
|
|
|
const poll_video_ready = async () => {
|
|
if(!await check_video()) {
|
|
poll_timer = setInterval(check_video, poll_tick);
|
|
}
|
|
}
|
|
|
|
const start_hlsjs = () => {
|
|
assert(source.includes(".m3u8"), `HLS is loaded but source doesn't have m3u8 "${source}"`);
|
|
assert(Hls !== undefined, "hlsjs_configured is true but no Hls object?");
|
|
assert(Hls.isSupported(), "HLS trying to run but Hls.isSupported() claims false.");
|
|
|
|
let video = document.getElementById("video-player");
|
|
hls = new Hls();
|
|
hls.attachMedia(video);
|
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
|
log.debug("HLS MEDIA_ATTACHED event start.");
|
|
hls.loadSource(source);
|
|
hls.on(Hls.Events.MANIFEST_PARSED,
|
|
async (event, details) => {
|
|
log.debug("HLS MANIFEST_PARSED event start.");
|
|
await fsm.do("video_ready")
|
|
});
|
|
|
|
hls.on(Hls.Events.ERROR, async (event, error) => {
|
|
log.debug("HLS ERROR event start.", error.type);
|
|
video_config.hls_error_type = error.type;
|
|
await fsm.do("hls_error");
|
|
});
|
|
|
|
});
|
|
}
|
|
|
|
const detect_video = () => {
|
|
if(!video_config.hls_source || video_config.hls_natively) {
|
|
// can just use HTML5 video directly
|
|
return ["POLLING_VIDEO", poll_video_ready];
|
|
} else if(video_config.hlsjs_configured) {
|
|
// it's an HLS video but the browser doesn't support it
|
|
// the hls.js script tag is already loaded so just run it
|
|
return ["HLSJS_READY", start_hlsjs];
|
|
} else {
|
|
// still an HLS video, but need to load hls.js
|
|
// need to load the hls.js script tag for this browser
|
|
return ["LOADING_HLSJS", load_hlsjs_script];
|
|
}
|
|
}
|
|
|
|
|
|
class VideoEvents {
|
|
async mount(state) {
|
|
switch(state) {
|
|
case "START":
|
|
video_config.hls_source = source.includes(".m3u8");
|
|
video_config.hls_natively = supports_hls_native();
|
|
|
|
if(starts_on && !video_config.countdown_done) {
|
|
return "COUNTING_DOWN";
|
|
} else {
|
|
return detect_video();
|
|
}
|
|
default:
|
|
return "ERROR";
|
|
}
|
|
}
|
|
|
|
async hlsjs_loaded(state) {
|
|
switch(state) {
|
|
case "LOADING_HLSJS":
|
|
start_hlsjs();
|
|
return "HLSJS_READY";
|
|
default:
|
|
return "ERROR";
|
|
}
|
|
}
|
|
|
|
async video_ready(state) {
|
|
switch(state) {
|
|
case "VIDEO_READY":
|
|
case "POLLING_VIDEO":
|
|
case "HLSJS_READY":
|
|
case "HLS_RECOVERING":
|
|
case "HLS_POLLING":
|
|
video_config.video_ready = true;
|
|
return "VIDEO_READY";
|
|
default:
|
|
return "ERROR";
|
|
}
|
|
}
|
|
|
|
async hls_error(state) {
|
|
// attempt to just resolve the error and stay in the same state
|
|
// TODO: this might be wrong, need to confirm it. Previously I
|
|
// return "HLS_RECOVERING" instead of the state that was given
|
|
switch(state) {
|
|
case "HLS_RECOVERING":
|
|
return recover_hls(state);
|
|
case "VIDEO_READY":
|
|
return recover_hls(state);
|
|
default:
|
|
log.debug("HLS error on state", state, "Not handled");
|
|
return "ERROR";
|
|
}
|
|
}
|
|
|
|
countdown_done(state) {
|
|
video_config.countdown_done = true;
|
|
switch(state) {
|
|
case "COUNTING_DOWN":
|
|
return detect_video();
|
|
default:
|
|
return "ERROR";
|
|
}
|
|
}
|
|
|
|
poll_limit(state) {
|
|
switch(state) {
|
|
case "POLLING_VIDEO":
|
|
return "POLL_LIMIT";
|
|
default:
|
|
return "ERROR";
|
|
}
|
|
}
|
|
|
|
clean_video() {
|
|
// this does seem to help with ram on Safari but only just
|
|
video_tag.pause();
|
|
video_tag.src = "";
|
|
video_tag.load();
|
|
}
|
|
|
|
destroy(state) {
|
|
log.debug("FSM: Destroying video on state", state);
|
|
if(hls) hls.destroy();
|
|
|
|
if(video_tag) this.clean_video();
|
|
|
|
clearInterval(poll_timer)
|
|
video_config.video_ready = false;
|
|
video_config.hls_error_type = undefined;
|
|
return "END";
|
|
}
|
|
}
|
|
|
|
onDestroy(async () => await fsm.do("destroy"));
|
|
|
|
onMount(async () => {
|
|
await fsm.do("mount");
|
|
});
|
|
|
|
|
|
fsm = new FSM(video_config, new VideoEvents());
|
|
fsm.onTransition(fsm => {
|
|
video_config.state = fsm.state
|
|
});
|
|
|
|
</script>
|
|
|
|
<style>
|
|
video-player video.not-ready {
|
|
filter: blur(4px);
|
|
}
|
|
|
|
video-player loading-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
video-player loading-panel:hover {
|
|
box-shadow: var(--box-shadow) var(--color-shadow);
|
|
}
|
|
|
|
video-player {
|
|
overflow: hidden;
|
|
}
|
|
|
|
video-player loading-panel h1,
|
|
video-player loading-panel h2 {
|
|
color: var(--color-bg-secondary);
|
|
font-size: 7vw;
|
|
margin-top: 0;
|
|
margin-bottom: 0;
|
|
text-shadow: 8px 8px 5px var(--color-overlay-shadow);
|
|
}
|
|
|
|
video-player loading-panel h2 {
|
|
font-size: 4vw;
|
|
}
|
|
</style>
|
|
|
|
<!-- Safari leaks the video content if you use a <source> tag. Future versions of this will
|
|
try to detect Safari and adjust accordingly, but for now this is the only thing that works.
|
|
-->
|
|
<video-player class="stacked" style="background: { background_color };">
|
|
<video bind:this={ video_tag }
|
|
playsinline={ playsinline }
|
|
src={ source }
|
|
type={ content_type }
|
|
class:not-ready={video_config.state !== "VIDEO_READY"}
|
|
class="layer" style="--aspect-ratio: { aspect_ratio };"
|
|
poster={poster}
|
|
controls={ controls && video_config.video_ready }
|
|
id="video-player"
|
|
preload={ preload }
|
|
autoplay={ autoplay }
|
|
muted={ muted }
|
|
>
|
|
</video>
|
|
|
|
{#if video_config.state !== "VIDEO_READY"}
|
|
<loading-panel class="layer top">
|
|
{#if video_config.state === "COUNTING_DOWN" }
|
|
<Countdown starts_on={ starts_on } on:done={ () => fsm.do("countdown_done") } />
|
|
{:else if video_config.state === "POLL_LIMIT" }
|
|
<Icon name="alert-triangle" size="7vw" color="var(--color-bg-secondary)" shadow={ true } />
|
|
<h1>Video Broken</h1>
|
|
<h2>Come back in a few minutes...</h2>
|
|
{:else if spinner}
|
|
<Spinner color="var(--color-bg)" size="128" />
|
|
{:else}
|
|
<img src={ poster } />
|
|
{/if}
|
|
</loading-panel>
|
|
{/if}
|
|
</video-player>
|
|
|