aboutsummaryrefslogtreecommitdiffstats
path: root/src/lib/Player/Player.svelte
blob: f6df1211935a154ffd9cde0cdfae49f7cadd5239 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
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>