Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ COPY packages/notify/package.json packages/notify/
COPY packages/playlists/package.json packages/playlists/
COPY packages/payments/package.json packages/payments/
COPY packages/queue/package.json packages/queue/
COPY packages/radio/package.json packages/radio/
COPY packages/sports/package.json packages/sports/
RUN bun install --frozen-lockfile || bun install

Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ packages/queue BullMQ queues, schedules and the fan-out workers.
packages/notify Web push (VAPID) and email (Resend).
packages/auth Magic link, passkeys, sessions.
packages/payments CoinPay checkout, webhook verification, entitlements.
packages/playlists A reader's own M3U line: import, probe, proxy, share.
packages/radio A reader's own SiriusXM: email+code sign-in, lineups, HLS proxy.
```

## How reminders scale
Expand Down Expand Up @@ -95,3 +97,32 @@ proxy forwarding to a closed socket while the container reports healthy.

Secrets belong on the service and in the logicsrc vault, not in a committed
`.env`.

## Radio (SiriusXM)

A reader connects their own SiriusXM subscription in settings — the email on the
account and the code SiriusXM sends to it, the way the SiriusXM app signs in —
and the sports and news lineups play on `/radio` and on a fixture's page. The
session is sealed with the playlist key and every byte is fetched by the server
as that reader; the browser never sees a SiriusXM address or a bearer. The player
is `@profullstack/player` with its audio bar, bundled to `vendor-player.js` and
fetched on the first press of Play.

Knobs, all read at request time:

- `SIRIUSXM` — `0` turns the rail off. Defaults to on for the tipoffwatch brand
and off for any other `BRAND`.
- `SIRIUSXM_PROXY_URL` — the residential exit for every SiriusXM call. Falls back
to `SPORTS_PROXY_URL`. Not optional in production: SiriusXM answers a
datacenter address with 403 and pins a session to the IP that authenticated it.
- `SIRIUSXM_PROXIES` — a pool of single-IP proxies (`host:port:user:pass` lines
or full URLs, comma or newline separated). Each reader is hashed to one and
keeps it, so login, refresh and playback all leave through the same address.
Set this when a rotating endpoint starts breaking streams mid-segment.
- `SIRIUSXM_DEVICE_GRANT` — a `DEVICE_GRANT` cookie value pasted from a browser
session, for the rare case SiriusXM refuses to start a sign-in without one.
The sign-in is tried without it first.

The pending-code state between "send code" and "verify" lives in the web
process's memory for ten minutes, which is right for one web replica and would
need Redis for more.
49 changes: 49 additions & 0 deletions apps/web/build-client.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { dirname } from 'node:path';

/**
* Bundles the browser helpers into public/ as globals.
*
Expand All @@ -12,19 +14,66 @@
const BUNDLES = [
['webauthn-entry.js', 'vendor-webauthn.js'],
['player-entry.js', 'vendor-mpegts.js'],
// The house player with its control bar, for radio. A third bundle rather than
// a second entry in the second: a reader pressing Play on a channel row must
// not download hls.js, and one pressing Play on a station must not download
// the transport stream demuxer.
['radio-entry.js', 'vendor-player.js'],
];

/*
* The player's stylesheet ships beside its script, copied out of the package so
* a version bump is one place. Fetched by app.js with the bundle, never linked
* by the Layout: it styles a bar that most pages never draw.
*/
const PLAYER_CSS = [
new URL('../../node_modules/@profullstack/player/dist/player.css', import.meta.url),
new URL('./node_modules/@profullstack/player/dist/player.css', import.meta.url),
];

/*
* What the radio bundle leaves out.
*
* The player loads its engines on demand, and a bundler with no code splitting
* answers a dynamic import by inlining it -- so the first build of this bundle
* carried hls.js AND the transport stream demuxer, 900KB for a page that only
* ever plays HLS. mpegts.js is marked external (the import stays a bare
* specifier that is never reached for an HLS source), and hls.js is swapped for
* its light build, which drops subtitles, DRM and alternate audio tracks. A
* radio station has none of those.
*/
const hlsLight = {
name: 'hls-light',
setup(build) {
// Resolved from the importer, not from here: hls.js is the player's own
// dependency and Bun's isolated linker does not hoist it to this package.
build.onResolve({ filter: /^hls\.js$/ }, (args) => ({
path: Bun.resolveSync('hls.js/dist/hls.light.mjs', dirname(args.importer)),
}));
},
};
const RADIO_ONLY = { external: ['mpegts.js'], plugins: [hlsLight] };

for (const [entry, name] of BUNDLES) {
const out = await Bun.build({
entrypoints: [new URL(`./src/client/${entry}`, import.meta.url).pathname],
outdir: new URL('./public', import.meta.url).pathname,
naming: name,
minify: true,
target: 'browser',
...(entry === 'radio-entry.js' ? RADIO_ONLY : {}),
});
if (!out.success) {
for (const l of out.logs) console.error(l);
process.exit(1);
}
console.log(`[build] ${name}`);
}

for (const candidate of PLAYER_CSS) {
const file = Bun.file(candidate.pathname);
if (!(await file.exists())) continue;
await Bun.write(new URL('./public/vendor-player.css', import.meta.url).pathname, file);
console.log('[build] vendor-player.css');
break;
}
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"build:client": "bun build-client.js"
},
"dependencies": {
"@profullstack/player": "0.3.1",
"@profullstack/player": "0.6.0",
"@simplewebauthn/browser": "^13.2.0",
"@tipoff/auth": "workspace:*",
"@tipoff/config": "workspace:*",
Expand All @@ -18,6 +18,7 @@
"@tipoff/payments": "workspace:*",
"@tipoff/playlists": "workspace:*",
"@tipoff/queue": "workspace:*",
"@tipoff/radio": "workspace:*",
"@tipoff/sports": "workspace:*",
"hono": "^4.10.3",
"mpegts.js": "1.8.2"
Expand Down
231 changes: 231 additions & 0 deletions apps/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,7 @@ function initNavigation() {
initMarketTabs();
initOwnChannelActions();
initInlinePlayer();
initRadio();
initPush();
initPasskeys();
initPlaylistReveal();
Expand Down Expand Up @@ -758,6 +759,7 @@ reportTimezone();
initMarketTabs();
initOwnChannelActions();
initInlinePlayer();
initRadio();
initPush();
initPasskeys();
// Before initFollowForms: it must be able to cancel a submit that handler would
Expand Down Expand Up @@ -1486,3 +1488,232 @@ function initPlayerSection(section) {
// iOS and on a back/forward navigation.
window.addEventListener('pagehide', teardown);
}

/* ------------------------------------------------------------------ radio -- */

/**
* The SiriusXM sections: the lineup on /radio and the lookup on an event page.
*
* Same shape as initPlayerSection, and the same rules, because the reasons are
* the same. One station at a time -- a SiriusXM account is one subscription and
* the second stream is what gets it flagged -- so the teardown chains into
* `__tipoffStopPlayer` with the video players, and a client-side navigation or a
* press on a TV channel stops the radio too. The bundle is fetched on the first
* press, never on load.
*
* The station plays through the house player's own bar (it has volume, mute
* and a LIVE badge of its own), so the button on the row only ever says Play
* here or Stop.
*/
const RADIO_QUALITY_KEY = 'tw.radio.quality';

function radioQuality() {
try {
const v = localStorage.getItem(RADIO_QUALITY_KEY);
return ['256', '128', '64', '32'].includes(v) ? v : '256';
} catch {
return '256';
}
}

function loadRadioBundle(section) {
if (window.__tipoffRadio) return Promise.resolve(window.__tipoffRadio);
if (window.__tipoffRadioLoading) return window.__tipoffRadioLoading;
// The stylesheet first, and not awaited: a bar drawn a frame before its CSS
// arrives is a bar, and a bar that never gets its CSS because the link
// failed is still a bar with buttons on it.
const css = section.dataset.radioCss;
if (css && !document.querySelector(`link[href="${css}"]`)) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = css;
document.head.append(link);
}
window.__tipoffRadioLoading = new Promise((resolve, reject) => {
const el = document.createElement('script');
el.src = section.dataset.radioSrc;
el.onload = () =>
window.__tipoffRadio ? resolve(window.__tipoffRadio) : reject(new Error('no player'));
el.onerror = () => {
window.__tipoffRadioLoading = null;
reject(new Error('could not load the player'));
};
document.head.append(el);
});
return window.__tipoffRadioLoading;
}

/** Can this browser push HLS audio into Media Source? Asked without the bundle, for the same reason canTransmux is. */
function canPlayRadio() {
try {
return (
typeof MediaSource !== 'undefined' &&
MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"')
);
} catch {
return false;
}
}

function initRadio(root = document) {
for (const section of root.querySelectorAll('[data-radio-src]')) initRadioSection(section);
for (const select of root.querySelectorAll('select[data-radio-quality]')) {
select.value = radioQuality();
select.addEventListener('change', () => {
try {
localStorage.setItem(RADIO_QUALITY_KEY, select.value);
} catch {
// A browser that refuses storage still gets the stream it asked for,
// just not next time.
}
});
}
}

function initRadioSection(section) {
if (!section || section.dataset.radio) return;
section.dataset.radio = '1';

let stop = null;
let stage = null;
let generation = 0;
let playing = null;

const teardown = () => {
if (stop) stop();
stop = null;
stage?.remove();
stage = null;
if (playing) {
playing.dataset.playing = '';
playing.textContent = 'Play here';
playing = null;
}
};
const previousStop = window.__tipoffStopPlayer;
window.__tipoffStopPlayer = () => {
previousStop?.();
teardown();
};
window.addEventListener('pagehide', teardown);

const message = (text, error) => {
section.querySelector('.player-error')?.remove();
if (!text) return;
const p = document.createElement('p');
p.className = error ? 'feedback error player-error' : 'feedback player-error';
p.textContent = text;
section.prepend(p);
};

const fail = (text) => {
teardown();
message(text, true);
};

const wire = (scope) => {
const buttons = [...scope.querySelectorAll('button[data-radio-play]')];
if (!canPlayRadio()) {
// Nothing to offer instead: SiriusXM streams only play with the bearer,
// and there is no app link that could carry it.
for (const b of buttons) b.closest('li')?.remove();
if (buttons.length) {
message(
'This browser cannot play SiriusXM streams here. Chrome, Firefox, Edge or an Android or TV browser can.',
true,
);
}
return;
}
for (const button of buttons) {
if (button.dataset.wired) continue;
button.dataset.wired = '1';
button.disabled = false;
button.addEventListener('click', async () => {
message(null);
if (button.dataset.playing) {
generation += 1;
teardown();
return;
}
generation += 1;
const mine = generation;
// Whatever else is playing -- a station in this section, a TV channel in
// another -- goes first. One stream per reader is the rule everywhere.
window.__tipoffStopPlayer?.();

button.disabled = true;
button.textContent = 'Starting…';
let player;
try {
player = await loadRadioBundle(section);
} catch {
button.disabled = false;
button.textContent = 'Play here';
fail('The player could not be loaded. Reload the page and try again.');
return;
}
if (mine !== generation) {
button.disabled = false;
button.textContent = 'Play here';
return;
}
button.disabled = false;
if (!player.supported()) {
fail('This browser cannot play SiriusXM streams here.');
return;
}

const separator = button.dataset.radioPlay.includes('?') ? '&' : '?';
const src = `${button.dataset.radioPlay}${separator}quality=${radioQuality()}`;
stage = document.createElement('div');
stage.className = 'player-stage audio';
button.closest('li')?.after(stage);
playing = button;
button.dataset.playing = '1';
button.textContent = 'Stop';
stop = player.play(stage, src, {
title: button.dataset.title,
artwork: button.dataset.artwork || undefined,
onError: fail,
onNotice: (text) => message(text, false),
onStop: () => {
generation += 1;
teardown();
},
});
});
}
};

wire(section);

/*
* The event page asks only when told to. Searching SiriusXM is an upstream
* call on the reader's own session, and most visits to a fixture do not want
* radio for it. The answer arrives as HTML rendered by the same component as
* the lineup page, so there is one row template and it is on the server.
*/
const find = section.querySelector('button[data-radio-find-button]');
const results = section.querySelector('[data-radio-results]');
if (find && results && section.dataset.radioFind) {
find.addEventListener('click', async () => {
find.disabled = true;
find.textContent = 'Looking…';
try {
const res = await fetch(section.dataset.radioFind, {
headers: { accept: 'text/html', 'x-requested-with': 'fetch' },
});
const html = await res.text();
if (!res.ok) throw new Error(html.slice(0, 200) || String(res.status));
results.innerHTML = html;
wire(results);
find.closest('p')?.remove();
} catch (err) {
find.disabled = false;
find.textContent = 'Find this game on SiriusXM';
message(err?.message || 'SiriusXM did not answer. Try again in a moment.', true);
}
});
}
}
Loading