223 lines
5 KiB
Vue
223 lines
5 KiB
Vue
<script setup lang="ts">
|
|
import { RiLoader4Fill } from "@remixicon/vue";
|
|
import { computed, onMounted, ref, useTemplateRef } from "vue";
|
|
import CPhotoButtonFlip from "./components/CPhotoButtonFlip.vue";
|
|
import CPhotoButtonGallery from "./components/CPhotoButtonGallery.vue";
|
|
import CPhotoButtonRecord from "./components/CPhotoButtonRecord.vue";
|
|
import { assert } from "./lib/assert";
|
|
|
|
const endpoint = "api/image";
|
|
const video = useTemplateRef<HTMLVideoElement>("video");
|
|
|
|
const stream = ref<MediaStream>();
|
|
const facing = ref<string>();
|
|
const mirrored = computed(() => facing.value === "user");
|
|
const covered = ref(false);
|
|
const originals = ref<boolean>();
|
|
|
|
const originalsInfo = computed(() => {
|
|
if (originals.value === true) {
|
|
return "Uploaded images are saved.";
|
|
} else if (originals.value === false) {
|
|
return "Uploaded images are not saved.";
|
|
} else {
|
|
return "Uploaded images may be saved.";
|
|
}
|
|
});
|
|
|
|
function getFacingModeFromStream(stream: MediaStream): string | undefined {
|
|
const videos = stream.getVideoTracks();
|
|
if (videos.length === 0) return undefined;
|
|
const video = videos[0];
|
|
return video?.getSettings().facingMode;
|
|
}
|
|
|
|
function deinitStream() {
|
|
if (stream.value === undefined) return;
|
|
for (const track of stream.value.getTracks()) {
|
|
track.stop();
|
|
}
|
|
stream.value = undefined;
|
|
}
|
|
|
|
async function initStream(facingMode?: string) {
|
|
assert(video.value !== null);
|
|
const video_ = video.value;
|
|
|
|
// If the tracks are not all stopped, getUserMedia throws an exception.
|
|
deinitStream();
|
|
|
|
stream.value = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: { ideal: facingMode } },
|
|
});
|
|
|
|
facing.value = getFacingModeFromStream(stream.value);
|
|
|
|
video_.srcObject = stream.value;
|
|
}
|
|
|
|
async function initOriginals() {
|
|
const response = await fetch(endpoint);
|
|
const info = (await response.json()) as { originals?: boolean };
|
|
originals.value = info.originals ?? false;
|
|
}
|
|
|
|
async function waitAtLeast(duration: number, since: number) {
|
|
const now = Date.now();
|
|
const wait = duration - (now - since);
|
|
if (wait > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
}
|
|
}
|
|
|
|
async function postImage(image: Blob | File) {
|
|
const form = new FormData();
|
|
form.append("image", image);
|
|
form.append("caption", new Date().toLocaleString());
|
|
|
|
const start = Date.now();
|
|
covered.value = true;
|
|
try {
|
|
await fetch(endpoint, { method: "POST", body: form });
|
|
} catch (e) {
|
|
console.error("Error uploading image:", e);
|
|
}
|
|
await waitAtLeast(500, start);
|
|
covered.value = false;
|
|
}
|
|
|
|
async function onGallery(file: File) {
|
|
try {
|
|
await postImage(file);
|
|
} finally {
|
|
await initStream(facing.value);
|
|
}
|
|
}
|
|
|
|
async function onRecord() {
|
|
assert(video.value !== null);
|
|
const video_ = video.value;
|
|
|
|
const canvas = document.createElement("canvas");
|
|
const ctx = canvas.getContext("2d");
|
|
assert(ctx !== null);
|
|
|
|
const scale = originals.value ? 1 : 384 / video_.videoWidth;
|
|
canvas.width = video_.videoWidth * scale; // Yes, slightly redundant
|
|
canvas.height = video_.videoHeight * scale;
|
|
ctx.drawImage(video_, 0, 0, canvas.width, canvas.height);
|
|
|
|
const blob = await new Promise<Blob | null>((resolve) => {
|
|
if (originals.value) {
|
|
canvas.toBlob(resolve, "image/jpeg", 0.9);
|
|
} else {
|
|
canvas.toBlob(resolve);
|
|
}
|
|
});
|
|
assert(blob !== null);
|
|
|
|
await postImage(blob);
|
|
}
|
|
|
|
async function onFlip() {
|
|
const facingOpposite = facing.value === "user" ? "environment" : "user";
|
|
await initStream(facingOpposite);
|
|
}
|
|
|
|
onMounted(() => {
|
|
void initStream();
|
|
void initOriginals();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<video ref="video" :class="{ mirrored }" autoplay playsinline></video>
|
|
<div class="originals">
|
|
<p>{{ originalsInfo }}</p>
|
|
</div>
|
|
<div class="buttons">
|
|
<CPhotoButtonGallery @click="onGallery" />
|
|
<CPhotoButtonRecord :disabled="stream === undefined" @click="onRecord" />
|
|
<CPhotoButtonFlip
|
|
:disabled="stream === undefined || facing === undefined"
|
|
@click="onFlip"
|
|
/>
|
|
</div>
|
|
<div class="cover" :class="{ hidden: !covered }">
|
|
<RiLoader4Fill size="48px" />
|
|
</div>
|
|
</template>
|
|
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
background-color: black;
|
|
}
|
|
</style>
|
|
|
|
<style scoped>
|
|
video {
|
|
position: absolute;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
video.mirrored {
|
|
scale: -1 1;
|
|
}
|
|
|
|
.originals {
|
|
position: absolute;
|
|
top: 0;
|
|
width: 100%;
|
|
display: flex;
|
|
justify-content: center;
|
|
}
|
|
|
|
.originals p {
|
|
margin: 0;
|
|
margin-top: 20px;
|
|
padding: 0.2em 0.8em;
|
|
border-radius: 10em;
|
|
background-color: #fffa;
|
|
text-align: center;
|
|
}
|
|
|
|
.buttons {
|
|
position: absolute;
|
|
bottom: 0;
|
|
width: 100%;
|
|
margin-bottom: 20px;
|
|
|
|
display: flex;
|
|
justify-content: space-evenly;
|
|
align-items: center;
|
|
}
|
|
|
|
.cover {
|
|
position: absolute;
|
|
width: 100%;
|
|
height: 100%;
|
|
background-color: white;
|
|
transition: opacity 100ms linear;
|
|
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.cover.hidden {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.cover svg {
|
|
animation: spinner 2s linear infinite;
|
|
}
|
|
|
|
@keyframes spinner {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
</style>
|