Port photo ui to vue

This commit is contained in:
Joscha 2025-03-26 23:14:06 +01:00
parent 06ec6d7792
commit 9e0e0f4359
7 changed files with 230 additions and 146 deletions

12
pnpm-lock.yaml generated
View file

@ -17,6 +17,9 @@ importers:
showbits-thermal-printer-ui:
dependencies:
'@remixicon/vue':
specifier: ^4.6.0
version: 4.6.0(vue@3.5.13(typescript@5.8.2))
vue:
specifier: ^3.5.13
version: 3.5.13(typescript@5.8.2)
@ -450,6 +453,11 @@ packages:
'@polka/url@1.0.0-next.28':
resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==}
'@remixicon/vue@4.6.0':
resolution: {integrity: sha512-OKDNBHM4gPbXZkYpKMu6xxLIP8LaioQQ7ipC10IfY4Wh5cmhrtA3aQIWgeCWGToRn1XoYoKAD8K95jUmE24hLQ==}
peerDependencies:
vue: '>= 3'
'@rollup/pluginutils@5.1.4':
resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==}
engines: {node: '>=14.0.0'}
@ -1887,6 +1895,10 @@ snapshots:
'@polka/url@1.0.0-next.28': {}
'@remixicon/vue@4.6.0(vue@3.5.13(typescript@5.8.2))':
dependencies:
vue: 3.5.13(typescript@5.8.2)
'@rollup/pluginutils@5.1.4(rollup@4.37.0)':
dependencies:
'@types/estree': 1.0.7

View file

