268 lines
5.9 KiB
Svelte
268 lines
5.9 KiB
Svelte
<script lang="ts">
|
|
import { run } from 'svelte/legacy';
|
|
|
|
import { onDestroy, onMount } from 'svelte';
|
|
import { browser } from '$app/environment';
|
|
import debug from 'debug';
|
|
import { i18n } from '$lib/i18n';
|
|
const dbg = debug('app:camera');
|
|
|
|
let video: HTMLVideoElement = $state();
|
|
let devices: MediaDeviceInfo[] = $state([]);
|
|
let currentDevice: string | undefined = $state();
|
|
|
|
let requestResolution: [number, number] | 'auto' = $state('auto');
|
|
let requestFramerate: number | 'auto' = $state('auto');
|
|
let deviceInfo: {
|
|
resolution?: string;
|
|
frameRate?: number;
|
|
} = $state({});
|
|
let snapshot: string | undefined = $state();
|
|
let flipped = $state(false);
|
|
|
|
run(() => {
|
|
dbg('devices %O', devices);
|
|
});
|
|
run(() => {
|
|
dbg('currentDevice %s', currentDevice);
|
|
});
|
|
|
|
onMount(() => {
|
|
refreshDevices();
|
|
video.addEventListener('playing', () => {
|
|
if (browser && video?.srcObject instanceof MediaStream) {
|
|
deviceInfo = {
|
|
resolution: `${video.videoWidth}x${video.videoHeight}`,
|
|
frameRate: video?.srcObject?.getVideoTracks()[0]?.getSettings().frameRate
|
|
};
|
|
}
|
|
});
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (browser && video?.srcObject instanceof MediaStream) {
|
|
video.srcObject.getTracks().forEach((t) => t.stop());
|
|
}
|
|
});
|
|
|
|
async function refreshDevices() {
|
|
devices = (await navigator.mediaDevices.enumerateDevices()).filter(
|
|
(d) => d.kind === 'videoinput'
|
|
);
|
|
if (!currentDevice) {
|
|
currentDevice = devices[0]?.deviceId;
|
|
}
|
|
}
|
|
|
|
run(() => {
|
|
if (currentDevice) {
|
|
navigator.mediaDevices
|
|
.getUserMedia({
|
|
video: {
|
|
deviceId: currentDevice,
|
|
width: requestResolution === 'auto' ? undefined : requestResolution[0],
|
|
height: requestResolution === 'auto' ? undefined : requestResolution[1],
|
|
frameRate: requestFramerate === 'auto' ? undefined : requestFramerate
|
|
}
|
|
})
|
|
.then((stream) => {
|
|
video.srcObject = stream;
|
|
refreshDevices();
|
|
});
|
|
}
|
|
});
|
|
|
|
async function takeSnapshot() {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
if (flipped) {
|
|
ctx.scale(-1, 1);
|
|
ctx.translate(-canvas.width, 0);
|
|
}
|
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
snapshot = canvas.toDataURL('image/png');
|
|
}
|
|
</script>
|
|
|
|
<h2><i class="ti ti-camera"></i> {$i18n.t('Camera test')}</h2>
|
|
|
|
<div class="controls">
|
|
<label>
|
|
{$i18n.t('Device')}
|
|
<select bind:value={currentDevice} disabled={!devices.length}>
|
|
{#each devices as device}
|
|
<option value={device.deviceId}>{device.label || '???'}</option>
|
|
{:else}
|
|
<option>{$i18n.t('No camera found')}</option>
|
|
{/each}
|
|
</select>
|
|
</label>
|
|
<button onclick={refreshDevices}>
|
|
<i class="ti ti-refresh"></i>
|
|
{$i18n.t('Refresh')}
|
|
</button>
|
|
<div class="separator"></div>
|
|
<label>
|
|
{$i18n.t('Resolution')}
|
|
<select bind:value={requestResolution}>
|
|
<option value="auto">Auto</option>
|
|
<option value={[4096, 2160]}>4096x2160</option>
|
|
<option value={[3840, 2160]}>3840x2160</option>
|
|
<option value={[1920, 1080]}>1920x1080</option>
|
|
<option value={[1280, 720]}>1280x720</option>
|
|
<option value={[640, 480]}>640x480</option>
|
|
<option value={[320, 240]}>320x240</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
{$i18n.t('Frame rate')}
|
|
<select bind:value={requestFramerate}>
|
|
<option value="auto">Auto</option>
|
|
<option value={120}>120 fps</option>
|
|
<option value={60}>60 fps</option>
|
|
<option value={30}>30 fps</option>
|
|
<option value={15}>15 fps</option>
|
|
<option value={10}>10 fps</option>
|
|
<option value={5}>5 fps</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="display" class:snapshot={Boolean(snapshot)}>
|
|
<!-- svelte-ignore a11y_media_has_caption -->
|
|
<video class:flipped bind:this={video} autoplay class:unloaded={!currentDevice}></video>
|
|
{#if snapshot}
|
|
<!-- svelte-ignore a11y_missing_attribute -->
|
|
<!--suppress HtmlRequiredAltAttribute -->
|
|
<img src={snapshot} />
|
|
<button onclick={() => (snapshot = undefined)}><i class="ti ti-x"></i></button>
|
|
{/if}
|
|
</div>
|
|
|
|
<footer>
|
|
{#if !currentDevice}
|
|
<span class="subdued">{$i18n.t('No camera selected')}</span>
|
|
{:else}
|
|
<ul>
|
|
{#key currentDevice}
|
|
<li>
|
|
{$i18n.t('Resolution')}: <strong>{deviceInfo.resolution || '???'}</strong>
|
|
</li>
|
|
<li>
|
|
{$i18n.t('Frame rate')}: <strong>{deviceInfo.frameRate || '???'}</strong>
|
|
</li>
|
|
{/key}
|
|
</ul>
|
|
<div class="controls">
|
|
<button onclick={takeSnapshot}>
|
|
<i class="ti ti-camera"></i>
|
|
{$i18n.t('Take picture')}
|
|
</button>
|
|
<button onclick={() => (flipped = !flipped)}>
|
|
<i class="ti ti-flip-vertical"></i>
|
|
{#if flipped}
|
|
{$i18n.t('Unflip image')}
|
|
{:else}
|
|
{$i18n.t('Flip image')}
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</footer>
|
|
|
|
<style>
|
|
.controls {
|
|
display: flex;
|
|
align-items: end;
|
|
justify-content: stretch;
|
|
gap: 1em;
|
|
|
|
& label:first-child {
|
|
flex-grow: 1;
|
|
min-width: 0;
|
|
}
|
|
}
|
|
|
|
select {
|
|
background: black;
|
|
color: white;
|
|
padding: 0.25em 0.5em;
|
|
border-radius: 0.25em;
|
|
border: 1px solid white;
|
|
}
|
|
|
|
label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2em;
|
|
|
|
font-size: 0.8em;
|
|
& select {
|
|
font-size: initial;
|
|
}
|
|
}
|
|
|
|
.display {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
flex-grow: 1;
|
|
justify-content: center;
|
|
|
|
& img {
|
|
object-fit: contain;
|
|
}
|
|
|
|
& button {
|
|
position: absolute;
|
|
top: 1em;
|
|
right: 1em;
|
|
}
|
|
|
|
&.snapshot {
|
|
& video {
|
|
display: none;
|
|
}
|
|
}
|
|
}
|
|
|
|
video {
|
|
min-width: 0;
|
|
min-height: 0;
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
|
|
margin: 1em 0;
|
|
|
|
&.unloaded {
|
|
background: repeating-linear-gradient(45deg, gray, gray 20px, darkgray 20px, darkgray 40px);
|
|
flex-grow: 1;
|
|
}
|
|
|
|
&.flipped {
|
|
transform: scaleX(-1);
|
|
}
|
|
}
|
|
|
|
footer {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
ul {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: inline-flex;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.subdued {
|
|
opacity: 0.8;
|
|
}
|
|
</style>
|