aboutsummaryrefslogtreecommitdiffstats
path: root/src/lib/Player/Player.svelte
blob: 078e84ab2e598864f5f10a649a760ae70cea21bf (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
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
<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 playbackRelativeTo = $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({
			isPreview: true
		});
		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;
		if (frame > video.length) frame = video.length;
		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 && !playing) renderPreviewFrame(video, frame);
	});
	let playbackLoopId = 0;
	const startPlaybackLoop = (id = ++playbackLoopId) => {
		if (video && id === playbackLoopId) {
			const ms = performance.now() - playbackRelativeTo;
			let f = Math.floor((ms / 1000) * video.fps) + playbackFrameOffset;

			if (audio) {
				const desiredTime = frame / video.fps;
				const timeDelta = Math.abs(desiredTime - audio.currentTime);
				// If we get too out of lock-step with audio, we re-sync - we assume audio source is accurate*, as to avoid too much jumping
				// *it may not be
				if (desiredTime < audio.duration - 0.5 && timeDelta > 0.1) {
					if (timeDelta > 0.3) {
						// too out of sync, we trust ourselves more
						audio.currentTime = desiredTime;
					} else {
						f = audio.currentTime * video.fps;
					}
				}
			}

			if (f > frameCount) {
				f = frameCount;
				playbackRelativeTo = 0;
				playing = false;
			}
			frame = f;
			renderPreviewFrame(video, f).then(() =>
				requestAnimationFrame(() => (playing ? startPlaybackLoop(id) : void 0))
			);
		}
	};
	const startPlaying = () => {
		if (frame === frameCount && playbackRelativeTo === 0) {
			frame = 0;
		}
		playbackRelativeTo = performance.now();
		playbackFrameOffset = frame;
		startPlaybackLoop();
	};
	// playback starter
	$effect(() => {
		if (playing) {
			startPlaying();
		} else playbackRelativeTo = 0;
	});
	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;
	});
	let nextSaveFrameNumber = 0;
	let lastSave = 0;
	const saveFramePosition = async (frame: number) => {
		nextSaveFrameNumber = frame;
		await new Promise((rs) => setTimeout(rs, 100));
		if (frame === nextSaveFrameNumber || performance.now() - lastSave > 500)
			try {
				lastSave = performance.now();
				sessionStorage.setItem('timestamp', frame.toString(36));
			} catch (_) {}
	};
	$effect(() => {
		if (loadedFrameTimestamp) saveFramePosition(frame);
	});
	$effect(() => {
		if (audio && video && !playing && !playbackFrameOffset) {
			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);
			}
		}
	});
	const updateAudioPlayingState = (playing: boolean) => {
		if (audio) {
			if (playing) audio.play();
			else audio.pause();
		}
	};
	$effect(() => updateAudioPlayingState(playing));
</script>

<svelte:window
	onresize={() => {
		if (canvas && video) {
			(async () => {
				video['_isInit'] = true;
				renderPromise = await video.init({
					isPreview: true
				});
				video['_isInit'] = false;
				renderPreviewFrame(video, frame);
			})();
		}
	}}
/>

<Keybinds
	bind:frame
	{frameCount}
	fps={video?.fps}
	bind:playing
	bind:playbackStarted={playbackRelativeTo}
/>

<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} preload="auto"></audio>
			{/if}
		</div>
	</div>
	<FrameSlider bind:frame {frameCount} bind:playing bind:playbackStarted={playbackRelativeTo} />
</div>