Skip to content
Open
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,14 @@ Lists all of the Control Rooms and Macros for a system. Also fires a named `Star

Usage: `node macros.mjs`

## Showcase Events (Internet Channel)

See [`showcase-events/`](./showcase-events) for building a live-events feature —
"what's live now / coming up" listings and players — on your own site.

These read live and upcoming events from `GET /cablecastapi/publicsitedata`
(`showcaseEventShows`), which is read-only, needs no authentication, and is the
one showcase feed available on both self-hosted Cablecast and Reflect+ hosted
channels. Includes a browser HLS player, a WordPress shortcode, a no-code iframe
embed, and a shared status helper. See [`showcase-events/README.md`](./showcase-events/README.md).

122 changes: 122 additions & 0 deletions showcase-events/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Showcase Events (Internet Channel)

A **Showcase Event** promotes a live or upcoming stream so it can be featured
ahead of on-demand content. On a Cablecast Internet Channel they fill the "Live
Events" area of the home page. These examples let you build the same thing on
your own site.

## Read events from `publicsitedata`

Everything here is built on a single read-only, no-auth endpoint:

```
GET /cablecastapi/publicsitedata?site={siteId}
```

The response is the full configuration and content for one Internet Channel
site. The live and upcoming events live in the `showcaseEventShows` array, each
already resolved into a show with a title, thumbnail and playback URL:

```jsonc
{
"liveGalleryTitle": "Live Events",
"showcaseEventShows": [
{
"showId": 1187,
"title": "City Council Meeting",
"thumbnailUrl": "/cablecastapi/dynamicthumbnails/8821",
"showcaseEventStatus": "live",
"vodUrl": "https://vod.example.org/showcase-2/1187-showcase-event-42/event.m3u8",
"isLive": true,
"scheduleStartTime": "2026-08-19T18:00:00-04:00",
"liveEventStarted": "2026-08-19T18:00:11-04:00",
"liveBridgeEventStatus": "active"
}
]
}
```

This is the same feed the Cablecast Internet Channel itself renders, so a site
built on it behaves identically to a hosted channel — **including Reflect+**. On
a Reflect+ hosted channel the base path is `/api` instead of `/cablecastapi`
(`GET /api/publicsitedata`); the payload shape is the same.

> Prefer `publicsitedata` for any third-party integration. It is the one showcase
> feed available on both self-hosted Cablecast and Reflect+ hosted channels.

## Event status

Each event carries a server-derived **`showcaseEventStatus`** — read that field
rather than working the status out yourself. The server already applies the
"is it really live / has the encoder come up" rules, and both self-hosted
Cablecast and Reflect+ emit the same values, so you don't have to reimplement any
of it.

| `showcaseEventStatus` | Meaning | Play `vodUrl`? |
|-----------------------|---------|----------------|
| `live` | Reported streaming now. `vodUrl` is the live EVENT playlist. | Yes — but pre-check the manifest (see Playback notes) |
| `upcoming` | Scheduled, not streaming yet. Use `scheduleStartTime` for a countdown or a "starting soon" treatment. | No — keep polling |
| `vod` | The event is over. | Only if a recording/VOD exists |

`showcaseEventStatus` is an **open enum** — plan for values beyond the three above.
Reserved for future use:

| Value | Meaning (once emitted) | What to do |
|-------|------------------------|------------|
| `canceled` | The event was called off and will not air (cancelled before start, or ended without producing a recording). | Terminal, not-live, nothing to play — stop polling; hide the listing or show a "cancelled" note. |
| `error` | The event failed to stream (an encoder or platform error prevented it). | Terminal, not-live, no reliable playback — stop polling; hide the listing or show an "unavailable" note. |

Neither is emitted today. Treat **any** value you don't recognise defensively —
as **not-live** (don't mount a player) — so a new status can never break your
integration.

[`event-status.mjs`](./event-status.mjs) reads `showcaseEventStatus` and adds the
client-side `starting_soon` refinement (the near/far split, from
`scheduleStartTime`), returning `live | starting_soon | upcoming | vod`. The other
examples reuse it (and `wordpress.php` ports it to PHP).

## Files

| File | What it shows |
|------|---------------|
| [`config.mjs`](./config.mjs) | Shared API host/site config for the two browser pages — set your server here, once. |
| [`event-status.mjs`](./event-status.mjs) | The status helper the other examples import. |
| [`events-gallery.html`](./events-gallery.html) | The landing page: a scrolling row of showcase events (live / starting soon / upcoming) that links into the player. |
| [`browser-player.html`](./browser-player.html) | Opened as `?show=<showId>`: poll one event every 15s and mount an HLS player when it goes live (with a manifest pre-check). |
| [`wordpress.php`](./wordpress.php) | A `[cablecast_live_events]` WordPress shortcode listing what's live and upcoming. |
| [`iframe-embed.html`](./iframe-embed.html) | No-code option: iframe the Internet Channel show page and let it handle the whole lifecycle. |

The two browser pages work together: **`events-gallery.html`** is the entry
point, and clicking a card opens **`browser-player.html?show=<showId>`**. Set your
API host once in `config.mjs`, then serve the folder over http(s) — the pages use
ES-module imports and a cross-origin `fetch`, so `file://` won't work:

