Skip to content

Adding a Custom Koi Audio Player to Your Article Body Chain

This guide shows how to add the Koi player as a custom feature component from scratch (not reusing a shared bundle’s component) and wire it into a custom article body chain. It walks through the four things the player needs at runtime, then covers integration and code-quality considerations.

The <koi-audio-player> / <koi-card-audio-player> custom elements need:

  1. The Koi player script loaded on the page (once)
  2. An Arc audio token (arc-token attribute)
  3. A media ID (media-id attribute) — typically pulled from the ANS audio element
  4. Org and env config (config attribute) — JSON-encoded

1. Load the Koi Player Script Once Per Page

The Koi player is shipped as an ES module at:

https://{org}-{env}.audio.arc-cdn.net/player/koi/v0.2.17/koi-player.min.js

Inject it once per page and de-duplicate by src so multiple player instances on the same article don’t double-load it:

const scriptUrlCache = new Set();
function ensureKoiPlayerScript(src) {
if (typeof document === "undefined" || !src) return;
if (scriptUrlCache.has(src)) return;
if (document.querySelector(`script[src="${src}"]`)) {
scriptUrlCache.add(src);
return;
}
const script = document.createElement("script");
script.type = "module";
script.src = src;
script.setAttribute("data-koi-player", "1");
document.head.appendChild(script);
scriptUrlCache.add(src);
}

Build the URL from AUDIO_ORG / AUDIO_ENV env values so org/env live in one place:

environment/sandbox.js
const AUDIO_ORG = "yourorg";
const AUDIO_ENV = "sandbox";
export default {
AUDIO_ORG,
AUDIO_ENV,
KOI_PLAYER_SCRIPT_URL: `https://${AUDIO_ORG}-${AUDIO_ENV}.audio.arc-cdn.net/player/koi/v0.2.17/koi-player.min.js`,
};

2. Provide an Arc Audio Token

The player calls audio APIs that require an Arc token. Without it the player loads but every request fails in the CDN environment. There are two supported ways to provide it.

Method A — Static ARC_AUDIO_TOKEN env var

Manually obtain a token (from the Developer Center or by calling the delivery API once) and add it to your bundle environment:

environment/sandbox.js
export default {
ARC_AUDIO_TOKEN: "arc-public-sandbox-...",
// ...other env values
};

Then read it inside your component:

import { ARC_AUDIO_TOKEN } from "fusion:environment";
// ...
<koi-audio-player arc-token={ARC_AUDIO_TOKEN} ... />

Pros

  • Simplest setup — one value in one env file.
  • No runtime API call to fetch the token, so no extra latency or failure mode at render time.
  • Works when the delivery API is unreachable from the bundle (locked-down networks, local dev with no Arc creds).
  • Token is visible in resolved env, easy to debug.

Cons

  • Manual rotation: when the token is rotated or expires you must update env config and redeploy.
  • Per-environment files (sandbox.js, prod.js, …) each need their own value.
  • Risk of stale tokens silently breaking playback between rotations.
  • Long-lived secret in version-controlled config — harder to audit and rotate.

Method B — delivery-keys content source

Write a small content source that calls Arc’s /delivery-api/v1/access/keys and selects the audio key for your org/env. The following is an example implementation. Before it will work, you’ll need to follow the directions above to generate a key and add it to an audio collection.

content/sources/delivery-keys.ts
import { ARC_ACCESS_TOKEN, CONTENT_BASE, ENVIRONMENT } from "fusion:environment";
const resolveEnv = () => {
const [orgId = "yourorg", env = "sandbox"] = (ENVIRONMENT || "").split("-");
return { orgId, env };
};
async function fetchFunction() {
const { orgId, env } = resolveEnv();
const collectionName = `arc-client-${orgId}-${env}-audio`;
const response = await fetch(`${CONTENT_BASE}/delivery-api/v1/access/keys`, {
method: "GET",
headers: {
"content-type": "application/json",
...(ARC_ACCESS_TOKEN && { Authorization: `Bearer ${ARC_ACCESS_TOKEN}` }),
},
});
const data = await response.json().catch(() => null);
if (!response.ok || !Array.isArray(data)) {
return { status: "error", errorCode: response.status, errorMessage: "delivery-keys fetch failed" };
}
const audioKey = data
.find((k) => k?.key_details?.collections?.some((c) => c?.collectionName === collectionName))
?.key_details?.keyValue;
return audioKey ? { audioKey } : { status: "error", errorCode: 404, errorMessage: "no audio key" };
}
export default { fetch: fetchFunction, params: {}, schemaName: "delivery-keys" };

Then consume it in your component:

import { useContent } from "fusion:content";
import { ARC_AUDIO_TOKEN } from "fusion:environment";
const { audioKey } = useContent({ source: "delivery-keys", query: {} }) || {};
const arcToken = audioKey || ARC_AUDIO_TOKEN; // fall back if the API is down

The content source must have these env values available:

