filestash/public/assets/pages/viewerpage/application_video.js
2025-05-13 17:05:41 +10:00

376 lines
15 KiB
JavaScript

import { createElement, onDestroy } from "../../lib/skeleton/index.js";
import rxjs, { effect } from "../../lib/rx.js";
import { animate, slideYIn } from "../../lib/animate.js";
import { loadCSS, loadJS } from "../../helpers/loader.js";
import { qs, qsa } from "../../lib/dom.js";
import { settings_get, settings_put } from "../../lib/settings.js";
import { ApplicationError } from "../../lib/error.js";
import assert from "../../lib/assert.js";
import Hls from "../../lib/vendor/hlsjs/hls.js";
import ctrlError from "../ctrl_error.js";
import { transition } from "./common.js";
import { formatTimecode } from "./common_player.js";
import { ICON } from "./common_icon.js";
import { renderMenubar, buttonDownload, buttonFullscreen } from "./component_menubar.js";
import "../../components/icon.js";
const STATUS_PLAYING = "PLAYING";
const STATUS_PAUSED = "PAUSED";
const STATUS_BUFFERING = "BUFFERING";
export default function(render, { mime, getFilename, getDownloadUrl }) {
const $page = createElement(`
<div class="component_videoplayer">
<component-menubar filename="${getFilename()}"></component-menubar>
<div class="video_container">
<span>
<div class="video_screen video-state-pause is-casting-no">
<div class="video_wrapper">
<video></video>
</div>
<div class="loader no-select">
<component-icon name="loading"></component-icon>
</div>
<div class="videoplayer_control no-select hidden">
<div class="progress">
<div data-bind="progress-buffer">
<div class="progress-buffer" style="left: 0%; width: 0%;"></div>
</div>
<div class="progress-active" style="width: 0%;">
<div class="thumb"></div>
</div>
<div class="progress-placeholder"></div>
</div>
<img class="component_icon" draggable="false" src="${ICON.PLAY}" alt="play">
<img class="component_icon hidden" draggable="false" src="${ICON.PAUSE}" alt="pause">
<component-icon name="loading" class="hidden"></component-icon>
<img class="component_icon hidden" draggable="false" src="${ICON.VOLUME_MUTE}" alt="volume_mute">
<img class="component_icon hidden" draggable="false" src="${ICON.VOLUME_LOW}" alt="volume_low">
<img class="component_icon hidden" draggable="false" src="${ICON.VOLUME_NORMAL}" alt="volume">
<input type="range" min="0" max="100" value="13">
<span class="timecode">
<div class="current"></div>
<div class="hint hidden"></div>
</span>
</div>
</div>
</span>
</div>
</div>
`);
render($page);
renderMenubar(
qs($page, "component-menubar"),
buttonDownload(getFilename(), getDownloadUrl()),
buttonFullscreen(qs($page, "video")),
);
transition(qs($page, ".video_container"));
const $video = qs($page, "video");
const $control = {
play: qs($page, `.videoplayer_control [alt="play"]`),
pause: qs($page, `.videoplayer_control [alt="pause"]`),
loading: qs($page, `.videoplayer_control component-icon[name="loading"]`),
};
const $volume = {
range: qs($page, `input[type="range"]`),
icon_mute: qs($page, `img[alt="volume_mute"]`),
icon_low: qs($page, `img[alt="volume_low"]`),
icon_normal: qs($page, `img[alt="volume"]`),
};
const setVolume = (volume) => {
settings_put("volume", volume);
$video.volume = volume / 100;
$volume.range.value = volume;
if (volume === 0) {
$volume.icon_mute.classList.remove("hidden");
$volume.icon_low.classList.add("hidden");
$volume.icon_normal.classList.add("hidden");
} else if (volume < 50) {
$volume.icon_mute.classList.add("hidden");
$volume.icon_low.classList.remove("hidden");
$volume.icon_normal.classList.add("hidden");
} else {
$volume.icon_mute.classList.add("hidden");
$volume.icon_low.classList.add("hidden");
$volume.icon_normal.classList.remove("hidden");
}
};
const setStatus = (status) => {
switch (status) {
case "PLAYING":
$control.play.classList.add("hidden");
$control.pause.classList.remove("hidden");
$control.loading.classList.add("hidden");
$video.play();
break;
case "PAUSED":
$control.play.classList.remove("hidden");
$control.pause.classList.add("hidden");
$control.loading.classList.add("hidden");
$video.pause();
break;
case "BUFFERING":
$control.play.classList.add("hidden");
$control.pause.classList.add("hidden");
$control.loading.classList.remove("hidden");
break;
default:
assert.fail(status);
}
};
const setSeek = (newTime, shouldSet = false) => {
if (shouldSet) $video.currentTime = newTime;
const width = 100 * (newTime / $video.duration);
qs($page, ".progress .progress-active").style.width = `${width}%`;
if (!isNaN($video.duration)) {
qs($page, ".timecode .current").textContent = formatTimecode($video.currentTime) + " / " + formatTimecode($video.duration);
}
};
// feature1: setup the dom
const setup$ = rxjs.of(null).pipe(
rxjs.map(() => {
const loadPolicy = { default: { maxLoadTimeMs: 3600000, maxTimeToFirstByteMs: Infinity, timeoutRetry: { maxNumRetry: 0 } } };
const hls = new Hls({
debug: !!new URLSearchParams(location.search).get("debug"),
manifestLoadPolicy: loadPolicy,
});
const sources = window.overrides["video-map-sources"]([{
src: getDownloadUrl(),
type: mime,
}]);
for (let i=0; i<sources.length; i++) {
if (sources[i].type !== "application/x-mpegURL") {
const $source = document.createElement("source");
$source.setAttribute("type", "video/mp4");
$source.setAttribute("src", sources[i].src);
$video.appendChild($source);
return [{ ...sources[i], type: "video/mp4" }];
}
hls.loadSource(sources[i].src);
}
hls.attachMedia($video);
onDestroy(() => {
$video.pause();
$video.remove();
});
return sources;
}),
rxjs.mergeMap((sources) => rxjs.merge(
rxjs.fromEvent($video, "loadeddata"),
...[...qsa($page, "source")].map(($source) => rxjs.fromEvent($source, "error").pipe(rxjs.tap(() => {
throw new ApplicationError("NOT_SUPPORTED", JSON.stringify({ mime, sources }, null, 2));
}))),
)),
rxjs.mergeMap(() => {
const $loader = qs($page, ".loader");
$loader.replaceChildren(createElement(`<img src="${ICON.PLAY}" />`));
animate($loader, {
time: 150,
keyframes: [
{ transform: "scale(0.7)" },
{ transform: "scale(1)" },
],
});
setSeek(0);
return rxjs.race(
rxjs.fromEvent($loader, "click").pipe(rxjs.mapTo($loader)),
rxjs.fromEvent(document, "keydown").pipe(rxjs.filter((e) => e.code === "Space"), rxjs.first()),
).pipe(rxjs.mapTo($loader));
}),
rxjs.tap(($loader) => {
$loader.classList.add("hidden");
const $control = qs($page, ".videoplayer_control");
$control.classList.remove("hidden");
animate($control, { time: 300, keyframes: slideYIn(5) });
setStatus(STATUS_PLAYING);
}),
rxjs.catchError(ctrlError()),
rxjs.share(),
);
effect(setup$);
effect(setup$.pipe(rxjs.mergeMap(() => rxjs.fromEvent($video, "error").pipe(rxjs.tap(() => {
// console.error(err);
// notify.send(t("Not supported"), "error");
// setIsPlaying(false);
// setIsLoading(false);
})))));
// feature2: player control - volume
effect(setup$.pipe(
rxjs.switchMap(() => rxjs.fromEvent($volume.range, "input").pipe(rxjs.map((e) => e.target.value))),
rxjs.startWith(settings_get("volume") === null ? 80 : settings_get("volume")),
rxjs.tap((volume) => setVolume(parseInt(volume))),
));
// feature3: player control - play/pause
effect(setup$.pipe(
rxjs.mergeMap(() => rxjs.merge(
rxjs.fromEvent($control.play, "click").pipe(rxjs.mapTo(STATUS_PLAYING)),
rxjs.fromEvent($control.pause, "click").pipe(rxjs.mapTo(STATUS_PAUSED)),
rxjs.fromEvent($video, "ended").pipe(rxjs.mapTo(STATUS_PAUSED)),
rxjs.fromEvent($video, "waiting").pipe(rxjs.mapTo(STATUS_BUFFERING)),
rxjs.fromEvent($video, "playing").pipe(rxjs.mapTo(STATUS_PLAYING)),
)),
rxjs.debounceTime(50),
rxjs.tap((status) => setStatus(status)),
));
// feature4: hint
const $hint = qs($page, `.hint`);
effect(setup$.pipe(
rxjs.switchMap(() => rxjs.fromEvent(qs($page, ".progress"), "mousemove")),
rxjs.map((e) => {
const rec = e.target.getBoundingClientRect();
const width = e.clientX - rec.x;
const time = $video.duration * width / rec.width;
let posX = width;
posX = Math.max(posX, 30);
posX = Math.min(posX, e.target.clientWidth - 30);
return { x: `${posX}px`, time };
}),
rxjs.tap(({ x, time }) => {
$hint.classList.remove("hidden");
$hint.style.left = x;
$hint.textContent = formatTimecode(time);
}),
));
effect(setup$.pipe(
rxjs.switchMap(() => rxjs.fromEvent(qs($page, ".progress"), "mouseleave")),
rxjs.tap(() => $hint.classList.add("hidden")),
));
// feature5: player control - seek
effect(setup$.pipe(
rxjs.switchMap(() => rxjs.fromEvent(qs($page, ".progress"), "click").pipe(
rxjs.map((e) => { // TODO: use onClick instead?
let $progress = e.target;
if (e.target.classList.contains("progress") === false) {
$progress = e.target.parentElement;
}
const rec = $progress.getBoundingClientRect();
return (e.clientX - rec.x) / rec.width;
}),
rxjs.tap((n) => {
if (n < 2/100) {
setStatus(STATUS_PAUSED);
n = 0;
}
setSeek(n * $video.duration, true);
}),
)),
));
// feature6: player control - keyboard shortcut
effect(setup$.pipe(
rxjs.switchMap(() => rxjs.merge(
rxjs.fromEvent(document, "keydown").pipe(rxjs.map((e) => e.code)),
rxjs.fromEvent($video, "click").pipe(rxjs.mapTo("Space")),
)),
rxjs.tap((code) => {
switch (code) {
case "Space":
case "KeyK":
setStatus($video.paused ? STATUS_PLAYING : STATUS_PAUSED);
break;
case "KeyM":
setVolume($video.volume > 0 ? 0 : settings_get("volume"));
break;
case "ArrowUp":
setVolume(Math.min($video.volume*100 + 10, 100));
break;
case "ArrowDown":
setVolume(Math.max($video.volume*100 - 10, 0));
break;
case "KeyL":
setSeek(Math.min($video.duration, $video.currentTime + 10), true);
break;
case "KeyJ":
setSeek(Math.max(0, $video.currentTime - 10), true);
break;
case "KeyF":
// TODO
break;
case "Digit0":
setSeek(0, true);
break;
case "Digit1":
setSeek($video.duration / 10, true);
break;
case "Digit2":
setSeek($video.duration * 2 / 10, true);
break;
case "Digit3":
setSeek($video.duration * 3 / 10, true);
break;
case "Digit4":
setSeek($video.duration * 4 / 10, true);
break;
case "Digit5":
setSeek($video.duration * 5 / 10, true);
break;
case "Digit6":
setSeek($video.duration * 6 / 10, true);
break;
case "Digit7":
setSeek($video.duration * 7 / 10, true);
break;
case "Digit8":
setSeek($video.duration * 8 / 10, true);
break;
case "Digit9":
setSeek($video.duration * 9 / 10, true);
break;
}
}),
));
// feature7: render the progress bar
effect(setup$.pipe(
rxjs.mergeMap(() => rxjs.fromEvent($video, "timeupdate")),
rxjs.tap(() => setSeek($video.currentTime)),
));
// feature8: render loading buffer
effect(setup$.pipe(
rxjs.mergeMap(() => rxjs.fromEvent($video, "timeupdate")),
rxjs.tap(() => {
const calcWidth = (i) => {
return ($video.buffered.end(i) - $video.buffered.start(i)) / $video.duration * 100;
};
const calcLeft = (i) => {
return $video.buffered.start(i) / $video.duration * 100;
};
const $container = qs($page, `[data-bind="progress-buffer"]`);
if ($video.buffered.length !== $container.children.length) {
$container.innerHTML = "";
const $fragment = document.createDocumentFragment();
Array.from({ length: $video.buffered.length })
.map(() => $fragment.appendChild(createElement(`
<div className="progress-buffer" style=""></div>
`)));
$container.appendChild($fragment);
}
for (let i=0; i<$video.buffered.length; i++) {
$container.children[i].style.left = calcLeft(i) + "%";
$container.children[i].style.width = calcWidth(i) + "%";
}
}),
));
}
export function init() {
if (!window.overrides) window.overrides = {};
return Promise.all([
loadCSS(import.meta.url, "./application_video.css"),
loadJS(import.meta.url, "/overrides/video-transcoder.js"),
]).then(async() => {
if (typeof window.overrides["video-map-sources"] !== "function") window.overrides["video-map-sources"] = (s) => (s);
});
}