Files
illusory-iotam/apps/frontend/src/routes/session/+page.svelte
2026-03-28 20:35:26 +02:00

162 lines
5.2 KiB
Svelte

<script lang="ts">
import { goto } from "$app/navigation";
import {
checkLinkValidityFresh,
mapLinkErrorToSessionEndReason,
} from "$lib/domains/link/client";
import { endLinkSessionSC } from "$lib/domains/link/link.remote";
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 hasEnded = false;
let timer: ReturnType<typeof setInterval> | null = null;
let inFlight = false;
let consecutivePollErrors = 0;
const endSession = async (reason: string) => {
if (disposed || hasEnded) return;
hasEnded = true;
if (timer) clearInterval(timer);
streamUrl = null;
if (token) {
try {
await endLinkSessionSC({ token });
} catch {
// Best-effort cleanup; user ejection should proceed regardless.
}
}
await goto(`/session-ended?reason=${encodeURIComponent(reason)}`, {
replaceState: true,
});
};
const pollValidity = async () => {
if (disposed || inFlight || !token) return;
inFlight = true;
try {
const result = await checkLinkValidityFresh(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 checkLinkValidityFresh(token);
if (initial?.error || !initial?.data) {
await endSession(mapLinkErrorToSessionEndReason(initial?.error));
return;
}
streamUrl = initial.data.device?.streamUrl ?? null;
state = "ready";
statusText = "Session active";
await pollValidity();
timer = setInterval(() => {
void pollValidity();
}, POLL_INTERVAL_MS);
};
void start();
return () => {
disposed = true;
if (timer) clearInterval(timer);
if (!hasEnded && token) {
void endLinkSessionSC({ token });
}
};
});
</script>
<main class="bg-background text-foreground min-h-dvh">
{#if state === "loading"}
<section class="flex min-h-dvh 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="bg-black relative h-dvh min-h-dvh overflow-hidden">
<div
class="pointer-events-none absolute inset-x-0 top-0 z-10 flex justify-center px-3 pt-2"
>
<div class="rounded-full bg-black/55 px-3 py-1 text-xs text-white/90">
{statusText}
</div>
</div>
<div class="h-full w-full">
{#if streamUrl}
<iframe
src={streamUrl}
title="Android session"
class="block h-full w-full border-0 bg-black"
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>