import { CAPABILITIES, MAX_MEDIA_TIME } from './shared/constants.js'; export function validateCanonicalMediaState(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; if (!Number.isSafeInteger(value.revision) || value.revision < 1) return null; if (value.playbackState !== 'playing' && value.playbackState !== 'paused') return null; if (typeof value.currentTime !== 'number' || !Number.isFinite(value.currentTime) || value.currentTime < 0 || value.currentTime > MAX_MEDIA_TIME) { return null; } const normalized = { revision: value.revision, playbackState: value.playbackState, currentTime: value.currentTime }; if (typeof value.updatedBy === 'string' && value.updatedBy) { normalized.updatedBy = value.updatedBy.substring(0, 16); } return normalized; } export function canonicalMediaStateFromRoomData(roomData) { const capabilities = Array.isArray(roomData?.capabilities) ? roomData.capabilities : []; if (!capabilities.includes(CAPABILITIES.MEDIA_STATE_V1)) { return { status: 'unsupported', mediaState: null }; } if (roomData.mediaState === null || roomData.mediaState === undefined) { return { status: 'empty', mediaState: null }; } const mediaState = validateCanonicalMediaState(roomData.mediaState); return mediaState ? { status: 'available', mediaState } : { status: 'invalid', mediaState: null }; } function normalizeRoomId(roomId) { return typeof roomId === 'string' && roomId ? roomId : null; } export function createCanonicalMediaStateTracker() { let roomId = null; let knownRevision = 0; let appliedRevision = 0; let pending = null; function adoptRoom(nextRoomId) { const normalizedRoomId = normalizeRoomId(nextRoomId); if (normalizedRoomId === roomId) return false; roomId = normalizedRoomId; knownRevision = 0; appliedRevision = 0; pending = null; return true; } return { adoptRoom, beginRecovery(nextRoomId) { adoptRoom(nextRoomId); appliedRevision = 0; pending = null; }, receive(nextRoomId, value) { adoptRoom(nextRoomId); const mediaState = validateCanonicalMediaState(value); if (!roomId || !mediaState) return { status: 'invalid' }; if (mediaState.revision < knownRevision) return { status: 'stale' }; if (mediaState.revision === knownRevision && (appliedRevision === mediaState.revision || pending?.mediaState.revision === mediaState.revision)) { return { status: 'duplicate' }; } knownRevision = Math.max(knownRevision, mediaState.revision); pending = { roomId, mediaState }; return { status: 'pending', mediaState }; }, getPending(nextRoomId = roomId) { return pending && pending.roomId === normalizeRoomId(nextRoomId) ? { roomId: pending.roomId, mediaState: { ...pending.mediaState } } : null; }, markHandled(nextRoomId, revision) { if (normalizeRoomId(nextRoomId) !== roomId || !Number.isSafeInteger(revision) || revision < 1) { return false; } knownRevision = Math.max(knownRevision, revision); appliedRevision = Math.max(appliedRevision, revision); if (pending?.mediaState.revision <= revision) pending = null; return true; }, clear() { adoptRoom(null); }, restore(value, currentRoomId) { adoptRoom(currentRoomId); if (!value || typeof value !== 'object' || value.roomId !== roomId || !roomId) return false; knownRevision = Number.isSafeInteger(value.knownRevision) && value.knownRevision >= 0 ? value.knownRevision : 0; appliedRevision = Number.isSafeInteger(value.appliedRevision) && value.appliedRevision >= 0 ? Math.min(value.appliedRevision, knownRevision) : 0; const restoredPending = validateCanonicalMediaState(value.pending?.mediaState); if (value.pending?.roomId === roomId && restoredPending && restoredPending.revision >= appliedRevision && restoredPending.revision >= knownRevision) { pending = { roomId, mediaState: restoredPending }; knownRevision = restoredPending.revision; } return true; }, snapshot() { return { roomId, knownRevision, appliedRevision, pending: pending ? { roomId: pending.roomId, mediaState: { ...pending.mediaState } } : null }; } }; }