```sh
cd showcase-events
python3 -m http.server 8000 # or: npx serve .
# then open http://localhost:8000/events-gallery.html
```

## Playback notes

- `vodUrl` carries the live **EVENT** HLS playlist while an event is live, so
late joiners can scrub back to the start, and the same URL keeps serving the
recording for a while after the event ends. Treat post-event playback as best
effort and handle a failed load.
- **Pre-check the manifest before mounting a player.** An event can report `live`
a beat before its encoder actually publishes segments, and mounting a player
against a manifest that 404s gives a terminal error the viewer can only clear
by refreshing. Before handing `vodUrl` to your player, confirm the manifest is
serving something playable — and if it isn't yet, show a "starting soon" state
and re-check on your next poll. `browser-player.html` has a small copy-pasteable
`manifestReady()` doing exactly this (fetch the `.m3u8`, require a real segment,
follow a master to its first variant once). The hosted Cablecast player and the
`iframe-embed.html` option already do this for you; a player *you* mount does not.
- Turn on your player's live UI (in video.js, `liveui: true` with source type
`application/x-mpegURL`) so the scrubber and "back to live" control appear.
- Captions and translated subtitle tracks travel inside the manifest as subtitle
renditions; players pick them up automatically.
- Poll `publicsitedata` no faster than its 15-second cache. Stop polling once
you've mounted the player — the manifest keeps the stream current on its own.
- For a durable on-demand copy after the event, the station publishes a normal
VOD for the show, available through the usual `vods` endpoints.
212 changes: 212 additions & 0 deletions showcase-events/browser-player.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
<!doctype html>
<!--
Single-event live player built on `GET /cablecastapi/publicsitedata`.

Opened as `browser-player.html?show=<showId>` from events-gallery.html, it polls
that one showcase event and mounts an HLS player against its `vodUrl` (which
carries the live EVENT playlist while the event is live). Until the event is
live it shows a "starting soon" / "starts at…" state and keeps polling on the
same 15-second cadence the Cablecast Internet Channel uses. With no `?show=`
param it falls back to the first event that is currently live, so the file still
works on its own.

An event can report `live` a beat before its encoder actually publishes
segments, and mounting video.js against a manifest that 404s gives a terminal
"media could not be played" error the viewer can only clear by refreshing. So
before mounting we do a lightweight pre-check that the manifest is really
serving something playable, and treat a not-yet-ready manifest as "starting
soon" — the 15-second poll doubles as the retry. (The hosted Cablecast player
runs a more elaborate version of this same check.)

