316 lines
9.8 KiB
Vue
316 lines
9.8 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
Video,
|
|
SwitchCamera,
|
|
MonitorUp,
|
|
ArrowUpRight,
|
|
Copy,
|
|
Check,
|
|
ShieldCheck,
|
|
UsersRound,
|
|
Radio,
|
|
Square,
|
|
Maximize2,
|
|
Wifi,
|
|
RefreshCw,
|
|
} from "lucide-vue-next";
|
|
import QRCode from "qrcode";
|
|
const { t } = useI18n();
|
|
useHead({ title: computed(() => `${t("nav.host")} · Webka`) });
|
|
const {
|
|
media,
|
|
state,
|
|
error,
|
|
source,
|
|
rearAvailable,
|
|
screenAvailable,
|
|
quality,
|
|
capture,
|
|
start,
|
|
stop,
|
|
setQuality,
|
|
} = useStream("host");
|
|
interface HostStatus {
|
|
live: boolean;
|
|
since: number | null;
|
|
viewer_count: number;
|
|
viewers: { id: string; username: string; session_id: string }[];
|
|
local_url: string;
|
|
}
|
|
const status = ref<HostStatus | null>(null);
|
|
const qr = ref(""),
|
|
copied = ref(false),
|
|
busy = ref(false),
|
|
networkError = ref(""),
|
|
tick = ref(Date.now()),
|
|
preview = ref<HTMLElement>();
|
|
const apiError = useApiError();
|
|
let timer: ReturnType<typeof setInterval>;
|
|
const live = computed(() => state.value === "live");
|
|
const elapsed = computed(() => {
|
|
const seconds = status.value?.since
|
|
? Math.max(0, Math.floor(tick.value / 1000) - status.value.since)
|
|
: 0;
|
|
return `${Math.floor(seconds / 3600)
|
|
.toString()
|
|
.padStart(2, "0")}:${Math.floor((seconds % 3600) / 60)
|
|
.toString()
|
|
.padStart(2, "0")}:${(seconds % 60).toString().padStart(2, "0")}`;
|
|
});
|
|
async function refresh() {
|
|
try {
|
|
status.value = await $fetch<HostStatus>("/api/status");
|
|
networkError.value = "";
|
|
if (status.value?.local_url)
|
|
qr.value = await QRCode.toDataURL(status.value.local_url, {
|
|
margin: 1,
|
|
width: 176,
|
|
color: { dark: "#111418", light: "#ffffff" },
|
|
});
|
|
} catch (e) {
|
|
networkError.value = apiError(e);
|
|
}
|
|
}
|
|
onMounted(() => {
|
|
void refresh();
|
|
timer = setInterval(() => {
|
|
tick.value = Date.now();
|
|
void refresh();
|
|
}, 3000);
|
|
});
|
|
onBeforeUnmount(() => clearInterval(timer));
|
|
async function choose(value: "front" | "back" | "screen") {
|
|
busy.value = true;
|
|
await capture(value);
|
|
busy.value = false;
|
|
}
|
|
async function toggle() {
|
|
busy.value = true;
|
|
if (state.value !== "offline") stop();
|
|
else await start();
|
|
busy.value = false;
|
|
await refresh();
|
|
}
|
|
async function copy() {
|
|
try {
|
|
await navigator.clipboard.writeText(status.value?.local_url ?? "");
|
|
copied.value = true;
|
|
setTimeout(() => (copied.value = false), 1800);
|
|
} catch {
|
|
networkError.value = t("errors.clipboard");
|
|
}
|
|
}
|
|
async function kick(sid: string) {
|
|
try {
|
|
await $fetch(`/api/sessions/${sid}`, { method: "DELETE" });
|
|
await refresh();
|
|
} catch (e) {
|
|
networkError.value = apiError(e);
|
|
}
|
|
}
|
|
async function fullscreen() {
|
|
try {
|
|
await preview.value?.requestFullscreen();
|
|
} catch {
|
|
networkError.value = t("errors.fullscreen");
|
|
}
|
|
}
|
|
</script>
|
|
<template>
|
|
<div class="page host-page">
|
|
<div class="page-heading">
|
|
<div>
|
|
<div class="eyebrow">{{ $t("host.eyebrow") }}</div>
|
|
<h1>{{ $t("host.title") }}<span class="heading-dot">.</span></h1>
|
|
<p class="muted">{{ $t("host.subtitle") }}</p>
|
|
</div>
|
|
<GlassButton
|
|
:variant="live ? 'danger' : 'primary'"
|
|
:disabled="busy"
|
|
@click="toggle"
|
|
><Square v-if="live" :size="16" fill="currentColor" /><Radio
|
|
v-else
|
|
:size="18"
|
|
/>{{
|
|
$t(state !== "offline" ? "host.stop" : "host.start")
|
|
}}</GlassButton
|
|
>
|
|
</div>
|
|
<p v-if="error || networkError" role="alert" class="error">
|
|
{{ error || networkError }}
|
|
</p>
|
|
<div class="host-grid">
|
|
<section class="feed-section">
|
|
<div ref="preview" class="preview">
|
|
<StreamVideo
|
|
v-if="media"
|
|
:stream="media"
|
|
:mirror="source === 'front'"
|
|
/>
|
|
<div v-else class="preview-empty">
|
|
<div class="camera-orbit">
|
|
<Video :size="38" :stroke-width="1.3" />
|
|
</div>
|
|
<h2>{{ $t("host.previewTitle") }}</h2>
|
|
<p>{{ $t("host.previewHint") }}</p>
|
|
<GlassButton :disabled="busy" @click="choose(source)"
|
|
><Video :size="17" />{{ $t("host.enableCamera") }}</GlassButton
|
|
>
|
|
</div>
|
|
<div class="preview-top">
|
|
<span class="glass status-pill" role="status"
|
|
><span class="status-dot" :class="{ live }" />{{
|
|
$t(`stream.${state}`)
|
|
}}</span
|
|
><span class="glass private-pill"
|
|
><ShieldCheck :size="14" />{{ $t("host.private") }}</span
|
|
>
|
|
</div>
|
|
<div v-if="media" class="preview-bottom">
|
|
<span class="glass status-pill">{{ $t(`sources.${source}`) }}</span
|
|
><GlassButton
|
|
class="icon-button"
|
|
:aria-label="$t('watch.fullscreen')"
|
|
@click="fullscreen"
|
|
><Maximize2 :size="18"
|
|
/></GlassButton>
|
|
</div>
|
|
</div>
|
|
<div class="feed-caption">
|
|
<span
|
|
><span class="status-dot" :class="{ live }" />{{
|
|
$t(live ? "host.broadcasting" : "host.previewOnly")
|
|
}}</span
|
|
><span>{{ $t("host.audioOff") }}</span>
|
|
</div>
|
|
<GlassCard class="source-toolbar"
|
|
><div class="toolbar-label">
|
|
<span class="eyebrow">{{ $t("host.videoSource") }}</span
|
|
><small>{{ $t("host.switchAnytime") }}</small>
|
|
</div>
|
|
<div class="source-options">
|
|
<button
|
|
:class="{ selected: source === 'front' }"
|
|
:disabled="busy"
|
|
@click="choose('front')"
|
|
>
|
|
<Video :size="20" /><span>{{ $t("sources.front") }}</span></button
|
|
><button
|
|
:class="{ selected: source === 'back' }"
|
|
:disabled="busy || !rearAvailable"
|
|
:title="!rearAvailable ? $t('host.rearHint') : undefined"
|
|
@click="choose('back')"
|
|
>
|
|
<SwitchCamera :size="20" /><span>{{
|
|
$t("sources.back")
|
|
}}</span></button
|
|
><button
|
|
:class="{ selected: source === 'screen' }"
|
|
:disabled="busy || !screenAvailable"
|
|
@click="choose('screen')"
|
|
>
|
|
<MonitorUp :size="20" /><span>{{ $t("sources.screen") }}</span>
|
|
</button>
|
|
</div></GlassCard
|
|
>
|
|
<div class="stats-row">
|
|
<div>
|
|
<span class="muted">{{ $t("host.uptime") }}</span
|
|
><strong class="mono">{{ elapsed }}</strong>
|
|
</div>
|
|
<div>
|
|
<span class="muted">{{ $t("host.viewers") }}</span
|
|
><strong
|
|
>{{ status?.viewer_count ?? 0 }}<small> / 31</small></strong
|
|
>
|
|
</div>
|
|
<div>
|
|
<label class="muted" for="quality">{{ $t("host.quality") }}</label
|
|
><select
|
|
id="quality"
|
|
:value="quality"
|
|
@change="setQuality(($event.target as HTMLSelectElement).value)"
|
|
>
|
|
<option value="auto">{{ $t("quality.auto") }}</option>
|
|
<option value="high">{{ $t("quality.high") }}</option>
|
|
<option value="low">{{ $t("quality.low") }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<aside class="host-aside">
|
|
<GlassCard class="share-panel"
|
|
><div class="section-heading">
|
|
<h2>{{ $t("host.connectDevice") }}</h2>
|
|
<ArrowUpRight :size="20" />
|
|
</div>
|
|
<p class="muted">{{ $t("host.scanHint") }}</p>
|
|
<div class="qr-wrap">
|
|
<img
|
|
v-if="qr"
|
|
:src="qr"
|
|
:alt="$t('host.qrAlt')"
|
|
width="154"
|
|
height="154"
|
|
/>
|
|
<div v-else class="qr-loading"><RefreshCw :size="24" /></div>
|
|
</div>
|
|
<div class="url-copy">
|
|
<input
|
|
:value="status?.local_url ?? ''"
|
|
readonly
|
|
:aria-label="$t('host.localAddress')"
|
|
/><GlassButton
|
|
class="icon-button"
|
|
:disabled="!status"
|
|
:aria-label="$t('host.copy')"
|
|
@click="copy"
|
|
><Check v-if="copied" :size="18" /><Copy v-else :size="18"
|
|
/></GlassButton>
|
|
</div>
|
|
<span class="local-note"
|
|
><Wifi :size="14" />{{
|
|
$t(copied ? "host.copied" : "host.sameNetwork")
|
|
}}</span
|
|
></GlassCard
|
|
>
|
|
<section class="viewers-panel">
|
|
<div class="section-heading">
|
|
<h2>
|
|
{{ $t("host.connected")
|
|
}}<span class="count-badge">{{ status?.viewer_count ?? 0 }}</span>
|
|
</h2>
|
|
<UsersRound :size="19" />
|
|
</div>
|
|
<div v-if="!status?.viewers.length" class="empty-viewers">
|
|
<UsersRound :size="28" :stroke-width="1.3" />
|
|
<p>{{ $t("host.noViewers") }}</p>
|
|
<small>{{ $t("host.inviteHint") }}</small>
|
|
</div>
|
|
<ul v-else class="viewer-list">
|
|
<li v-for="viewer in status.viewers" :key="viewer.id">
|
|
<span class="avatar">{{
|
|
viewer.username.slice(0, 1).toUpperCase()
|
|
}}</span
|
|
><span
|
|
>{{ viewer.username
|
|
}}<small>{{ $t("stream.live") }}</small></span
|
|
><GlassButton
|
|
class="small-button"
|
|
@click="kick(viewer.session_id)"
|
|
>{{ $t("host.kick") }}</GlassButton
|
|
>
|
|
</li>
|
|
</ul>
|
|
<NuxtLink class="text-link" to="/settings/accounts"
|
|
>{{ $t("host.manageAccess") }}<ArrowUpRight :size="16"
|
|
/></NuxtLink>
|
|
</section>
|
|
</aside>
|
|
</div>
|
|
<footer class="page-footer">
|
|
<ShieldCheck :size="16" />{{ $t("host.footer") }}<span>WEBKA / LAN</span>
|
|
</footer>
|
|
</div>
|
|
</template>
|