1 Commits
Author SHA1 Message Date
Julian 816aff8902 better refreshing on client app to avoid showing stale data 2026-10-05 01:20:11 +02:00
3 changed files with 107 additions and 19 deletions

No files matched your search

+58 -13
View File
@@ -5,6 +5,10 @@ import type { AgentState } from "./types";
const STORAGE_KEY = "framemate.connection";
const DEFAULT_PORT = 7380;
const RETRY_MS = 3000;
/** A connect that hasn't opened by then is given up (a sleeping Frame never answers the SYN). */
const CONNECT_TIMEOUT_MS = 8000;
/** The agent pushes at least every ~10 s (power poll); silence beyond this means a dead socket. */
const SILENCE_MS = 25000;
export interface ConnectionSettings {
/** Hostname or IP, optionally with `:port`. */
@@ -28,8 +32,17 @@ class Agent {
state = $state<AgentState | null>(null);
status = $state<ConnectionStatus>("unconfigured");
/** When the last message arrived; tells live data from a snapshot left over from before. */
receivedAt = $state(0);
#socket: WebSocket | null = null;
#retry: ReturnType<typeof setTimeout> | undefined;
#watchdog: ReturnType<typeof setTimeout> | undefined;
/** True while `state` is being kept current by an open connection. */
get live() {
return this.status === "connected";
}
get configured() {
return this.settings.host.trim() !== "" && this.settings.token.trim() !== "";
@@ -67,24 +80,55 @@ class Agent {
if (this.status !== "offline" && this.status !== "unauthorized") this.status = "connecting";
const socket = new WebSocket(this.socketUrl("/api/ws"));
this.#socket = socket;
let opened = false;
const lost = () => this.#lost(socket, opened);
socket.onmessage = event => {
if (this.#socket !== socket) return;
this.state = JSON.parse(event.data);
this.status = "connected";
this.receivedAt = Date.now();
this.#arm(socket, SILENCE_MS, lost);
};
let opened = false;
socket.onopen = () => (opened = true);
socket.onclose = async () => {
if (this.#socket !== socket) return; // replaced by a newer connection
this.#socket = null;
// A rejected upgrade looks like any other failure to the WebSocket API; ask over HTTP.
const status = opened ? "offline" : await this.#probe();
if (this.#socket || this.#retry !== undefined) return; // reconnected meanwhile
this.status = status;
this.#retry = setTimeout(() => {
this.#retry = undefined;
this.connect();
}, RETRY_MS);
};
socket.onclose = lost;
this.#arm(socket, CONNECT_TIMEOUT_MS, lost);
}
/**
* Fresh snapshot after the app was in the background: Android freezes the WebView, so the old
* socket may be dead without ever having fired `onclose` and would keep showing old data.
* The current state stays visible (marked stale via `live`) until the new one arrives.
*/
refresh() {
if (!this.configured) return;
if (this.status === "connected") this.status = "connecting";
this.connect();
}
/** Give up on `socket` if nothing arrives within `ms`. */
#arm(socket: WebSocket, ms: number, lost: () => void) {
clearTimeout(this.#watchdog);
this.#watchdog = setTimeout(() => {
if (this.#socket !== socket) return;
// On a dead TCP connection onclose can take minutes; don't wait for it.
socket.onclose = null;
socket.close();
lost();
}, ms);
}
async #lost(socket: WebSocket, opened: boolean) {
if (this.#socket !== socket) return; // replaced by a newer connection
this.#socket = null;
clearTimeout(this.#watchdog);
// A rejected upgrade looks like any other failure to the WebSocket API; ask over HTTP.
const status = opened ? "offline" : await this.#probe();
if (this.#socket || this.#retry !== undefined) return; // reconnected meanwhile
this.status = status;
this.#retry = setTimeout(() => {
this.#retry = undefined;
this.connect();
}, RETRY_MS);
}
/** Wrong token (401) vs. unreachable; needs CORS on /api/state. */
@@ -99,6 +143,7 @@ class Agent {
}
#close() {
clearTimeout(this.#watchdog);
clearTimeout(this.#retry);
this.#retry = undefined;
const socket = this.#socket;
+34 -5
View File
@@ -20,17 +20,27 @@
];
const battery = $derived(agent.state?.steam.topics.battery);
// Old data stays visible while reconnecting, dimmed and with its age in the top bar.
const stale = $derived(!!agent.state && !agent.live);
const lastUpdate = $derived(
new Date(agent.receivedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
);
onMount(() => {
agent.connect();
if (updates.autoCheck) updates.check();
if (!agent.configured) goto("/settings");
// Mobile WebViews drop sockets in the background; reconnect when we come back.
// Coming back from the background: the socket may be dead without knowing it, so always
// fetch a fresh snapshot instead of trusting the old one.
const onVisible = () => {
if (document.visibilityState === "visible" && agent.status !== "connected") agent.connect();
if (document.visibilityState === "visible") agent.refresh();
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
window.addEventListener("online", onVisible);
return () => {
document.removeEventListener("visibilitychange", onVisible);
window.removeEventListener("online", onVisible);
};
});
</script>
@@ -40,18 +50,21 @@
<a href="/" class="title">FrameMate</a>
<span class="spacer"></span>
{#if battery}
<span class="battery">
<span class="battery" class:stale>
{Math.round(battery.level * 100)}%
<Battery level={battery.level} charging={battery.ac_state === 2} size={18} />
</span>
{/if}
{#if stale}
<span class="stale-note">{agent.status === "connecting" ? "Updating…" : lastUpdate}</span>
{/if}
<span class="status {agent.status}" title={agent.status}></span>
<a href="/help" class="help" class:active={true} aria-label="Help" >
<Icon name="help" size={20} />
</a>
</header>
<main>
<main class:stale>
{@render children()}
</main>
@@ -121,6 +134,22 @@
overflow-y: auto;
padding-bottom: 24px;
}
/* Delayed, so the usual quick refresh on resume doesn't flash. */
main.stale,
.battery.stale {
opacity: 0.45;
transition: opacity 0.2s 0.8s;
}
.stale-note {
color: var(--muted);
font-size: 13px;
animation: appear 0.2s 0.8s both;
}
@keyframes appear {
from {
opacity: 0;
}
}
.tabs {
display: flex;
background: var(--topbar);
+15 -1
View File
@@ -103,6 +103,20 @@
if (video.currentTime - start > 10) buffer.remove(start, video.currentTime - 5);
}
// In the background the socket dies or, worse, keeps the Frame capturing for nobody:
// stop it, and start fresh (new keyframe, no stale buffer) when the app comes back.
function onVisibilityChange() {
if (closed) return;
clearTimeout(retry);
if (socket) socket.onclose = null;
socket?.close();
socket = null;
if (document.visibilityState === "visible") {
status = "Connecting…";
connect();
}
}
onMount(connect);
onDestroy(() => {
if (fullscreen) applyFullscreen(false);
@@ -113,7 +127,7 @@
});
</script>
<svelte:document onfullscreenchange={onFullscreenChange} />
<svelte:document onfullscreenchange={onFullscreenChange} onvisibilitychange={onVisibilityChange} />
<div class="player" class:fullscreen>
<video bind:this={video} autoplay muted playsinline></video>