VariablePurpose
ARC_ACCESS_TOKENBearer credential for the delivery API. Server-side only — never exposed to the browser.
CONTENT_BASEBase URL for the delivery API (e.g. https://api.{org}.arcpublishing.com).
ENVIRONMENTUsed to derive org/env so the source can find the arc-client-{org}-{env}-audio collection.

Pros

  • Token fetched at request time — rotating the underlying audio key takes effect without a bundle redeploy.
  • One source of truth across environments — org/env derived from ENVIRONMENT, not duplicated per env file.
  • Centralized secret: only ARC_ACCESS_TOKEN (the delivery-api credential) lives in env config. The audio token is never committed.
  • Cached via Fusion’s content-source caching, so the lookup cost is amortized across requests.

Cons

  • Adds a runtime dependency: if the delivery API is slow or unavailable, the source errors. Keep ARC_AUDIO_TOKEN set as a fallback if you want graceful degradation.
  • Requires ARC_ACCESS_TOKEN provisioned with delivery-api access in every environment.
  • More moving parts to debug (network, content-source cache TTL, collection naming).
  • Naming dependency: assumes a key in arc-client-{org}-{env}-audio. Orgs with a different convention need to adjust the lookup.

3. Resolve the Media ID from the ANS Element

The article body chain hands each content_elements entry to a renderer. For an audio element, the playable ID is _id (sometimes additional_properties.audio_id):

case "audio": {
const mediaId = item?._id || item?.additional_properties?.audio_id;
return mediaId
? <YourKoiPlayer key={`audio_${index}_${item._id}`} mediaId={mediaId} />
: null;
}

Render nothing when the ID is missing — don’t render an empty player.

4. Your Custom Feature Component

Putting it together, the minimum viable component:

components/features/your-koi-player/default.jsx
import React, { useEffect, useMemo } from "react";
import PropTypes from "@arc-fusion/prop-types";
import { useContent } from "fusion:content";
import {
ARC_AUDIO_TOKEN,
AUDIO_ORG,
AUDIO_ENV,
KOI_PLAYER_SCRIPT_URL,
} from "fusion:environment";
const scriptUrlCache = new Set();
function ensureKoiPlayerScript(src) {
if (typeof document === "undefined" || !src) return;
if (scriptUrlCache.has(src)) return;
if (document.querySelector(`script[src="${src}"]`)) {
scriptUrlCache.add(src);
return;
}
const script = document.createElement("script");
script.type = "module";
script.src = src;
document.head.appendChild(script);
scriptUrlCache.add(src);
}
const YourKoiPlayer = ({ mediaId, orgId = AUDIO_ORG, env = AUDIO_ENV }) => {
const { audioKey } = useContent({ source: "delivery-keys", query: {} }) || {};
const arcToken = audioKey || ARC_AUDIO_TOKEN;
useEffect(() => ensureKoiPlayerScript(KOI_PLAYER_SCRIPT_URL), []);
const config = useMemo(() => JSON.stringify({ orgId, env }), [orgId, env]);
if (!mediaId || !arcToken) return null;
return <koi-audio-player config={config} media-id={mediaId} arc-token={arcToken} />;
};
YourKoiPlayer.propTypes = {
mediaId: PropTypes.string.isRequired,
orgId: PropTypes.string,
env: PropTypes.string,
};
export default YourKoiPlayer;

Then in your article body chain, call it from the case "audio" branch as shown in step 3.

Quality Considerations

A few things worth getting right up front so this doesn’t bite you in production.

Keep the player a feature, not chain-inlined

Define the player as its own feature component (as above) and have the chain dispatch to it. That way the player has its own propTypes, tests, and lifecycle, and you can drop it anywhere — PageBuilder block, article body chain, related-content rail — without copy-pasting custom-element JSX.

SSR safety

<koi-audio-player> is a custom element registered by client-side JS, so its first render on the server produces an unknown tag. That’s fine — the script upgrades it on the client. But all script-injection logic must be guarded with typeof document === "undefined" (as above) or kept inside a useEffect. Don’t reach for window or document at module scope.

Render nothing when invariants fail

If mediaId is missing or the token can’t be resolved, return null rather than rendering a broken player. Avoid throwing — chains are unforgiving and one bad element should not blow up the article.

One source of truth for org/env

Derive both KOI_PLAYER_SCRIPT_URL and the config payload from the same AUDIO_ORG / AUDIO_ENV env values. Avoid hardcoding the script URL alongside a separate org/env constant — they will drift.

De-duplicate the script tag

Multiple audio elements in one article (or PageBuilder page) must not load the script multiple times. The scriptUrlCache set plus the querySelector check handles both cases (same React tree and re-mounts across pages in a SPA).

Memoize JSON-encoded attributes

config={JSON.stringify({orgId, env})} re-creates the string on every render, which thrashes the custom element’s attribute observer. Wrap it in useMemo.

Don’t expose ARC_ACCESS_TOKEN to the client

Content sources run server-side in Fusion, so the bearer credential stays on the server and only the resolved audioKey ships to the browser. Never import ARC_ACCESS_TOKEN into a feature component or pass it through props — that token has broader scope than the audio token and should not appear in HTML or client JS bundles.

Test the chain switch with no audio element

The most common regression after changing the chain is breaking articles that don’t have audio. Add a chain test that renders an article whose content_elements contain no audio entries and assert no player is rendered.

Pin the Koi player version

The script URL above pins v0.2.17. Treat it like any other dependency — review changelogs before bumping, and consider environment-by-environment promotion (sandbox first, then prod).

References