mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
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
183 lines
5.0 KiB
JavaScript
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;
|
|
},
|
|
};
|
|
}
|