aboutsummaryrefslogtreecommitdiffstats
path: root/src/lib/Player/Player.svelte
diff options
context:
space:
mode:
authorLibravatarLarge Libravatar memdmp <memdmpestrogenzone>2025-07-31 22:48:01 +0200
committerLibravatarLarge Libravatar memdmp <memdmpestrogenzone>2025-07-31 22:48:01 +0200
commite55f4c2fe8a6e1d62a0b005777b46c80e360d37e (patch)
tree1f9ad00b914f04677ffaf8b395a4c5d4ff659756 /src/lib/Player/Player.svelte
downloadvideotool-e55f4c2fe8a6e1d62a0b005777b46c80e360d37e.tar.gz
videotool-e55f4c2fe8a6e1d62a0b005777b46c80e360d37e.tar.bz2
videotool-e55f4c2fe8a6e1d62a0b005777b46c80e360d37e.tar.lz
videotool-e55f4c2fe8a6e1d62a0b005777b46c80e360d37e.zip

feat: initial commit

Diffstat (limited to 'src/lib/Player/Player.svelte')
-rw-r--r--src/lib/Player/Player.svelte161
1 files changed, 161 insertions, 0 deletions
diff --git a/src/lib/Player/Player.svelte b/src/lib/Player/Player.svelte
new file mode 100644
index 0000000..f6df121
--- /dev/null
+++ b/src/lib/Player/Player.svelte
@@ -0,0 +1,161 @@
+<script lang="ts">
+ import FrameSlider from './FrameSlider.svelte';
+ import { type Video, type VideoConstructor } from '$/lib/Player/Video';
+ import Keybinds from './Keybinds.svelte';
+ import { onMount } from 'svelte';
+
+ let frame = $state(0);
+ let canvas = $state(null as HTMLCanvasElement | null);
+ let audio = $state(null as HTMLAudioElement | null);
+ let lastCanvas = null as typeof canvas;
+ let video = $state(undefined as Video | undefined);
+ let frameCount = $state(0);
+ let playing = $state(false);
+ let playbackStarted = $state(0);
+ let playbackFrameOffset = 0;
+ let renderPromise: Promise<void> | void = void 0;
+ let renderId = 0;
+ let audioSource = $state(null as null | string);
+ let {
+ Video: VideoImplementation
+ }: {
+ Video: VideoConstructor;
+ } = $props();
+ const newCanvas = async (canvas: HTMLCanvasElement, videoImplementation: VideoConstructor) => {
+ if (video) video.cleanup();
+ lastCanvas = canvas;
+ video = new VideoImplementation(canvas);
+ const audioSourcePromise = (async () => {
+ if (video?.audioUrl?.[1]) {
+ audioSource = await fetch(video.audioUrl[1])
+ .then(async (v) => {
+ if (v.status.toString().startsWith('2')) return URL.createObjectURL(await v.blob());
+ else throw new Error('non-2xx audio');
+ })
+ .catch((e) => {
+ console.warn('Failed to get audio', e);
+ return video?.audioUrl?.[1] ?? null;
+ });
+ }
+ })();
+ video['_isInit'] = true;
+ renderPromise = video.init();
+ await renderPromise;
+ video['_isInit'] = false;
+ frameCount = video.length;
+ await audioSourcePromise;
+ };
+ const renderPreviewFrame = async (video: Video, frame: number) => {
+ const ourId = ++renderId;
+ if (renderPromise) await renderPromise;
+ if (renderId !== ourId) return;
+ renderPromise =
+ video.renderFrame({
+ frames: frame,
+ milliseconds: (frame / video.fps) * 1000,
+ seconds: frame / video.fps
+ }) ?? Promise.resolve();
+ return renderPromise;
+ };
+ $effect(() => {
+ if (canvas && canvas !== lastCanvas) newCanvas(canvas, VideoImplementation);
+ });
+ $effect(() => {
+ if (video) renderPreviewFrame(video, frame);
+ });
+ let playbackLoopId = 0;
+ const startPlaybackLoop = (id = ++playbackLoopId) => {
+ if (video && id === playbackLoopId) {
+ const ms = performance.now() - playbackStarted;
+ let f = Math.floor((ms / 1000) * video.fps) + playbackFrameOffset;
+
+ if (f > frameCount) {
+ f = frameCount;
+ playing = false;
+ }
+ frame = f;
+ renderPreviewFrame(video, frame).then(() =>
+ requestAnimationFrame(() => (playing ? startPlaybackLoop(id) : void 0))
+ );
+ }
+ };
+ $effect(() => {
+ if (playing) {
+ playbackStarted = performance.now();
+ playbackFrameOffset = frame;
+ startPlaybackLoop();
+ }
+ });
+ let loadedFrameTimestamp = false;
+ onMount(() => {
+ const t = sessionStorage.getItem('timestamp');
+ const tI = t ? parseInt(t, 36) : null;
+ if (tI && !isNaN(tI)) {
+ frame = tI;
+ requestAnimationFrame(() => (frame = tI));
+ }
+ loadedFrameTimestamp = true;
+ });
+ // TODO: implement waitin a few seconds before saving
+ $effect(() => {
+ if (loadedFrameTimestamp)
+ try {
+ sessionStorage.setItem('timestamp', frame.toString(36));
+ } catch (_) {}
+ });
+ $effect(() => {
+ if (audio && video && !playing) {
+ try {
+ const f = frame;
+ audio.currentTime = frame / video.fps;
+ audio.play();
+ (async () => {
+ const targetTime = (frame + 1) / video.fps;
+ while (audio.currentTime <= targetTime) {
+ await new Promise((rs) => requestAnimationFrame(rs));
+ if (playing || frame !== f) return;
+ }
+ audio.pause();
+ audio.currentTime = frame / video.fps;
+ })();
+ } catch (error) {
+ console.warn(error);
+ }
+ }
+ });
+ $effect(() => {
+ if (audio) {
+ if (playing) audio.play();
+ else audio.pause();
+ }
+ });
+</script>
+
+<svelte:window
+ onresize={() => {
+ if (canvas && video) {
+ (async () => {
+ video['_isInit'] = true;
+ renderPromise = await video.init();
+ video['_isInit'] = false;
+ renderPreviewFrame(video, frame);
+ })();
+ }
+ }}
+/>
+
+<Keybinds bind:frame {frameCount} fps={video?.fps} bind:playing bind:playbackStarted />
+
+<div class="p-2 w-screen h-screen relative flex flex-col">
+ <div class="flex-1 relative">
+ <div class="absolute top-0 left-0 w-full h-full flex items-center justify-center">
+ <canvas class="pointer-events-none bg-black" bind:this={canvas}>
+ Your browser doesn't support the canvas API.
+ </canvas>
+ {#if audioSource}
+ <audio src={audioSource} bind:this={audio}></audio>
+ {/if}
+ </div>
+ </div>
+ <FrameSlider bind:frame {frameCount} bind:playing bind:playbackStarted />
+</div>