feat: (mostly ai gen) touchscreen oriented viewer

This commit is contained in:
2026-01-12 23:01:57 +01:00
parent 103644acc3
commit c8d9a4e132
6 changed files with 176 additions and 20 deletions

View File

@@ -1,20 +1,46 @@
<script setup lang="ts">
import { ref } from "vue"
import { ref, watch } from "vue"
import { Delete } from "lucide-vue-next"
import {
PinInput,
PinInputGroup,
PinInputSlot,
} from "@/components/ui/pin-input"
import { Button } from "@/components/ui/button"
import { useViewerStore } from "~/state/viewer";
const viewerStore = useViewerStore()
const digits = ref<string[]>([])
// Sync local digits with store code
watch(digits, (newDigits) => {
const code = newDigits.join('')
viewerStore.code = code
})
// Also sync if store updates from elsewhere (though unlikely in this flow)
watch(() => viewerStore.code, (newCode) => {
if (newCode !== digits.value.join('')) {
digits.value = newCode.split('')
}
})
const handleNumPad = (num: number) => {
if (digits.value.length < 6) {
digits.value = [...digits.value, num.toString()]
}
}
const handleBackspace = () => {
digits.value = digits.value.slice(0, -1)
}
</script>
<template>
<div>
<div class="flex flex-col items-center gap-6">
<PinInput
id="pin-input"
@complete="(viewerStore.code = $event.join(''))"
v-model="digits"
type="number"
>
<PinInputGroup>
@@ -22,8 +48,41 @@ const viewerStore = useViewerStore()
v-for="(id, index) in 6"
:key="id"
:index="index"
class="h-14 w-10 sm:h-16 sm:w-12 text-lg sm:text-xl"
/>
</PinInputGroup>
</PinInput>
<!-- Touchscreen Numpad -->
<div class="grid grid-cols-3 gap-3 w-full max-w-[280px]">
<Button
v-for="n in 9"
:key="n"
variant="outline"
class="h-14 text-xl font-medium active:scale-95 transition-transform"
@click="handleNumPad(n)"
>
{{ n }}
</Button>
<div class="col-span-1"></div> <!-- Spacer -->
<Button
variant="outline"
class="h-14 text-xl font-medium active:scale-95 transition-transform"
@click="handleNumPad(0)"
>
0
</Button>
<Button
variant="ghost"
class="h-14 active:scale-95 transition-transform hover:bg-destructive/10 hover:text-destructive"
@click="handleBackspace"
:disabled="digits.length === 0"
>
<Delete class="w-6 h-6" />
</Button>
</div>
</div>
</template>
</template>

View File

@@ -18,6 +18,7 @@ export interface PresetAuthor {
fullName: string | null;
profileImageUrl: string | null;
username: string | null;
email: string | null;
}
export interface PresetShareResponse {

View File

@@ -160,5 +160,6 @@ export async function getPresetAuthorData(event: H3Event, presetId: string) {
fullName: user.fullName,
profileImageUrl: user.imageUrl,
username: user.username,
email: user.primaryEmailAddress?.emailAddress || null,
};
}

View File

