// ═══════════════════════════════════════════════════════════════════════════════ // GLOBAL RADIO STATE SINGLETON // Shared between MiniRadioPlayer and the full Radio page to ensure // playback continuity when navigating between pages. // ═══════════════════════════════════════════════════════════════════════════════ import type Hls from "hls.js"; export interface QualityLevel { index: number; bitrate: number; name: string; isLossless?: boolean; } export interface GlobalRadioState { audio: HTMLAudioElement | null; video: HTMLVideoElement | null; hls: Hls | null; isPlaying: boolean; station: string; qualityLevel: number; qualityLevels: QualityLevel[]; // Track metadata for seamless handoff trackTitle: string; trackArtist: string; trackAlbum: string; coverArt: string; trackUuid: string | null; } // Station type helpers export const VIDEO_STATIONS = ["videos"] as const; export type VideoStation = typeof VIDEO_STATIONS[number]; export function isVideoStation(station: string): station is VideoStation { return VIDEO_STATIONS.includes(station as VideoStation); } export function getStreamUrl(station: string): string { if (isVideoStation(station)) { return `https://stream.codey.horse/hls/videos/videos.m3u8`; } return `https://stream.codey.horse/hls/${station}/${station}.m3u8`; } const GLOBAL_KEY = "__seriousFmRadio"; const RADIO_SHARED_STATE_KEY = "__seriousFmRadioSharedState"; const RADIO_SYNC_PULSE_KEY = "__seriousFmRadioSyncPulse"; const RADIO_SYNC_CHANNEL = "seriousfm-radio-sync-v1"; const TAB_ID = typeof window === "undefined" ? "server" : `${Date.now()}-${Math.random().toString(36).slice(2)}`; type RadioSyncMessage = { type: "PLAYBACK" | "STATE"; tabId: string; at: number; payload: Record; }; let syncInitialized = false; let syncChannel: BroadcastChannel | null = null; const handledSyncMessageKeys: string[] = []; function buildSyncMessageKey(message: RadioSyncMessage): string { return `${message.tabId}|${message.type}|${message.at}|${JSON.stringify(message.payload || {})}`; } function markSyncMessageHandled(message: RadioSyncMessage): boolean { const key = buildSyncMessageKey(message); if (handledSyncMessageKeys.includes(key)) { return false; } handledSyncMessageKeys.push(key); if (handledSyncMessageKeys.length > 200) { handledSyncMessageKeys.shift(); } return true; } function readSharedSnapshot(): Partial | null { if (typeof window === "undefined") return null; try { const raw = localStorage.getItem(RADIO_SHARED_STATE_KEY); if (!raw) return null; return JSON.parse(raw); } catch { return null; } } function writeSharedSnapshot(state: GlobalRadioState): void { if (typeof window === "undefined") return; try { const existingOwner = readPlaybackOwnerTabId(); const snapshot = { station: state.station, qualityLevel: state.qualityLevel, qualityLevels: state.qualityLevels, isPlaying: state.isPlaying, playbackOwnerTabId: existingOwner, trackTitle: state.trackTitle, trackArtist: state.trackArtist, trackAlbum: state.trackAlbum, coverArt: state.coverArt, trackUuid: state.trackUuid, at: Date.now(), }; localStorage.setItem(RADIO_SHARED_STATE_KEY, JSON.stringify(snapshot)); } catch { // ignore } } function readPlaybackOwnerTabId(): string | null { if (typeof window === "undefined") return null; try { const raw = localStorage.getItem(RADIO_SHARED_STATE_KEY); if (!raw) return null; const parsed = JSON.parse(raw); return typeof parsed?.playbackOwnerTabId === "string" ? parsed.playbackOwnerTabId : null; } catch { return null; } } function writePlaybackOwnerTabId(ownerTabId: string | null): void { if (typeof window === "undefined") return; try { const raw = localStorage.getItem(RADIO_SHARED_STATE_KEY); const parsed = raw ? JSON.parse(raw) : {}; parsed.playbackOwnerTabId = ownerTabId; parsed.at = Date.now(); localStorage.setItem(RADIO_SHARED_STATE_KEY, JSON.stringify(parsed)); } catch { // ignore } } function postSyncMessage(message: RadioSyncMessage): void { if (typeof window === "undefined") return; try { if (syncChannel) { syncChannel.postMessage(message); } } catch { // ignore } // Fallback pulse for browsers/environments without BroadcastChannel support. try { localStorage.setItem(RADIO_SYNC_PULSE_KEY, JSON.stringify(message)); } catch { // ignore } } function applyRemoteState(payload: Record): void { const state = getGlobalRadioState(); const prevStation = state.station; const prevQuality = state.qualityLevel; const prevTrackUuid = state.trackUuid; if (typeof payload.station === "string") { state.station = payload.station; try { localStorage.setItem("radioStation", payload.station); } catch { // ignore } } if (typeof payload.qualityLevel === "number") { state.qualityLevel = payload.qualityLevel; try { localStorage.setItem("radioQuality", String(payload.qualityLevel)); } catch { // ignore } } if (Array.isArray(payload.qualityLevels)) state.qualityLevels = payload.qualityLevels; if (typeof payload.isPlaying === "boolean") { state.isPlaying = payload.isPlaying; emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: payload.isPlaying }); } if (typeof payload.trackTitle === "string") state.trackTitle = payload.trackTitle; if (typeof payload.trackArtist === "string") state.trackArtist = payload.trackArtist; if (typeof payload.trackAlbum === "string") state.trackAlbum = payload.trackAlbum; if (typeof payload.coverArt === "string") state.coverArt = payload.coverArt; if (typeof payload.trackUuid === "string" || payload.trackUuid === null) state.trackUuid = payload.trackUuid; if (state.station !== prevStation) { emitRadioEvent(RADIO_EVENTS.STATION_CHANGED, { station: state.station }); } if (state.qualityLevel !== prevQuality) { emitRadioEvent(RADIO_EVENTS.QUALITY_CHANGED, { quality: state.qualityLevel }); } if (state.trackUuid !== prevTrackUuid) { emitRadioEvent(RADIO_EVENTS.TRACK_CHANGED, { title: state.trackTitle, artist: state.trackArtist, album: state.trackAlbum, coverArt: state.coverArt, uuid: state.trackUuid, }); } } function handleSyncMessage(message: RadioSyncMessage): void { if (!message || message.tabId === TAB_ID) return; if (!markSyncMessageHandled(message)) return; if (message.type === "PLAYBACK") { const shouldBePlaying = !!message.payload?.isPlaying; const state = getGlobalRadioState(); const ownerTabId = typeof message.payload?.ownerTabId === "string" ? message.payload.ownerTabId : null; const mediaList = [state.audio, state.video].filter(Boolean) as Array; if (shouldBePlaying) { if (ownerTabId && ownerTabId === TAB_ID) { const media = getActiveMediaElement(); if (media) { try { media.playbackRate = 1; const playPromise = media.play(); if (playPromise && typeof (playPromise as Promise).catch === "function") { (playPromise as Promise).catch(() => { // ignore autoplay/user-gesture restrictions }); } } catch { // ignore } } state.isPlaying = true; writePlaybackOwnerTabId(ownerTabId); emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: true }); return; } for (const media of mediaList) { if (media.paused || media.ended) continue; try { media.pause(); } catch { // ignore } } // Reflect that radio is playing in another tab while keeping this tab paused. state.isPlaying = true; if (ownerTabId) { writePlaybackOwnerTabId(ownerTabId); } emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: true }); return; } // Global pause request: pause any local media in this tab as well. for (const media of mediaList) { if (media.paused || media.ended) continue; try { media.pause(); } catch { // ignore } } if (ownerTabId) { writePlaybackOwnerTabId(ownerTabId); } state.isPlaying = false; emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: false }); return; } if (message.type === "STATE") { applyRemoteState(message.payload || {}); } } function ensureCrossTabSync(): void { if (typeof window === "undefined" || syncInitialized) return; syncInitialized = true; if (typeof BroadcastChannel !== "undefined") { try { syncChannel = new BroadcastChannel(RADIO_SYNC_CHANNEL); syncChannel.onmessage = (event: MessageEvent) => { handleSyncMessage(event.data); }; } catch { syncChannel = null; } } window.addEventListener("storage", (event) => { if (!event.key) return; if (event.key === RADIO_SYNC_PULSE_KEY && event.newValue) { try { handleSyncMessage(JSON.parse(event.newValue)); } catch { // ignore } return; } if (event.key === RADIO_SHARED_STATE_KEY && event.newValue) { try { const snapshot = JSON.parse(event.newValue); // Live playback state is coordinated via PLAYBACK messages; do not override // it opportunistically from snapshot writes that can come from passive tabs. const { isPlaying: _ignored, playbackOwnerTabId: _ignoredOwner, ...rest } = snapshot || {}; applyRemoteState(rest); } catch { // ignore } } }); } function syncStateAcrossTabs(partial: Record): void { if (typeof window === "undefined") return; postSyncMessage({ type: "STATE", tabId: TAB_ID, at: Date.now(), payload: partial, }); } function syncPlaybackAcrossTabs(isPlaying: boolean, ownerTabIdOverride?: string | null): void { if (typeof window === "undefined") return; const ownerTabId = ownerTabIdOverride !== undefined ? ownerTabIdOverride : (isPlaying ? TAB_ID : null); postSyncMessage({ type: "PLAYBACK", tabId: TAB_ID, at: Date.now(), payload: { isPlaying, ownerTabId }, }); } export function getGlobalRadioState(): GlobalRadioState { if (typeof window === "undefined") { return { audio: null, video: null, hls: null, isPlaying: false, station: "main", qualityLevel: -1, qualityLevels: [], trackTitle: "", trackArtist: "", trackAlbum: "", coverArt: "/images/radio_art_default.jpg", trackUuid: null, }; } const w = window as any; if (!w[GLOBAL_KEY]) { const snapshot = readSharedSnapshot(); w[GLOBAL_KEY] = { audio: null, video: null, hls: null, isPlaying: typeof snapshot?.isPlaying === "boolean" ? snapshot.isPlaying : false, station: snapshot?.station || localStorage.getItem("radioStation") || "main", qualityLevel: typeof snapshot?.qualityLevel === "number" ? snapshot.qualityLevel : parseInt(localStorage.getItem("radioQuality") || "-1", 10), qualityLevels: Array.isArray(snapshot?.qualityLevels) ? snapshot.qualityLevels : [], trackTitle: snapshot?.trackTitle || "", trackArtist: snapshot?.trackArtist || "", trackAlbum: snapshot?.trackAlbum || "", coverArt: snapshot?.coverArt || "/images/radio_art_default.jpg", trackUuid: snapshot?.trackUuid ?? null, }; } ensureCrossTabSync(); return w[GLOBAL_KEY]; } export function getOrCreateAudio(): HTMLAudioElement { const state = getGlobalRadioState(); if (!state.audio) { state.audio = document.createElement("audio"); state.audio.preload = "none"; state.audio.crossOrigin = "anonymous"; state.audio.setAttribute("playsinline", "true"); state.audio.setAttribute("webkit-playsinline", "true"); } return state.audio; } export function getOrCreateVideo(): HTMLVideoElement { const state = getGlobalRadioState(); if (!state.video) { state.video = document.createElement("video"); state.video.preload = "none"; state.video.crossOrigin = "anonymous"; state.video.setAttribute("playsinline", "true"); state.video.setAttribute("webkit-playsinline", "true"); state.video.muted = false; } return state.video; } /** Returns the current media element (audio or video) based on station */ export function getActiveMediaElement(): HTMLAudioElement | HTMLVideoElement | null { const state = getGlobalRadioState(); if (isVideoStation(state.station)) { return state.video; } return state.audio; } export function updateGlobalStation(station: string): void { const state = getGlobalRadioState(); state.station = station; // Reset metadata on station switch to avoid stale info bleeding across stations. state.trackTitle = ""; state.trackArtist = ""; state.trackAlbum = ""; state.coverArt = "/images/radio_art_default.jpg"; state.trackUuid = null; localStorage.setItem("radioStation", station); writeSharedSnapshot(state); syncStateAcrossTabs({ station, trackTitle: "", trackArtist: "", trackAlbum: "", coverArt: "/images/radio_art_default.jpg", trackUuid: null, }); } export function updateGlobalQuality(qualityLevel: number): void { const state = getGlobalRadioState(); state.qualityLevel = qualityLevel; localStorage.setItem("radioQuality", qualityLevel.toString()); writeSharedSnapshot(state); syncStateAcrossTabs({ qualityLevel }); } export function updateGlobalPlayingState(isPlaying: boolean): void { const state = getGlobalRadioState(); // Prevent passive tabs from stomping shared playing state owned by another tab. if (!isPlaying) { const currentOwner = readPlaybackOwnerTabId(); if (currentOwner && currentOwner !== TAB_ID) { return; } } state.isPlaying = isPlaying; writeSharedSnapshot(state); writePlaybackOwnerTabId(isPlaying ? TAB_ID : null); syncPlaybackAcrossTabs(isPlaying); } /** * Force pause radio across all tabs/windows. * Useful when a passive tab (not current owner) requests pause. */ export function requestGlobalPauseAll(): void { const state = getGlobalRadioState(); const previousOwner = readPlaybackOwnerTabId(); state.isPlaying = false; writeSharedSnapshot(state); // Preserve previous owner so resume can target the same tab/media element. writePlaybackOwnerTabId(previousOwner); syncPlaybackAcrossTabs(false, previousOwner); emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: false }); } /** * If another tab owns playback, request that owner tab to resume. * Returns true when a remote-owner resume request was sent. */ export function requestOwnerTabResume(): boolean { const ownerTabId = readPlaybackOwnerTabId(); if (!ownerTabId || ownerTabId === TAB_ID) return false; const state = getGlobalRadioState(); state.isPlaying = true; writeSharedSnapshot(state); writePlaybackOwnerTabId(ownerTabId); syncPlaybackAcrossTabs(true, ownerTabId); emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: true }); return true; } export function updateGlobalTrackInfo(info: { title?: string; artist?: string; album?: string; coverArt?: string; uuid?: string | null; }): void { const state = getGlobalRadioState(); if (info.title !== undefined) state.trackTitle = info.title; if (info.artist !== undefined) state.trackArtist = info.artist; if (info.album !== undefined) state.trackAlbum = info.album; if (info.coverArt !== undefined) state.coverArt = info.coverArt; if (info.uuid !== undefined) state.trackUuid = info.uuid; writeSharedSnapshot(state); syncStateAcrossTabs({ trackTitle: state.trackTitle, trackArtist: state.trackArtist, trackAlbum: state.trackAlbum, coverArt: state.coverArt, trackUuid: state.trackUuid, }); } export function destroyGlobalHls(): void { const state = getGlobalRadioState(); if (state.hls) { state.hls.destroy(); state.hls = null; } } export function setGlobalHls(hls: Hls | null): void { const state = getGlobalRadioState(); state.hls = hls; } export function setGlobalQualityLevels(levels: QualityLevel[]): void { const state = getGlobalRadioState(); state.qualityLevels = levels; writeSharedSnapshot(state); syncStateAcrossTabs({ qualityLevels: levels }); } // Event system for cross-component communication type RadioEventCallback = (data: any) => void; const listeners: Map> = new Map(); export function onRadioEvent(event: string, callback: RadioEventCallback): () => void { if (!listeners.has(event)) { listeners.set(event, new Set()); } listeners.get(event)!.add(callback); // Return unsubscribe function return () => { listeners.get(event)?.delete(callback); }; } export function emitRadioEvent(event: string, data?: any): void { listeners.get(event)?.forEach(callback => { try { callback(data); } catch (e) { console.error("Radio event listener error:", e); } }); } // Event names export const RADIO_EVENTS = { STATION_CHANGED: "station_changed", QUALITY_CHANGED: "quality_changed", PLAYBACK_CHANGED: "playback_changed", TRACK_CHANGED: "track_changed", PLAYER_TAKEOVER: "player_takeover", // Full player taking over from mini } as const;