- Implemented a one-shot live catch-up mechanism in `liveCatchup.ts` to enhance user experience during live streaming. - Created a global radio state management system in `radioState.ts` to maintain playback continuity and metadata across different components and tabs. - Bumped version 1.0 -> 1.1
578 lines
18 KiB
TypeScript
578 lines
18 KiB
TypeScript
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// 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<string, any>;
|
|
};
|
|
|
|
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<GlobalRadioState> | 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<string, any>): 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<HTMLMediaElement>;
|
|
|
|
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<void>).catch === "function") {
|
|
(playPromise as Promise<void>).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<RadioSyncMessage>) => {
|
|
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<string, any>): 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<string, Set<RadioEventCallback>> = 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;
|