Files
gods-eye-view/mobile/ui/realtime.js
T
Claude 8b7fcb09df feat(mobile): rooms, live and upload capture, and the altitude-band identity
The Now feed mixes sensor, eye, track, number, witness and room cards
across Orbit, Sky, Ground and Inside, ordered so neighbours differ, with
composited thumbnails and live public-camera frames.

Every situation has a room: typed, place- and time-anchored posts,
evidence badges, bridging-rated notes and a cited Known / Disputed /
Unknown local synthesis, with cross-tab presence over BroadcastChannel.
Example posts are labelled as such.

Capture adds Live (10 s segments sealed into a hash chain, shown on the
globe as a view frustum) and Upload (EXIF placement, Unverified tier),
and an attach sheet that files any capture into a nearby situation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
2026-10-19 02:49:03 +00:00

183 lines
5.0 KiB
JavaScript

/**
* Cross-tab realtime over BroadcastChannel: presence, posts, reactions and
* live broadcasts. Two tabs of the app on one device see each other live,
* which is the whole multiplayer loop, minus a server.
*/
import { emitter } from './dom.js';
const CHANNEL = 'gev-mobile-live-v1';
const ME_KEY = 'gev.mobile.me.v1';
const HEARTBEAT = 4000;
const EXPIRE = 12000;
function loadMe() {
try {
const v = JSON.parse(localStorage.getItem(ME_KEY) || 'null');
if (v?.handle) return v;
} catch {
/* ignore */
}
const id = Math.random().toString(36).slice(2, 6);
const me = { handle: `@you.${id}`, home: null, createdAt: Date.now() };
try {
localStorage.setItem(ME_KEY, JSON.stringify(me));
} catch {
/* ignore */
}
return me;
}
export function createRealtime() {
const ev = emitter();
const tab = Math.random().toString(36).slice(2, 8);
const me = loadMe();
const peers = new Map(); // tab → { handle, room, at, watching }
const lives = new Map(); // live id → { live, frame, at, viewers:Map }
let room = null;
let watching = null; // live id this tab is watching
let ch = null;
try {
ch = new BroadcastChannel(CHANNEL);
} catch {
ch = null;
}
const send = (msg) => {
try {
ch?.postMessage({ ...msg, tab, handle: me.handle, at: Date.now() });
} catch {
/* ignore (e.g. un-cloneable) */
}
};
function hello() {
send({ t: 'here', room, watching, visible: !document.hidden });
}
function prune() {
const now = Date.now();
let changed = false;
for (const [k, p] of peers)
if (now - p.at > EXPIRE) {
peers.delete(k);
changed = true;
}
for (const [id, l] of lives)
if (now - l.at > EXPIRE) {
lives.delete(id);
ev.emit('live-end', { id });
changed = true;
} else
for (const [v, at] of l.viewers)
if (now - at > EXPIRE) l.viewers.delete(v);
if (changed) ev.emit('presence', presence(room));
}
ch?.addEventListener('message', (e) => {
const m = e.data;
if (!m || m.tab === tab) return;
if (m.t === 'here' || m.t === 'hello') {
const prev = peers.get(m.tab);
peers.set(m.tab, {
handle: m.handle,
room: m.room,
at: Date.now(),
watching: m.watching,
});
if (m.watching)
for (const l of lives.values())
if (l.live.id === m.watching) l.viewers.set(m.tab, Date.now());
if (!prev || prev.room !== m.room) ev.emit('presence', presence(room));
if (m.t === 'hello') hello();
} else if (m.t === 'bye') {
peers.delete(m.tab);
ev.emit('presence', presence(room));
} else if (m.t === 'post') ev.emit('post', m.post);
else if (m.t === 'react')
ev.emit('react', { room: m.room, kind: m.kind, remote: true });
else if (m.t === 'rate') ev.emit('rate', m);
else if (m.t === 'live') {
const cur = lives.get(m.live.id);
lives.set(m.live.id, {
live: m.live,
frame: m.frame || cur?.frame || null,
at: Date.now(),
viewers: cur?.viewers || new Map(),
});
ev.emit('live', { ...m.live, frame: m.frame || cur?.frame || null });
} else if (m.t === 'live-end') {
lives.delete(m.id);
ev.emit('live-end', { id: m.id });
} else if (m.t === 'watch') {
ev.emit('watch', { live: m.live, tab: m.tab });
}
});
send({ t: 'hello', room, watching });
setInterval(() => {
hello();
prune();
}, HEARTBEAT);
addEventListener('pagehide', () => send({ t: 'bye' }));
document.addEventListener('visibilitychange', hello);
function presence(r) {
const list = [{ tab, handle: me.handle, self: true }];
for (const [k, p] of peers)
if (!r || p.room === r)
list.push({ tab: k, handle: p.handle, self: false });
return list;
}
return {
tab,
me,
on: ev.on,
off: ev.off,
setHome(home) {
me.home = home;
try {
localStorage.setItem(ME_KEY, JSON.stringify(me));
} catch {
/* ignore */
}
},
join(r) {
room = r;
hello();
ev.emit('presence', presence(room));
},
leave() {
room = null;
hello();
},
get room() {
return room;
},
presence,
/** Tabs on this device currently anywhere in the app. */
tabs: () => peers.size + 1,
post: (post) => send({ t: 'post', post }),
react: (r, kind) => send({ t: 'react', room: r, kind }),
rate: (payload) => send({ t: 'rate', ...payload }),
/* live broadcasts */
liveTick: (live, frame) => send({ t: 'live', live, frame }),
liveEnd: (id) => send({ t: 'live-end', id }),
watch(id) {
watching = id;
hello();
},
lives: () =>
[...lives.values()].map((l) => ({ ...l.live, frame: l.frame })),
/** Viewers of my own broadcast (other tabs that report watching it). */
viewersOf(id) {
let n = 0;
const now = Date.now();
for (const p of peers.values())
if (p.watching === id && now - p.at < EXPIRE) n += 1;
return n;
},
};
}