This is the template project that's checked out and configured when you run the bando-up command from ljsthw-bandolier. This is where the code really lives.
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.
bandolier-template/client/components/HLSVideo.svelte

310 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>