@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@remixicon/vue": "^4.6.0",
"vue": "^3.5.13"
},
"devDependencies": {

View file

@ -1,93 +1,97 @@
<script setup lang="ts">
import { onMounted } from "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 video = useTemplateRef<HTMLVideoElement>("video");
const live = ref(false);
const facing = ref<string>();
const mirrored = computed(() => facing.value === "user");
const covered = ref(false);
function getFacingModeFromStream(stream: MediaStream): string | undefined {
const videos = stream.getVideoTracks();
if (videos.length === 0) return undefined;
const video = videos[0];
return video?.getSettings().facingMode;
}
async function initStream(facingMode?: string) {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: facingMode } },
});
// Display stream as video
assert(video.value !== null);
video.value.srcObject = stream;
live.value = true;
facing.value = getFacingModeFromStream(stream);
}
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 onRecord() {
assert(video.value !== null);
const canvas = document.createElement("canvas");
const scale = 384 / video.value.videoWidth;
canvas.width = video.value.videoWidth * scale;
canvas.height = video.value.videoHeight * scale;
const ctx = canvas.getContext("2d");
assert(ctx !== null);
ctx.drawImage(video.value, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve);
});
assert(blob !== null);
const form = new FormData();
form.append("image", blob);
form.append("caption", new Date().toLocaleString());
const start = Date.now();
covered.value = true;
try {
await fetch("api/image", { method: "POST", body: form });
} catch (e) {
console.error("Error uploading image:", e);
}
await waitAtLeast(500, start);
covered.value = false;
}
async function onFlip() {
const facingOpposite = facing.value === "user" ? "environment" : "user";
await initStream(facingOpposite);
}
onMounted(async () => {
const video = document.getElementById("video") as HTMLVideoElement;
const button = document.getElementById("button") as HTMLButtonElement;
const flip = document.getElementById("flip") as HTMLAnchorElement;
const cover = document.getElementById("cover") as HTMLDivElement;
const facing =
new URLSearchParams(window.location.search).get("facing") ?? undefined;
function getStreamFacingMode(stream: MediaStream): string | undefined {
const videos = stream.getVideoTracks();
if (videos.length === 0) return undefined;
const video = videos[0];
return video?.getSettings().facingMode;
}
async function initStream(facingMode?: string) {
// Display video
let stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: facingMode } },
});
video.srcObject = stream;
// Flip video horizontally if it's facing the user
const facing = getStreamFacingMode(stream);
if (facing !== "environment") {
video.classList.add("mirrored");
}
// Enable or disable flip button
const canFlip = facing !== undefined;
const facingOpposite = facing === "user" ? "environment" : "user";
flip.hidden = !canFlip;
flip.setAttribute("href", `?facing=${facingOpposite}`);
}
await initStream(facing);
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));
}
}
button.addEventListener("click", () => {
const canvas = document.createElement("canvas");
const scale = 384 / video.videoWidth;
canvas.width = video.videoWidth * scale;
canvas.height = video.videoHeight * scale;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const ctx = canvas.getContext("2d")!;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// eslint-disable-next-line @typescript-eslint/no-misused-promises
canvas.toBlob(async (blob) => {
if (blob === null) return;
const form = new FormData();
form.append("image", blob);
form.append("caption", new Date().toLocaleString());
const start = Date.now();
cover.classList.remove("hidden");
try {
await fetch("api/image", { method: "POST", body: form });
} catch (e) {
console.error("Error uploading image:", e);
}
await waitAtLeast(500, start);
cover.classList.add("hidden");
});
});
await initStream();
});
</script>
<template>
<video id="video" autoplay playsinline></video>
<button id="button"><div class="circle"></div></button>
<a id="flip" hidden>
<svg viewBox="0 0 6 6">
<path fill="#fff" stroke="none" d="M0,2h1v4h1v-4h1l-1.5,-2"></path>
<path fill="#fff" stroke="none" d="M3,4h1v-4h1v4h1l-1.5,2"></path>
</svg>
</a>
<div id="cover" class="hidden"></div>
<video ref="video" :class="{ mirrored }" autoplay playsinline></video>
<div class="buttons">
<CPhotoButtonGallery style="visibility: hidden" />
<CPhotoButtonRecord :disabled="!live" @click="onRecord" />
<CPhotoButtonFlip :disabled="facing === undefined" @click="onFlip" />
</div>
<div class="cover" :class="{ hidden: !covered }"></div>
</template>
<style>
@ -95,7 +99,9 @@ body {
margin: 0;
background-color: black;
}
</style>
<style scoped>
video {
position: absolute;
width: 100%;
@ -106,72 +112,18 @@ video.mirrored {
scale: -1 1;
}
#button {
.buttons {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
bottom: 0;
width: 100%;
margin-bottom: 20px;
width: 100px;
height: 100px;
border: 10px solid #f00;
border-radius: 100px;
background-color: transparent;
display: flex;
justify-content: space-evenly;
align-items: center;
}
#button:active {
border-color: #fff;
}
#button .circle {
width: 60px;
height: 60px;
border-radius: 60px;
margin: auto;
background-color: #f00;
}
#button:active .circle {
background-color: #a00;
}
#flip {
position: absolute;
bottom: 20px;
right: 20px;
box-sizing: border-box;
width: 60px;
height: 60px;
background-color: transparent;
border: 5px solid #fff;
border-radius: 100px;
touch-action: manipulation;
}
#flip:active {
background-color: #fff;
}
#flip svg {
width: 60%;
height: 60%;
position: relative;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
#flip:active path {
fill: #000;
}
#cover {
.cover {
position: absolute;
width: 100%;
height: 100%;
@ -179,7 +131,7 @@ video.mirrored {
transition: background-color 100ms linear;
}
#cover.hidden {
.cover.hidden {
background-color: transparent;
pointer-events: none;
}

View file

@ -0,0 +1,35 @@
<script setup lang="ts">
import { RiCameraSwitchFill } from "@remixicon/vue";
</script>
<template>
<button>
<RiCameraSwitchFill size="48px" />
</button>
</template>
<style scoped>
button {
padding: 8px;
color: white;
border: 5px solid white;
border-radius: 100px;
background-color: transparent;
}
button:enabled:active {
color: black;
background-color: white;
}
button:disabled {
--disabled: #444;
color: var(--disabled);
border-color: var(--disabled);
}
svg {
display: block;
}
</style>

View file

@ -0,0 +1,30 @@
<script setup lang="ts">
import { RiMultiImageFill } from "@remixicon/vue";
</script>
<template>
<label>
<RiMultiImageFill size="48px" />
</label>
</template>
<style scoped>
label {
padding: 8px;
border: 5px solid white;
border-radius: 100px;
background-color: transparent;
color: white;
}
label:active {
background-color: white;
color: black;
}
svg {
display: block;
}
</style>

View file

@ -0,0 +1,35 @@
<script setup lang="ts"></script>
<template>
<button><div></div></button>
</template>
<style scoped>
button {
width: 100px;
height: 100px;
--color: red;
border: 10px solid var(--color);
border-radius: 100px;
background-color: transparent;
}
button:enabled:active {
--color: white;
}
button:disabled {
--color: #444;
}
button div {
width: 60px;
height: 60px;
border-radius: 60px;
margin: auto;
background-color: var(--color);
}
</style>

View file

@ -0,0 +1,19 @@
export class AssertionError extends Error {}
export function assert(
condition: boolean,
description?: string,
): asserts condition {
if (condition) return;
if (description === undefined) {
description = "assertion failed";
console.error("assertion failed");
} else {
console.error("assertion failed:", description);
}
throw new AssertionError(description);
}
export function assertUnreachable(): never {
assert(false, "unreachable code reached");
}