`publicsitedata` is read-only, needs no authentication, and sends open CORS
headers, so this works straight from the browser. Serve this file over http(s)
(not file://) so the ES-module imports and the cross-origin fetch both work.

Configure your API host once in config.mjs (shared with events-gallery.html).
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cablecast Live Event</title>
<!--
This example loads video.js from a public CDN to stay copy-paste simple. For
production, self-host these assets or add Subresource Integrity (integrity + crossorigin)
to the pinned version so a compromised CDN can't inject script.
-->
<link href="https://vjs.zencdn.net/8.10.0/video-js.css" rel="stylesheet" />
<script src="https://vjs.zencdn.net/8.10.0/video.min.js"></script>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; }
.back { display: inline-block; margin-bottom: 1rem; color: #4c8dff; text-decoration: none; }
#status { margin-bottom: 1rem; color: #444; }
.video-js { width: 100%; max-width: 800px; aspect-ratio: 16 / 9; height: auto; }
</style>
</head>
<body>
<a class="back" href="./events-gallery.html">← All events</a>
<p id="status">Loading event…</p>
<video id="player" class="video-js" controls playsinline poster=""></video>

<script type="module">
import { publicSiteDataUrl, POLL_MS, absolute } from "./config.mjs";
import { getEventStatus } from "./event-status.mjs";

// Which event to play: `?show=<showId>` from the gallery. With no param we
// fall back to the first live event, so this page still works standalone.
const wantedShowId = new URLSearchParams(location.search).get("show");

const statusEl = document.getElementById("status");
const playerEl = document.getElementById("player");
let player = null;

async function tick() {
let config;
try {
const res = await fetch(publicSiteDataUrl());
if (!res.ok) throw new Error(`HTTP ${res.status}`);
config = await res.json();
} catch (err) {
// Fail soft: leave any running player alone and try again shortly.
statusEl.textContent = "Could not reach the Cablecast API. Retrying…";
return setTimeout(tick, POLL_MS);
}

const shows = config.showcaseEventShows ?? [];
const target = wantedShowId
? shows.find((s) => String(s.showId) === wantedShowId)
: shows.find((s) => getEventStatus(s) === "live");

if (!target) {
statusEl.textContent = wantedShowId
? "That event is not listed right now."
: "No live events right now.";
return setTimeout(tick, POLL_MS);
}

document.title = target.title ?? "Cablecast Live Event";
// Show the thumbnail as a poster while we wait for playback.
playerEl.poster = absolute(target.thumbnailUrl);

const status = getEventStatus(target);

// Live now, or an ended event whose recording (vodUrl) is still serving:
// both play `vodUrl`, so try to mount once the manifest is really ready.
if (status === "live" || status === "vod") {
if (target.vodUrl && (await manifestReady(target.vodUrl))) {
mount(
target,
status === "live" ? `Live now: ${target.title}` : `Recording: ${target.title}`
);
// Stop polling once mounted: the HLS manifest keeps the stream current
// on its own, and re-reading a cached response can briefly flip the
// status and tear the player down.
return;
}
statusEl.textContent =
status === "live"
? `Starting soon: ${target.title}` // live, but manifest not serving yet
: `No recording available yet: ${target.title}`;
return setTimeout(tick, POLL_MS);
}

// Not streaming yet (upcoming / starting soon): show when it starts and
// keep polling until it goes live.
const start = new Date(target.scheduleStartTime).getTime();
statusEl.textContent = Number.isNaN(start)
? `Waiting for “${target.title}” to start…`
: `“${target.title}” starts ${new Date(start).toLocaleString()}`;
setTimeout(tick, POLL_MS);
}

function mount(show, statusText) {
statusEl.textContent = statusText;

// `vodUrl` carries the live EVENT playlist while the event is live, so
// late joiners can scrub back to the start. Captions and translated
// subtitle tracks ride inside the manifest — no separate fetch needed.
// liveui self-disables on a finalized (VOD) manifest, so it is safe to
// leave on for a recording too.
player = videojs("player", {
liveui: true,
sources: [{ src: show.vodUrl, type: "application/x-mpegURL" }],
});

// Safety net for a transient manifest gap right at the start: rather than
// leaving the viewer on video.js's terminal error, clear it and re-load
// the same source shortly. The pre-check makes this rare; this just keeps
// a momentary blip from becoming a dead end.
player.on("error", () => {
statusEl.textContent = `Reconnecting: ${show.title}…`;
setTimeout(() => {
if (!player) return;
player.error(null);
player.src({ src: show.vodUrl, type: "application/x-mpegURL" });
player.play().catch(() => {});
}, 3000);
});
}

// ---- lightweight manifest pre-check ---------------------------------
// Confirm an HLS manifest is serving something playable before mounting.
// A trimmed-down version of the check the hosted Cablecast player runs: it
// follows a master playlist to its first variant once, which covers the
// case where the master is published (200) while its variant is still 404.
// Any failure — a 404, an error page, a network/CORS blip — reads the same:
// "not serving yet", so the caller keeps polling.

async function manifestReady(url) {
// A progressive (non-HLS) source has no manifest to wait for.
if (!url || !/\.m3u8(?:[?#]|$)/i.test(url)) return !!url;
try {
const playlist = await getPlaylist(url);
if (!playlist) return false;
if (!playlist.isMaster) return playlist.hasSegments;
// Master served. A master can go up before its variants exist, so
// follow the first variant one level and require a real segment there.
if (!playlist.firstVariant) return true; // can't parse a variant: fail open
const variantUrl = new URL(playlist.firstVariant, url).toString();
const variant = await getPlaylist(variantUrl);
if (!variant) return false;
return variant.isMaster ? true : variant.hasSegments;
} catch {
// Offline, an opaque cross-origin failure, a parse error: no signal,
// so treat it as "not serving yet" and let the next poll retry.
return false;
}
}

async function getPlaylist(url) {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return null;
// Everything we classify from lives in the first few hundred bytes; a
// finalized VOD playlist can be large, so only look at the head.
const text = (await res.text())
.slice(0, 4096)
.replace(/^/, "") // strip a BOM some origins emit
.replace(/\r\n/g, "\n");
// Not an #EXTM3U body → an error page or app-shell HTML for a path that
// isn't published yet. That is the exact "not serving" signal.
if (!/^#EXTM3U/.test(text)) return null;
const lines = text.split("\n");
const masterIdx = lines.findIndex((l) =>
l.startsWith("#EXT-X-STREAM-INF:")
);
const isMaster = masterIdx !== -1;
// `#EXT-X-PART` covers LL-HLS, which can carry parts before the first
// full segment.
const hasSegments = /^(?:#EXTINF:|#EXT-X-PART:)/m.test(text);
// The variant URI is the first non-blank, non-comment line after the tag.
const firstVariant = isMaster
? lines
.slice(masterIdx + 1)
.map((l) => l.trim())
.find((l) => l !== "" && !l.startsWith("#")) ?? null
: null;
return { isMaster, hasSegments, firstVariant };
}

tick();
</script>
</body>
</html>
23 changes: 23 additions & 0 deletions showcase-events/config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Shared configuration for the showcase-events browser examples
// (events-gallery.html + browser-player.html).
//
// Keeping it in one module means the API host is set once, not copy-pasted into
// every page. Set API_BASE and SITE_ID for your system:
// - Self-hosted Cablecast: https://your-server.example.org/cablecastapi
// - Reflect+ hosted: your Reflect+ channel origin + /api (path is /api,
// not /cablecastapi; the payload shape is identical)

export const API_BASE = "https://cablecast.example.org/cablecastapi";
export const SITE_ID = 1;
export const POLL_MS = 15000; // matches the 15s publicsitedata cache

/** URL of the publicsitedata feed for the configured site. */
export const publicSiteDataUrl = () =>
`${API_BASE}/publicsitedata?site=${SITE_ID}`;

// `thumbnailUrl` (and other asset paths) come back relative to the Cablecast
// host, so prefix them with the origin of API_BASE. An already-absolute URL is
// returned unchanged.
const apiOrigin = new URL(API_BASE).origin;
export const absolute = (path) =>
path ? (path.startsWith("http") ? path : apiOrigin + path) : "";
Loading