upend/webui/src/components/display/blobs/VideoViewer.svelte

264 lines
5.9 KiB
Svelte

<script lang="ts">
import { throttle } from "lodash";
import Spinner from "../../utils/Spinner.svelte";
import Icon from "../../utils/Icon.svelte";
import { useEntity } from "../../../lib/entity";
import { i18n } from "../../../i18n";
import { createEventDispatcher } from "svelte";
import api from "../../../lib/api";
const dispatch = createEventDispatcher();
export let address: string;
export let detail: boolean;
const { entity } = useEntity(address);
enum State {
LOADING = "loading",
PREVIEW = "preview",
PREVIEWING = "previewing",
PLAYING = "playing",
ERRORED = "errored",
}
let state = State.LOADING;
let supported = true;
$: if (state == State.PREVIEW) dispatch("loaded");
$: {
if ($entity && videoEl) {
const mime = $entity.get("FILE_MIME");
if (mime) {
supported = Boolean(videoEl.canPlayType(mime as string));
}
}
}
let videoEl: HTMLVideoElement;
let currentTime: number;
let timeCodeWidth: number;
let timeCodeLeft: string;
let timeCodeSize: string;
const seek = throttle((progress: number) => {
if (state === State.PREVIEWING && videoEl.duration) {
currentTime = videoEl.duration * progress;
if (timeCodeWidth) {
let timeCodeLeftPx = Math.min(
Math.max(videoEl.clientWidth * progress, timeCodeWidth / 2),
videoEl.clientWidth - timeCodeWidth / 2
);
timeCodeLeft = `${timeCodeLeftPx}px`;
timeCodeSize = `${videoEl.clientHeight / 9}px`;
}
}
}, 100);
function updatePreviewPosition(ev: MouseEvent) {
if (state === State.PREVIEW || state === State.PREVIEWING) {
state = State.PREVIEWING;
const bcr = videoEl.getBoundingClientRect();
const progress = (ev.clientX - bcr.x) / bcr.width;
seek(progress);
}
}
function resetPreview() {
if (state === State.PREVIEWING) {
state = State.PREVIEW;
videoEl.load();
}
}
function startPlaying() {
if (detail) {
state = State.PLAYING;
videoEl.play();
}
}
</script>
<div class="video-viewer {state}" class:detail class:unsupported={!supported}>
<div class="player" style="--icon-size: {detail ? 100 : 32}px">
{#if state === State.LOADING}
<Spinner />
{/if}
{#if state === State.LOADING || (!detail && state === State.PREVIEW)}
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<img
class="thumb"
src="{api.apiUrl}/thumb/{address}?mime=video"
alt="Preview for {address}"
loading="lazy"
on:load={() => (state = State.PREVIEW)}
on:mouseover={() => (state = State.PREVIEWING)}
on:error={() => (state = State.ERRORED)}
/>
{:else}
<!-- svelte-ignore a11y-media-has-caption -->
<video
preload={detail ? "auto" : "metadata"}
src="{api.apiUrl}/raw/{address}"
poster="{api.apiUrl}/thumb/{address}?mime=video"
on:mousemove={updatePreviewPosition}
on:mouseleave={resetPreview}
on:click|preventDefault={startPlaying}
controls={state === State.PLAYING}
bind:this={videoEl}
bind:currentTime
/>
{#if !supported}
<div class="unsupported-message">
<div class="label">
{$i18n.t("UNSUPPORTED FORMAT")}
</div>
</div>
{/if}
{/if}
<div class="play-icon">
<Icon plain border name="play" />
</div>
<div
class="timecode"
bind:clientWidth={timeCodeWidth}
style:left={timeCodeLeft}
style:font-size={timeCodeSize}
>
{#if videoEl?.duration && currentTime}
{#if videoEl.duration > 3600}{String(
Math.floor(currentTime / 3600)
).padStart(2, "0")}:{/if}{String(
Math.floor((currentTime % 3600) / 60)
).padStart(2, "0")}:{String(
Math.floor((currentTime % 3600) % 60)
).padStart(2, "0")}
{:else if supported}
<Spinner />
{/if}
</div>
</div>
</div>
<style lang="scss">
.video-viewer {
min-width: 0;
min-height: 0;
&,
.player {
display: flex;
align-items: center;
min-height: 0;
flex-direction: column;
width: 100%;
}
img,
video {
width: 100%;
max-height: 100%;
min-height: 0;
object-fit: contain;
// background: rgba(128, 128, 128, 128);
transition: filter 0.2s;
}
.player {
position: relative;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: var(--icon-size);
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.timecode {
display: none;
pointer-events: none;
position: absolute;
top: 50%;
left: var(--left);
transform: translate(-50%, -50%);
font-feature-settings: "tnum", "zero";
font-weight: bold;
color: white;
opacity: 0.66;
}
&.unsupported.detail {
.play-icon {
display: none;
}
}
.unsupported-message {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(1, 1, 1, 0.7);
pointer-events: none;
.label {
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
width: 100%;
text-align: center;
font-weight: bold;
color: darkred;
}
}
&.loading {
.player > * {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
}
&.standby,
&.preview {
img,
video {
filter: brightness(0.75);
}
.play-icon {
opacity: 0.8;
}
}
&.previewing {
.timecode {
display: block;
}
video {
cursor: pointer;
}
}
}
</style>