Port photo ui to vue
This commit is contained in:
parent
06ec6d7792
commit
9e0e0f4359
7 changed files with 230 additions and 146 deletions
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
|
|
@ -17,6 +17,9 @@ importers:
|
||||||
|
|
||||||
showbits-thermal-printer-ui:
|
showbits-thermal-printer-ui:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@remixicon/vue':
|
||||||
|
specifier: ^4.6.0
|
||||||
|
version: 4.6.0(vue@3.5.13(typescript@5.8.2))
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.13
|
specifier: ^3.5.13
|
||||||
version: 3.5.13(typescript@5.8.2)
|
version: 3.5.13(typescript@5.8.2)
|
||||||
|
|
@ -450,6 +453,11 @@ packages:
|
||||||
'@polka/url@1.0.0-next.28':
|
'@polka/url@1.0.0-next.28':
|
||||||
resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==}
|
resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==}
|
||||||
|
|
||||||
|
'@remixicon/vue@4.6.0':
|
||||||
|
resolution: {integrity: sha512-OKDNBHM4gPbXZkYpKMu6xxLIP8LaioQQ7ipC10IfY4Wh5cmhrtA3aQIWgeCWGToRn1XoYoKAD8K95jUmE24hLQ==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: '>= 3'
|
||||||
|
|
||||||
'@rollup/pluginutils@5.1.4':
|
'@rollup/pluginutils@5.1.4':
|
||||||
resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==}
|
resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
@ -1887,6 +1895,10 @@ snapshots:
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.28': {}
|
'@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)':
|
'@rollup/pluginutils@5.1.4(rollup@4.37.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.7
|
'@types/estree': 1.0.7
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@remixicon/vue": "^4.6.0",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
<script setup lang="ts">
|
<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";
|
||||||
|
|
||||||
onMounted(async () => {
|
const video = useTemplateRef<HTMLVideoElement>("video");
|
||||||
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 =
|
const live = ref(false);
|
||||||
new URLSearchParams(window.location.search).get("facing") ?? undefined;
|
const facing = ref<string>();
|
||||||
|
const mirrored = computed(() => facing.value === "user");
|
||||||
|
const covered = ref(false);
|
||||||
|
|
||||||
function getStreamFacingMode(stream: MediaStream): string | undefined {
|
function getFacingModeFromStream(stream: MediaStream): string | undefined {
|
||||||
const videos = stream.getVideoTracks();
|
const videos = stream.getVideoTracks();
|
||||||
if (videos.length === 0) return undefined;
|
if (videos.length === 0) return undefined;
|
||||||
const video = videos[0];
|
const video = videos[0];
|
||||||
|
|
@ -18,27 +20,18 @@ onMounted(async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initStream(facingMode?: string) {
|
async function initStream(facingMode?: string) {
|
||||||
// Display video
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
let stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
video: { facingMode: { ideal: facingMode } },
|
video: { facingMode: { ideal: facingMode } },
|
||||||
});
|
});
|
||||||
video.srcObject = stream;
|
|
||||||
|
|
||||||
// Flip video horizontally if it's facing the user
|
// Display stream as video
|
||||||
const facing = getStreamFacingMode(stream);
|
assert(video.value !== null);
|
||||||
if (facing !== "environment") {
|
video.value.srcObject = stream;
|
||||||
video.classList.add("mirrored");
|
|
||||||
|
live.value = true;
|
||||||
|
facing.value = getFacingModeFromStream(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
async function waitAtLeast(duration: number, since: number) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const wait = duration - (now - since);
|
const wait = duration - (now - since);
|
||||||
|
|
@ -47,47 +40,58 @@ onMounted(async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button.addEventListener("click", () => {
|
async function onRecord() {
|
||||||
const canvas = document.createElement("canvas");
|
assert(video.value !== null);
|
||||||
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
|
const canvas = document.createElement("canvas");
|
||||||
canvas.toBlob(async (blob) => {
|
|
||||||
if (blob === null) return;
|
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();
|
const form = new FormData();
|
||||||
form.append("image", blob);
|
form.append("image", blob);
|
||||||
form.append("caption", new Date().toLocaleString());
|
form.append("caption", new Date().toLocaleString());
|
||||||
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
cover.classList.remove("hidden");
|
covered.value = true;
|
||||||
try {
|
try {
|
||||||
await fetch("api/image", { method: "POST", body: form });
|
await fetch("api/image", { method: "POST", body: form });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error uploading image:", e);
|
console.error("Error uploading image:", e);
|
||||||
}
|
}
|
||||||
await waitAtLeast(500, start);
|
await waitAtLeast(500, start);
|
||||||
cover.classList.add("hidden");
|
covered.value = false;
|
||||||
});
|
}
|
||||||
});
|
|
||||||
|
async function onFlip() {
|
||||||
|
const facingOpposite = facing.value === "user" ? "environment" : "user";
|
||||||
|
await initStream(facingOpposite);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await initStream();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<video id="video" autoplay playsinline></video>
|
<video ref="video" :class="{ mirrored }" autoplay playsinline></video>
|
||||||
<button id="button"><div class="circle"></div></button>
|
<div class="buttons">
|
||||||
<a id="flip" hidden>
|
<CPhotoButtonGallery style="visibility: hidden" />
|
||||||
<svg viewBox="0 0 6 6">
|
<CPhotoButtonRecord :disabled="!live" @click="onRecord" />
|
||||||
<path fill="#fff" stroke="none" d="M0,2h1v4h1v-4h1l-1.5,-2"></path>
|
<CPhotoButtonFlip :disabled="facing === undefined" @click="onFlip" />
|
||||||
<path fill="#fff" stroke="none" d="M3,4h1v-4h1v4h1l-1.5,2"></path>
|
</div>
|
||||||
</svg>
|
<div class="cover" :class="{ hidden: !covered }"></div>
|
||||||
</a>
|
|
||||||
<div id="cover" class="hidden"></div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -95,7 +99,9 @@ body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
video {
|
video {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -106,72 +112,18 @@ video.mirrored {
|
||||||
scale: -1 1;
|
scale: -1 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#button {
|
.buttons {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 20px;
|
bottom: 0;
|
||||||
left: 50%;
|
width: 100%;
|
||||||
transform: translateX(-50%);
|
margin-bottom: 20px;
|
||||||
|
|
||||||
width: 100px;
|
display: flex;
|
||||||
height: 100px;
|
justify-content: space-evenly;
|
||||||
|
align-items: center;
|
||||||
border: 10px solid #f00;
|
|
||||||
border-radius: 100px;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#button:active {
|
.cover {
|
||||||
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 {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
@ -179,7 +131,7 @@ video.mirrored {
|
||||||
transition: background-color 100ms linear;
|
transition: background-color 100ms linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
#cover.hidden {
|
.cover.hidden {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
19
showbits-thermal-printer-ui/src/lib/assert.ts
Normal file
19
showbits-thermal-printer-ui/src/lib/assert.ts
Normal 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");
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue