diff options
feat: initial commit
Diffstat (limited to 'src/lib/Player')
-rw-r--r-- | src/lib/Player/FrameSlider.svelte | 67 | ||||
-rw-r--r-- | src/lib/Player/Keybinds.svelte | 47 | ||||
-rw-r--r-- | src/lib/Player/Player.svelte | 161 | ||||
-rw-r--r-- | src/lib/Player/Video.ts | 55 |
4 files changed, 330 insertions, 0 deletions
diff --git a/src/lib/Player/FrameSlider.svelte b/src/lib/Player/FrameSlider.svelte new file mode 100644 index 0000000..a632aa9 --- /dev/null +++ b/src/lib/Player/FrameSlider.svelte @@ -0,0 +1,67 @@ +<script lang="ts"> + import RangeSlider from '../vendor/svelte-range-slider/range-slider.svelte'; + let { + frame = $bindable(), + frameCount, + playing = $bindable(), + playbackStarted = $bindable() + }: { + frame: number; + frameCount: number; + playing: boolean; + playbackStarted: number; + } = $props(); +</script> + +<div class="w-full flex items-center justify-center pt-2"> + <div class="p-2 pr-1"> + <button + onclick={() => { + playing = !playing; + }} + aria-label={playing ? 'pause' : 'play'} + class="flex items-center justify-center" + ><svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="size-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d={!playing + ? 'M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z' + : 'M15.75 5.25v13.5m-7.5-13.5v13.5'} + /> + </svg></button + > + </div> + <div class="flex-1"> + <RangeSlider bind:value={frame} min={0} max={frameCount} /> + </div> + <div class="label p-2 pl-1"> + <input + type="number" + bind:value={frame} + class="w-16 appearance-none text-right" + style="-moz-appearance:textfield;" + max={frameCount} + onkeypress={(e) => { + e.stopPropagation(); + }} + onkeydown={(e) => { + e.stopPropagation(); + }} + /> + of {frameCount} + <!-- <input + type="number" + bind:value={frameCount} + class="w-8 appearance-none" + style="-moz-appearance:textfield;" + /> --> + </div> +</div> diff --git a/src/lib/Player/Keybinds.svelte b/src/lib/Player/Keybinds.svelte new file mode 100644 index 0000000..756a865 --- /dev/null +++ b/src/lib/Player/Keybinds.svelte @@ -0,0 +1,47 @@ +<script lang="ts"> + let { + frame = $bindable(), + frameCount, + fps, + playing = $bindable(), + playbackStarted = $bindable() + }: { + frame: number; + frameCount: number; + fps: number | undefined; + playing: boolean; + playbackStarted: number; + } = $props(); +</script> + +<svelte:window + onkeypress={(e) => { + switch (e.key) { + case ' ': + e.preventDefault(); + playing = !playing; + if (playing) playbackStarted = performance.now(); + break; + + // default: + // if (dev) console.debug('Keypress:', e.key); + // break; + } + }} + onkeydown={(e) => { + switch (e.key) { + case 'ArrowLeft': + e.preventDefault(); + frame = Math.max(frame - (e.ctrlKey ? (fps ?? 60) : 1), 0); + break; + case 'ArrowRight': + e.preventDefault(); + frame = Math.min(frame + (e.ctrlKey ? (fps ?? 60) : 1), frameCount); + break; + + // default: + // if (dev) console.debug('Keydown:', e.key); + // break; + } + }} +/> 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> diff --git a/src/lib/Player/Video.ts b/src/lib/Player/Video.ts new file mode 100644 index 0000000..78b3b8f --- /dev/null +++ b/src/lib/Player/Video.ts @@ -0,0 +1,55 @@ +export type FrameTime = { + milliseconds: number, + seconds: number, + frames: number +} +export abstract class Video { + public constructor(public canvas: HTMLCanvasElement) { }; + public abstract renderFrame(time: FrameTime): Promise<void> | void; + /** (re-)Initializes the Video object. Also called on window resizes. */ + public abstract init(): void | Promise<void>; + private _isInit = false; + /** The frames per second to render at */ + public abstract get fps(): number; + /** Length in frames */ + public abstract get length(): number; + /** A URL (and matching filename) to an ffmpeg-compatible audio file */ + public audioUrl?: readonly [filename: string, fileUrl: string]; + /** Resizes the canvas to a predetermined render resolution - must only be called in init() - do not overwrite */ + public resize(x: number, y: number) { + if (!this._isInit) throw new Error('Must only call resize() in init.') + this.canvas.width = x; + this.canvas.height = y; + const parentW = this.canvas.parentElement!.clientWidth, + parentH = this.canvas.parentElement!.clientHeight + if (x <= parentW && y <= parentH) { + this.canvas.style.width = `${x}px`; + this.canvas.style.height = `${y}px`; + } else if (x <= parentW && y > parentH) { + this.canvas.style.width = `${x / y * parentH}px`; + this.canvas.style.height = `${parentH}px`; + } else if (y <= parentH && x > parentW) { + this.canvas.style.width = `${parentW}px`; + this.canvas.style.height = `${y / x * parentW}px`; + } else { + if ((parentW / x) * y > parentH) { + this.canvas.style.width = `${(parentH / y) * x}px` + this.canvas.style.height = `${parentH}px` + } else { + this.canvas.style.width = `${parentW}px` + this.canvas.style.height = `${(parentW / x) * y}px` + } + } + } + /** The width of the video, in pixels */ + public get w() { + return this.canvas.width; + } + /** The height of the video, in pixels */ + public get h() { + return this.canvas.height; + } + /** Use to cleanup any mess you made - do not remove the canvas, it may be reused. */ + public cleanup() { }; +} +export type VideoConstructor = new (...params: ConstructorParameters<typeof Video>) => Video |