@@ -1,27 +1,84 @@
<template>
<div class="flex flex-col items-center justify-center gap-6 mt-10 px-4">
<h1>helium</h1>
<p>effortless screensharing powered by webrtc</p>
<app-code-input />
<div class="flex flex-col items-center justify-center gap-6 mt-10 px-4 min-h-[80vh]">
<div v-if="!isConnected" class="flex flex-col items-center gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div class="text-center space-y-2">
<h1 class="text-4xl font-bold tracking-tight">helium</h1>
<p class="text-muted-foreground text-lg">effortless screensharing powered by webrtc</p>
</div>
<app-code-input />
<NuxtLink to="/stream">
<Button variant="link" class="text-muted-foreground hover:text-primary">
host instead?
</Button>
</NuxtLink>
</div>
<div class="video relative w-full max-w-1/2 aspect-video">
<div
class="video transition-all duration-500 ease-in-out"
:class="[
isConnected
? 'fixed inset-0 z-50 w-full h-full bg-black'
: 'relative w-full max-w-3xl aspect-video rounded-xl overflow-hidden border shadow-sm bg-muted/50'
]"
>
<!-- Status Overlay -->
<div
v-if="!isConnected"
class="absolute inset-0 bg-black flex items-center justify-center z-10 text-white"
class="absolute inset-0 flex items-center justify-center z-10 p-4 text-center"
>
{{ viewerStore.connectionStatus }}
<div v-if="viewerStore.isDisconnected" class="space-y-4">
<p class="text-sm font-medium text-muted-foreground">stream ended</p>
<Button @click="handleReset" variant="outline">
Enter another code
</Button>
</div>
<div v-else-if="viewerStore.connectionStatus !== 'waiting for a code'" class="space-y-4">
<div class="animate-spin w-8 h-8 border-4 border-primary border-t-transparent rounded-full mx-auto" />
<p class="text-sm font-medium text-muted-foreground">{{ viewerStore.connectionStatus }}</p>
</div>
<p v-else class="text-muted-foreground/50 text-sm">
enter code to join stream
</p>
</div>
<!-- Video Feed -->
<video
ref="videofeedRef"
autoplay
playsinline
controls
class="bg-black w-full h-full"
:controls="false"
class="w-full h-full object-contain bg-black"
@loadeddata="isConnected = true"
/>
</div>
<NuxtLink to="/stream"><Button>host instead?</Button></NuxtLink>
<!-- Connected Controls Overlay -->
<div
v-if="isConnected"
class="absolute top-0 left-0 right-0 p-4 flex justify-between items-start opacity-0 hover:opacity-100 transition-opacity bg-gradient-to-b from-black/50 to-transparent"
>
<Button
variant="destructive"
size="lg"
class="gap-2 shadow-lg"
@click="cleanupViewing"
>
<LogOut class="w-5 h-5" />
Disconnect
</Button>
<Button
variant="secondary"
size="lg"
class="gap-2 shadow-lg"
@click="toggleFullscreen"
>
<Maximize class="w-5 h-5" />
Fullscreen
</Button>
</div>
</div>
</div>
</template>
@@ -30,11 +87,14 @@ import { useWebSocket } from "@vueuse/core";
import { useViewerStore } from "~/state/viewer";
import { Button } from "@/components/ui/button";
import { useWebSocketUrl } from "~/composables/useWebSocketUrl";
import { LogOut, Maximize } from "lucide-vue-next";
const isConnected = ref(false);
const viewerStore = useViewerStore();
const { code: codeRef } = storeToRefs(viewerStore);
const wsUrl = useWebSocketUrl();
const videofeedRef = ref<HTMLVideoElement | null>(null);
const { send, close: closeWebSocket } = useWebSocket(wsUrl, {
autoReconnect: true,
heartbeat: {
@@ -90,7 +150,11 @@ const { send, close: closeWebSocket } = useWebSocket(wsUrl, {
viewerStore.setConnectionStatus(
`connection ${peerConnection.connectionState}`,
);
isConnected.value = false;
// Don't set isConnected = false immediately here to avoid flickering if it's a temp glitch,
// but usually disconnected means it's over.
if (peerConnection.connectionState !== "connected") {
isConnected.value = false;
}
}
};
@@ -161,8 +225,6 @@ const { send, close: closeWebSocket } = useWebSocket(wsUrl, {
},
});
const videofeedRef = ref<HTMLVideoElement | null>(null);
const startWebRTCConnection = async () => {
send(
JSON.stringify({
@@ -190,10 +252,35 @@ function cleanupViewing() {
if (videofeedRef.value) {
videofeedRef.value.srcObject = null;
}
// Clear code
viewerStore.code = '';
// Reset connection status
viewerStore.setConnectionStatus("disconnected");
isConnected.value = false;
// Exit fullscreen if active
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
}
function toggleFullscreen() {
if (!videofeedRef.value) return;
if (!document.fullscreenElement) {
videofeedRef.value.requestFullscreen().catch((err) => {
console.error(`Error attempting to enable fullscreen: ${err.message}`);
});
} else {
document.exitFullscreen();
}
}
function handleReset() {
viewerStore.resetDisconnected();
viewerStore.setConnectionStatus('waiting for a code');
}
// Cleanup on component unmount
@@ -214,4 +301,4 @@ onMounted(() => {
window.removeEventListener("beforeunload", handleBeforeUnload);
});
});
</script>
</script>

View File

@@ -22,6 +22,7 @@
{{
(response.author?.fullName ||
response.author?.username ||
response.author?.email ||
"?")[0]!.toUpperCase()
}}
</div>
@@ -68,7 +69,7 @@
<div class="mt-8 flex justify-end gap-3">
<Button variant="outline" @click="navigateTo('/')">Cancel</Button>
<Button @click="importPreset" :disabled="isImporting">
{{ isImporting ? 'Importing...' : 'Import Preset' }}
{{ isImporting ? "Importing..." : "Import Preset" }}
</Button>
</div>
</CardContent>

View File

@@ -5,6 +5,7 @@ export const useViewerStore = defineStore('viewer', {
code: '',
peerConnection: null as RTCPeerConnection | null,
connectionStatus: 'waiting for a code',
isDisconnected: false,
}),
actions: {
setCode(code: string) {
@@ -18,6 +19,12 @@ export const useViewerStore = defineStore('viewer', {
console.log('pinia connection status debug:', status);
}
this.connectionStatus = status;
if (status === 'disconnected') {
this.isDisconnected = true;
}
},
resetDisconnected() {
this.isDisconnected = false;
}
},
});