cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/portal-dist-prebuild
goq / portal / session-state.mjs
session-state.mjs 3.8K
1function emptySnapshotState() {2 return {3 mode: 'disconnected',4 nativeGeneration: 0,5 revision: 0,6 snapshot: null,7 eligible: false,8 focused: false,9 focusGeneration: null,10 locallyActive: false,11 };12}1314function normalizeSnapshot(snapshot) {15 if (!snapshot || !Number.isSafeInteger(snapshot.revision) || snapshot.revision <= 0) {16 throw new Error('session snapshot revision must be a positive safe integer');17 }18 if (!Array.isArray(snapshot.viewers) || snapshot.viewers.length < 1 || snapshot.viewers.length > 8) {19 throw new Error('session snapshot roster must contain 1..=8 viewers');20 }21 const self = snapshot.viewers.find((viewer) => viewer.you === true);22 if (!self || self.presence_id !== snapshot.self_presence_id) {23 throw new Error('session snapshot does not contain the self viewer');24 }25 const focus = snapshot.focus;26 const focused = focus?.state === 'held'27 && focus.holder === self.presence_id28 && focus.session_id === self.session_id29 && Number.isSafeInteger(focus.focus_generation)30 && focus.focus_generation > 0;31 return {32 snapshot,33 revision: snapshot.revision,34 eligible: self.input_capable === true,35 focused,36 focusGeneration: focused ? focus.focus_generation : null,37 };38}3940export function createSessionState({ onFocusLoss = () => {}, onChange = () => {} } = {}) {41 if (typeof onFocusLoss !== 'function' || typeof onChange !== 'function') {42 throw new TypeError('session state callbacks must be functions');43 }44 let state = emptySnapshotState();4546 function publish(next) {47 const lostFocus = state.focused && !next.focused;48 if (lostFocus) onFocusLoss({ previous: snapshot(), next: { ...next } });49 state = next;50 onChange(snapshot());51 return true;52 }5354 function acceptConnection({ nativeGeneration, controlProtocol, initialSnapshot, eligible }) {55 if (!Number.isSafeInteger(nativeGeneration) || nativeGeneration <= 0) {56 throw new Error('native generation must be a positive safe integer');57 }58 if (controlProtocol !== 'control-v2') {59 return publish({60 ...emptySnapshotState(),61 mode: 'legacy-v1',62 nativeGeneration,63 eligible: eligible === true,64 focused: eligible === true,65 });66 }67 const normalized = normalizeSnapshot(initialSnapshot);68 return publish({69 ...emptySnapshotState(),70 ...normalized,71 mode: 'control-v2',72 nativeGeneration,73 });74 }7576 function applyNativeSnapshot(payload) {77 if (state.mode !== 'control-v2') return false;78 if (payload?.native_generation !== state.nativeGeneration) return false;79 const normalized = normalizeSnapshot(payload.snapshot);80 if (normalized.revision <= state.revision) return false;81 return publish({82 ...state,83 ...normalized,84 locallyActive: normalized.focused ? state.locallyActive : false,85 });86 }8788 function setLocallyActive(active) {89 const next = active === true && state.focused;90 if (next === state.locallyActive) return false;91 state = { ...state, locallyActive: next };92 onChange(snapshot());93 return true;94 }9596 function reset() {97 if (state.focused) onFocusLoss({ previous: snapshot(), next: emptySnapshotState() });98 state = emptySnapshotState();99 onChange(snapshot());100 }101102 function snapshot() {103 return { ...state, snapshot: state.snapshot };104 }105106 return Object.freeze({107 acceptConnection,108 applyNativeSnapshot,109 reset,110 setLocallyActive,111 snapshot,112 get mode() { return state.mode; },113 get eligible() { return state.eligible; },114 get focused() { return state.focused; },115 get focusGeneration() { return state.focusGeneration; },116 get locallyActive() { return state.locallyActive; },117 get revision() { return state.revision; },118 get nativeGeneration() { return state.nativeGeneration; },119 });120}