Implement frontend session routing flow

- Validate and prepare access links in apps/frontend
- Add session, ended, and unauthorized routes with polling
- Copy full public access URLs from the admin links page
This commit is contained in:
user
2026-03-28 19:10:24 +02:00
parent 31a501f75b
commit 92deee1b2e
16 changed files with 392 additions and 119 deletions

View File

@@ -0,0 +1,141 @@
<script lang="ts">
import { goto } from "$app/navigation";
import {
mapLinkErrorToSessionEndReason,
resolveLinkFresh,
} from "$lib/domains/link/client";
import { onMount } from "svelte";
const POLL_INTERVAL_MS = 2000;
const MAX_CONSECUTIVE_POLL_ERRORS = 3;
let state: "loading" | "ready" = "loading";
let token = "";
let streamUrl: string | null = null;
let statusText = "Connecting to your active session...";
onMount(() => {
let disposed = false;
let timer: ReturnType<typeof setInterval> | null = null;
let inFlight = false;
let consecutivePollErrors = 0;
const endSession = async (reason: string) => {
if (disposed) return;
if (timer) clearInterval(timer);
streamUrl = null;
await goto(`/session-ended?reason=${encodeURIComponent(reason)}`, {
replaceState: true,
});
};
const pollValidity = async () => {
if (disposed || inFlight || !token) return;
inFlight = true;
try {
const result = await resolveLinkFresh(token);
if (disposed) return;
if (result?.error || !result?.data) {
await endSession(
mapLinkErrorToSessionEndReason(result?.error),
);
return;
}
consecutivePollErrors = 0;
if (result.data.link.status !== "active") {
await endSession("expired");
return;
}
const nextStreamUrl = result.data.device.streamUrl;
if (typeof nextStreamUrl === "string" && nextStreamUrl.length > 0) {
streamUrl = nextStreamUrl;
}
} catch {
consecutivePollErrors += 1;
if (consecutivePollErrors >= MAX_CONSECUTIVE_POLL_ERRORS) {
await endSession("network");
}
} finally {
inFlight = false;
}
};
const start = async () => {
token =
new URLSearchParams(window.location.search)
.get("token")
?.trim() || "";
if (!token) {
await goto("/unauthorized?reason=missing_token", {
replaceState: true,
});
return;
}
const initial = await resolveLinkFresh(token);
if (initial?.error || !initial?.data) {
await endSession(mapLinkErrorToSessionEndReason(initial?.error));
return;
}
streamUrl = initial.data.device.streamUrl;
state = "ready";
statusText = "Session active";
await pollValidity();
timer = setInterval(() => {
void pollValidity();
}, POLL_INTERVAL_MS);
};
void start();
return () => {
disposed = true;
if (timer) clearInterval(timer);
};
});
</script>
<main class="bg-background text-foreground min-h-screen">
{#if state === "loading"}
<section class="flex min-h-screen items-center justify-center px-6">
<div
class="w-full max-w-sm rounded-xl border border-border bg-card p-6 text-center"
>
<h1 class="text-xl font-semibold">Loading session</h1>
<p class="mt-3 text-sm text-muted-foreground">
{statusText}
</p>
</div>
</section>
{:else}
<section class="flex min-h-screen flex-col">
<div class="flex items-center justify-center border-b border-border px-3 py-2">
<p class="text-xs text-muted-foreground">{statusText}</p>
</div>
<div class="bg-black flex-1">
{#if streamUrl}
<iframe
src={streamUrl}
title="Android session"
class="h-full w-full border-0"
allow="autoplay; fullscreen"
referrerpolicy="no-referrer"
></iframe>
{:else}
<div class="flex h-full items-center justify-center">
<p class="text-sm text-white/70">Waiting for stream...</p>
</div>
{/if}
</div>
</section>
{/if}
</main>