npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@coumtech/sdk

v0.4.1

Published

Official Synxed web SDK — licensed in-game radio and music over a live /sdk session

Readme

Synxed Web SDK

Official web SDK for licensed in-game radio and music. Coum Technologies Ltd (Synxed) maintains this package. Studios ship it in web games.

One live session over WebSocket wss://api.synxed.com/sdk streams the catalog into the game. You draw the UI. AdTonos programmatic ads, voice, station wheel, and discovery chrome are opt-in — not required to play music. First-party Synxed audio ads are scheduled by the server for your API key (see Ads).

Synxed handles the music licensing required to make the catalog available through your integration.

How it works

  1. Get an API key from your Synxed developer account.
  2. Mount the player, or call playPlaylist / playRadio (song, mix, and station work the same way).
  3. The SDK opens the /sdk session and plays licensed audio in the game.
  4. You draw controls and layout. Built-in chrome (SynxedWebPlayer), AdTonos ads, and voice stay off until you opt in. First-party Synxed ads follow your API key's ad frequency.

Default serverUrl is https://api.synxed.com. Playback uses WebSocket wss://api.synxed.com/sdk (protobuf session). That is the live contract — not REST /v1 and not Socket.IO. Every init carries protocolVersion 0.3.0; the backend treats older or missing versions as legacy clients (a content switch mid-ad ends the ad), and a 0.2.0-or-newer client instead waits for the ad to finish (see Ads). 0.3.0 is also what tells the server this client can take a break of two ads.

Install

npm i @coumtech/sdk

Previously published as unscoped synxed-sdk. @coumtech/sdk is the company package.

Versions and what each one needs

The package version and the wire protocol version are different numbers. The protocol version is what the SDK announces to the backend; the package version is what you install.

| SDK | Protocol | Needs backend | Notes | | --- | --- | --- | --- | | 0.4.1+ | 0.3.0 | main >= 3f77d93; two-ad breaks need synxed-backend development #102+ | An ad break can be two ads; breakStart/breakEnd bracket the whole break | | 0.4.0 | 0.2.0 | main >= 3f77d93 (deployed 22 Sep 2026) | Ads recover from failure; an ad break holds play*()/switch*() until it ends | | 0.3.x | 0.1.0 | any | An ad that fails to load stops playback for good |

If you run your own Synxed backend, deploy it before upgrading the SDK. On an older backend a 0.4.x client reports a failed ad in a way that backend does not understand, and it has no watchdog to end the break, so the listener hears silence instead of the next track. Against api.synxed.com this is already handled.

0.4.1 needs no newer backend than 0.4.0 did to play ads. Ad pods travel in fields the wire protocol did not have before, and a backend that does not send them is read as one ad per break, with the same terminal analytics payload 0.4.0 sent. What is new is that 0.4.1 announces protocolVersion 0.3.0 instead of 0.2.0, which is how the server decides a client can take a break of two ads — it serves a pod only at 0.3.0 and above. Any backend at 3f77d93+ already accepts 0.3.0 as a current client, so nothing has to be upgraded in step; pods simply start appearing once the backend serves them. See Ad breaks come as pods.

Four things do change on an unchanged backend, so read them before you upgrade. 0.4.1 is published as a patch, so ^0.4.0 and ~0.4.0 install it without asking; pin 0.4.0 exactly if you need time to adapt:

  • AdInfo and AdTonosSlotInfo gain required breakId, podPosition and podSize fields. Any fixture or mock you build by hand stops type-checking until you add them.
  • breakStart and breakEnd now fire around each ad, each ad being a break of one. Additive — duck against these instead of adStart/adEnd.
  • The built-in player stays in ad mode from adEnd / adTonosSlotEnd until the break closes (from breakStart for an item it rejects on arrival or declines, which fires neither): a round trip normally, and if the server goes quiet, the break's own deadline — durationSeconds + 30 s, where an ad that declared no duration counts as 15 s and an AdTonos slot that declared none 30 s; an ad rejected on arrival (no usable https: playbackUrl) counts as its declared duration, or 60 s if it declared none, and a malformed AdTonos slot (no valid slotId) counts as 60 s. During it a track skip is refused and auto-minimize is held.
  • The same creative served again in a later break now plays, where 0.4.0 could answer it with the previous break's end event and skip it. That is a fix, but it means an ad that was silently dropped is now played and counted.

Changes worth reading before an upgrade ship in the package as CHANGELOG.md: open node_modules/@coumtech/sdk/CHANGELOG.md, or run npm view @coumtech/sdk for the published versions.

Quick start

Load the key from your bundler's environment (process.env, import.meta.env, or NEXT_PUBLIC_SYNXED_API_KEY). Never commit a live synx_live_… key.

Headless — you own the UI:

import { SynxedPlayer } from "@coumtech/sdk";

const player = new SynxedPlayer({
  apiKey: process.env.SYNXED_API_KEY!,
  autoConnect: true,
});

await player.playPlaylist({
  playlistCode: "sxpl_YOUR_CODE",
});

Or mount the optional built-in player:

import "@coumtech/sdk/player.css";
import { SynxedWebPlayer } from "@coumtech/sdk";

SynxedWebPlayer.mount({
  apiKey: process.env.SYNXED_API_KEY!,
  source: { type: "radio" },
});

SynxedWebPlayer needs @coumtech/sdk/player.css once in the app entry file. Headless SynxedPlayer does not. The SDK does not inject a <style> tag at runtime.

Requirements

You need:

  • A Synxed API key from your developer account: https://portal.synxed.com
  • One of these content sources:
    • a song ID
    • a playlist code
    • a mix ID
    • live radio
    • a station ID

Choose an Integration

Use SynxedWebPlayer if you want the fastest setup with a ready-made player.

Use SynxedPlayer if you want to build your own controls, layouts, queue UI, station browser, or game-specific audio UI.

| Need | Use | | ------------------------------------ | ----------------- | | Floating player bar or mini player | SynxedWebPlayer | | Custom buttons and layout | SynxedPlayer | | Play one song | either | | Play a playlist | either | | Play live radio | either | | Play a mix | SynxedPlayer | | Play a scheduled station | SynxedPlayer | | Fetch stations, schedules, and mixes | discovery helpers | | Voice commands | either |

Built-In Web Player

The built-in player works in React, Vue, Angular, plain HTML, and most browser-based game/app shells.

import "@coumtech/sdk/player.css";
import { SynxedWebPlayer } from "@coumtech/sdk";

const player = SynxedWebPlayer.mount({
  apiKey: "YOUR_SYNXED_API_KEY",
  source: { type: "playlist", playlistCode: "sxpl_YOUR_CODE" },
});

Call destroy() when your page, route, or component unmounts:

player.destroy();

Play One Song

Use the song ID copied from the Synxed developer portal.

SynxedWebPlayer.mount({
  apiKey: "YOUR_SYNXED_API_KEY",
  source: { type: "song", songId: "SYNX_EX_SONG_ID" },
});

Play a Playlist

SynxedWebPlayer.mount({
  apiKey: "YOUR_SYNXED_API_KEY",
  source: { type: "playlist", playlistCode: "sxpl_YOUR_CODE" },
});

Play Live Radio

SynxedWebPlayer.mount({
  apiKey: "YOUR_SYNXED_API_KEY",
  source: { type: "radio" },
});

Live radio always stays live. It does not resume from an old timestamp after refresh.

Mount Inside Your Own Container

<div id="synxed-player"></div>
SynxedWebPlayer.mount({
  container: document.getElementById("synxed-player")!,
  apiKey: "YOUR_SYNXED_API_KEY",
  source: { type: "playlist", playlistCode: "sxpl_YOUR_CODE" },
});

When container is omitted, the player creates its own floating root and appends it to document.body.

Use a Custom Backend URL

The SDK uses https://api.synxed.com by default and connects to WebSocket /sdk on that host. Override serverUrl only for local development, staging, or self-hosted backend environments.

A self-hosted backend must be at main >= 3f77d93 (deployed 22 Sep 2026). This SDK reports a failed ad in a way earlier backends do not understand, and those backends have no watchdog to end the break, so the listener hears silence instead of the next track. api.synxed.com already runs a new enough build.

This SDK speaks wire protocol 0.3.0, which any backend at 3f77d93+ accepts as a current client — so beyond that baseline there is no further upgrade order to observe. Two-ad breaks additionally need synxed-backend development #102 or later; until then every break is a single ad.

SynxedWebPlayer.mount({
  apiKey: "YOUR_SYNXED_API_KEY",
  serverUrl: "https://your-synxed-backend.example.com",
  source: { type: "playlist", playlistCode: "sxpl_YOUR_CODE" },
});

Web Player Appearance

Modes

SynxedWebPlayer.mount({
  apiKey: "...",
  serverUrl: "https://api.synxed.com",
  source: { type: "playlist", playlistCode: "sxpl_..." },
  mode: "wide",
});

| Mode | Behavior | | ------- | --------------------------------------------------- | | wide | Compact horizontal bar. This is the default. | | mini | Circular DJ avatar button with a title pill. | | large | Larger card with visualizer and transport controls. |

Mount once. The widget changes chrome in place (setMode) so playback does not restart.

With enableChromeCycle: true and enableStationWheel: true:

  1. Click the mini avatar → wide bar
  2. Click the hamburger on the bar → large card
  3. Click the hamburger on the card → back to mini
  4. Double-click the avatar (any size) → station wheel

enableExpand is discovery only (toggleExpand / "Open in"). It does not cycle chrome.

onMiniClick still overrides the mini tap (for example to play/pause instead of expanding). When the player collapsed itself (see below), a tap first restores it and then calls onMiniClick, so a handler cannot leave the player stuck small.

Auto-minimize

New in 0.4.0, and on by default. A player upgraded from 0.3.x collapses on its own where it previously did not; autoMinimizeDelayMs: 0 restores the old behaviour.

The player gets out of the way on its own. After autoMinimizeDelayMs (default 7000) with no interaction, it collapses to a small idle icon (autoMinimizeMode: "idle", the default) or to the mini avatar ("mini"). Hovering it, keeping discovery open, or holding voice pauses the countdown; a tap restores the size it had.

SynxedWebPlayer.mount({
  // ...
  autoMinimizeDelayMs: 7000, // 0 turns auto-minimize off
  autoMinimizeMode: "idle",  // or "mini"
});

Only a collapse the player made itself is undone. A player mounted at mode: "idle" stays that size, because it is already the collapsed mode; one mounted at mode: "mini" still collapses to the idle icon, since the default autoMinimizeMode is "idle". Set autoMinimizeMode: "mini" to keep the mini avatar, or autoMinimizeDelayMs: 0 to stop the player collapsing at all. While an ad plays the player never collapses, and an ad that starts on a collapsed player restores it so the ad's title, skip countdown and CTA are visible; it collapses again after the ad.

Recolouring at runtime

The theme is a set of CSS custom properties on the widget root, so setTheme repaints without rebuilding chrome, touching the engine, or restarting playback. Partial themes are resolved against the defaults — same rules as the theme mount option — and the colour survives a later setMode().

const ui = SynxedWebPlayer.mount({ /* ... */ theme: { accent: "#CCFF00" } });

ui.setTheme({ accent: "#FF00A8" }); // repaints in place, music keeps playing
ui.getTheme().accent; // "#FF00A8" — resolved theme, every value filled in

Width

The wide player has a default responsive width. You can override it with any valid CSS width value.

SynxedWebPlayer.mount({
  apiKey: "...",
  serverUrl: "https://api.synxed.com",
  source: { type: "playlist", playlistCode: "sxpl_..." },
  width: "720px",
});

Responsive example:

SynxedWebPlayer.mount({
  apiKey: "...",
  serverUrl: "https://api.synxed.com",
  source: { type: "playlist", playlistCode: "sxpl_..." },
  width: "min(92vw, 820px)",
});

Position and Dragging

SynxedWebPlayer.mount({
  apiKey: "...",
  serverUrl: "https://api.synxed.com",
  source: { type: "radio" },
  position: {
    placement: "bottom-center",
    offsetX: 16,
    offsetY: 24,
  },
  draggable: true,
});

Available placements:

  • top-left
  • top-right
  • bottom-left
  • bottom-right
  • bottom-center

Theme

The player is gold by default. To match your brand, set theme.accent.

SynxedWebPlayer.mount({
  apiKey: "...",
  serverUrl: "https://api.synxed.com",
  source: { type: "playlist", playlistCode: "sxpl_..." },
  theme: {
    accent: "#22c55e",
    background: "#050805",
    text: "#ffffff",
  },
});

border, liveDot, and glow follow accent by default. Override them only when those colors need to be different.

theme: {
  accent: "#22c55e",
  border: "rgba(34, 197, 94, 0.55)",
  liveDot: "#22c55e",
  glow: "rgba(34, 197, 94, 0.35)",
}

Custom Labels and Classes

SynxedWebPlayer.mount({
  apiKey: "...",
  serverUrl: "https://api.synxed.com",
  source: { type: "playlist", playlistCode: "sxpl_..." },
  powerByLabel: "Powered by Synxed",
  className: "my-synxed-player",
  style: {
    zIndex: "9999",
  },
});

Resumable Playback

The built-in web player resumes songs and playlists by default after a refresh, using a stable anonymous listener ID stored locally.

SynxedWebPlayer.mount({
  apiKey: "...",
  serverUrl: "https://api.synxed.com",
  source: { type: "song", songId: "SYNX_EX_SONG_ID" },
  resumePlayback: true,
});

Turn it off when you always want playback to start fresh:

SynxedWebPlayer.mount({
  apiKey: "...",
  serverUrl: "https://api.synxed.com",
  source: { type: "playlist", playlistCode: "sxpl_..." },
  resumePlayback: false,
});

Resume behavior:

  • songs, playlists, and mixes can resume
  • live radio and scheduled stations stay live and do not resume old positions
  • checkpoint writes are throttled to avoid storage pressure
  • the SDK stores playback position metadata, not API keys or audio files

For a custom UI, enable resume on SynxedPlayer and pass the same listenerId whenever you start content:

import { SynxedPlayer } from "@coumtech/sdk";

const listenerId = "user_123";

const player = new SynxedPlayer({
  apiKey: "YOUR_SYNXED_API_KEY",
  autoConnect: true,
  resumePlayback: true,
});

await player.playPlaylist({
  playlistCode: "sxpl_YOUR_CODE",
  listenerId,
});

Voice Commands

Voice is off unless you opt in and host VAD/ONNX assets yourself (same-origin or https:). The SDK does not download WASM from jsDelivr.

SynxedWebPlayer.mount({
  apiKey: "...",
  serverUrl: "https://api.synxed.com",
  source: { type: "playlist", playlistCode: "sxpl_..." },
  enableVoice: true,
  voice: {
    vadBaseAssetPath: "/vendor/vad/",
    onnxWasmBasePath: "/vendor/onnx/",
  },
});

Turn voice off:

SynxedWebPlayer.mount({
  apiKey: "...",
  serverUrl: "https://api.synxed.com",
  source: { type: "playlist", playlistCode: "sxpl_..." },
  enableVoice: false,
});

Customize the hold delay:

SynxedWebPlayer.mount({
  apiKey: "...",
  serverUrl: "https://api.synxed.com",
  source: { type: "playlist", playlistCode: "sxpl_..." },
  voiceHoldMs: 500,
});

For custom controls:

const djButton = document.getElementById("dj-button")!;

djButton.addEventListener("pointerdown", () => {
  void player.beginVoiceHold({ listenerId: "user_123" });
});

djButton.addEventListener("pointerup", () => {
  void player.endVoiceHold();
});

You can also cancel an active voice hold:

player.cancelVoiceHold();

Custom Player UI

Use SynxedPlayer when you need full UI control.

import { SynxedPlayer } from "@coumtech/sdk";

const player = new SynxedPlayer({
  apiKey: "YOUR_SYNXED_API_KEY",
  autoConnect: true,
  resumePlayback: true,
});

Voice and AdTonos ads stay off until you pass voice: { enabled: true, vadBaseAssetPath, onnxWasmBasePath } or ads: { enabled: true, vastUrl }. First-party Synxed ads are not controlled by these options — see Ads.

Play Content

await player.playSong({
  songId: "SYNX_EX_SONG_ID",
  listenerId: "user_123",
});
await player.playPlaylist({
  playlistCode: "sxpl_YOUR_CODE",
  listenerId: "user_123",
});
await player.playMix({
  mixId: "mix_YOUR_ID",
  listenerId: "user_123",
});
await player.playRadio({
  listenerId: "user_123",
});
await player.playStation({
  stationId: "station_YOUR_ID",
  listenerId: "user_123",
});

Playback Controls

player.pause();
player.resume();
player.stop();
player.skip();
player.previous();
player.skipTo(2);
player.seek(45000);
player.setVolume(0.8);

Notes:

  • seek(ms) expects milliseconds.
  • setVolume(volume) accepts 0 to 1.
  • skip() and previous() apply to playlists and mixes.
  • live radio and stations are continuous streams, so skip/previous are not intended for them.

Foreground / Background Mix

Games usually have their own audio (SFX, ambience) playing under the Synxed stream. setMix balances the two:

  • foreground scales the Synxed licensed track on the SDK's playback graph. It multiplies with setVolume(), so the listener's volume always wins, and it persists across track changes and ads.
  • background never touches SDK audio. It is a level your game reads via getMix() or the mixChange event and applies to its own audio bus.
player.setMix({ foreground: 1, background: 0.35 });

// Apply the background level to your own audio (example with Howler):
player.on("mixChange", ({ background }) => {
  gameAmbience.volume(background);
});

Both levels accept 0 to 1 and default to 1. SynxedWebPlayer exposes the same setMix / getMix methods, which delegate to the engine.

Ads

There are two kinds of audio ad, and they are switched on in different places.

| | First-party Synxed ads | AdTonos (programmatic VAST) | | --- | --- | --- | | Who decides | The server, from your API key's ad frequency. An API key with an ad frequency of 0 never gets ads. | You, with ads: { enabled: true, vastUrl }. | | SDK option | None — ads.enabled does not turn them on or off. | ads.enabled, ads.vastUrl (https: only), ads.optOut. | | Events | adStart, adSkipUpdate, adEnd | adTonosSlotStart, adTonosSlotEnd | | Break events | breakStart, breakEnd — once per break, whichever kind of ad it holds | | | Getters | currentAd, isAdPlaying, canSkipAd(), getAdSkipCountdownSeconds(), currentAdBreak | currentAdTonosSlot, isAdPlaying, currentAdBreak | | Skip / click | skipAd(), clickAd() | Not skippable, no CTA |

Headless SynxedPlayer never injects the AdTonos script unless you opt in:

const player = new SynxedPlayer({
  apiKey: "YOUR_SYNXED_API_KEY",
  ads: {
    enabled: true, // AdTonos only
    vastUrl: "https://your-vast-tag.example/vast.xml",
    optOut: false, // true sets window.adtonosOptOuts = ["*"]
  },
});

Ad breaks come as pods

An ad break is normally two ads played back to back — one first-party Synxed ad and one AdTonos slot — and the SDK treats the pair as a single break:

  • At the start of a session the first-party ad plays first; between songs and at a mix's cue points the AdTonos slot plays first. The server decides the order and sends the two ads one after the other.
  • If only one side has an ad, the break is just that one ad. If neither has one, there is no break.
  • Every ad of a break shares a breakId, and each one carries podPosition (1-based) and podSize. A single-ad break has podSize 1. podSize is the number of ads the server planned: if one side has nothing to serve, a break of 2 delivers only the other ad, which can arrive as position 2 of 2. Drive your UI off what actually arrives, not off a count you expect to reach.
  • Cadence and frequency caps are server-side. Ads are at most 30 s each and a listener hears at most six an hour; the SDK does not schedule, count, or cap anything.

adStart / adEnd (and adTonosSlotStart / adTonosSlotEnd) fire per ad, as before. breakStart and breakEnd fire once for the whole break, so you can duck your own audio for the break instead of twice:

player.on("breakStart", ({ breakId, podSize }) => {
  duckGameAudio(); // once, however many ads the break holds
  console.log(`break ${breakId}: ${podSize} ad(s)`);
});

player.on("breakEnd", () => unduckGameAudio());

player.on("adStart", (ad) => {
  // podSize > 1 means a pod — tell the listener where they are in it.
  if (ad.podSize > 1) showAdPosition(`Ad ${ad.podPosition} of ${ad.podSize}`);
});

The player treats the whole break as "an ad is on" — including the gap between two ads, and the wait after the last one — so a track skip is refused and auto-minimize is held until breakEnd, not until adEnd.

breakStart fires before the first ad's adStart, and breakEnd after the last ad's adEnd — including for a break whose ads cannot play at all. The two are paired: every breakStart gets exactly one breakEnd, so duck/unduck stays balanced however the break ends. Normally that is once per break. If the SDK has to end a break early — the server answers PROCESSING / SERVICE_UNAVAILABLE instead of the next ad, or the break's own deadline passes with no answer — breakEnd fires then, because the listener is in silence and your audio should come back; if the server then sends another ad of that same break after all, it is announced with a fresh breakStart. Treat the pair as "audio is interrupted / audio is back", not as a count of breaks.

currentAdBreak reports the break in progress (null when there is none), including the brief gap between two ads of a pod, when isAdPlaying is momentarily false.

A breakStart or breakEnd listener that throws cannot break playback: the SDK logs the error and carries on with the break. Calling destroy() from inside a breakStart handler ends the session immediately, so that break's breakEnd arrives before your handler returns.

Against a backend that does not send pod fields, every ad is its own single-ad break: breakId is "", podPosition and podSize are 1, and breakStart / breakEnd fire once per ad.

What happens during an ad

  • The music fades out (about 400 ms) once when the break opens and fades back in once when it closes — never between two ads of a pod.
  • The ad plays through the same audio engine as music, so setVolume(), setMix(), pause() and resume() apply to it.
  • Each ad of a break keeps its own skip countdown, load timeout, stall watchdog and outcome. Skipping the first ad of a pod moves to the second — it does not bring the music back.
  • isAdPlaying is true from the moment the ad arrives until it ends. seek(), previous() and skipTo() are ignored during an ad. skip() skips the ad once it is skippable (on any source, including radio and stations). The server refuses new content while an ad break is open, so a play*() or switch*() call made during the break is held and sent the moment the whole break closes — not between two ads of a pod (the last one wins). The track the server was holding is replaced by it rather than played first.
  • After the last ad of the break the server sends the held track. For a mid-roll break (for example a mix cue) playback resumes where it was interrupted — once, at the end of the break.
  • If the listener pauses during an ad, the pause holds for the rest of the break: the next ad of the pod loads but waits, and the next track arrives paused too.
  • Voice holds don't start during an ad break. If a voice request already in flight completes during the break, the ad keeps playing. The DJ's spoken reply is held, not played over the ad. When the break ends, the reply plays first, then the voice playlist.
  • stop() during a break stops the ad's audio and emits adEnd, but the server's break stays open until it closes on its side. Nothing more from that break is played: neither a re-send of the ad that was playing nor a later ad of the pod. A play*()/switch*() made after stop() is held until the break closes, and the track the server hands back is not played.
  • If a play*()/switch*() crosses an ad the server has just started, the server answers with a SynxedError whose code is AD_PLAYING. The SDK holds that request and sends it when the break closes, and does not emit the error. You only see AD_PLAYING in error for requests the SDK did not make itself (for example a voice handoff).
  • When an ad's media ends slightly before its declared duration (up to 3 s), the SDK waits silently for the remainder before reporting completion, because the server accepts ad_complete only after that duration.

Each ad of a break reaches exactly one outcome, reported to the server, which then sends the next ad of the break — or, after the last one, the held track:

| Outcome | First-party analytics event | AdTonos analytics event | | --- | --- | --- | | Played to the end | ad_complete | adtonos_complete | | Skipped (after skipAfterSeconds) | ad_skip | — | | Could not play | ad_error with reason | adtonos_skip with reason |

First-party end events carry the adId so the server can match them to the break; one produced while offline is sent after the session resumes. ad_error reasons: load_failed (media failed to load, or the ad's playbackUrl is not https:), load_timeout (media did not load within 8 s), autoplay_blocked (the browser refused to play without a user gesture), playback_error (a fatal media error mid-ad, or media far shorter than its declared duration), stalled (no progress for 8 s while not paused by the listener). When an ad cannot play, the SDK emits adEnd once (if adStart was emitted), sets the status to loading, and music resumes as soon as the server answers — the listener is never left in silence. When the server sends a breakId, first-party and AdTonos end events carry it back, and echo podPosition / podSize whenever those arrived as whole positive numbers — including for an explicit single-ad pod — so the server can tell which ad of the pod they close. Values the SDK had to normalize are not reported back, and a backend that sends no breakId gets exactly the payload it got before pods existed: no breakId key, and no pod numbers. AdTonos slots report adtonos_skip with a reason instead of ad_error: no_fill (the VAST tag returned no ad — distinct from ads_disabled, and the server continues the break with the other ad without counting this one), overrun (the slot ran past its duration + 15 s from receipt; the creative is cut and no VAST complete beacon fires), the media failures above, ads_disabled or invalid_slot.

Building your own ad UI

player.on("adStart", (ad) => {
  showAdBadge(ad.brandName, ad.campaignName);
  if (ad.ctaUrl) showCta(ad.ctaLabel); // e.g. "Visit website", "Call now"
  if (ad.companionBannerUrl) showBanner(ad.companionBannerUrl); // always https
});

player.on("adSkipUpdate", ({ canSkip, countdownSeconds }) => {
  skipButton.disabled = !canSkip;
  skipButton.textContent = canSkip ? "Skip ad" : `Skip in ${countdownSeconds}s`;
});

player.on("adEnd", () => hideAdUi());

player.on("adTonosSlotStart", ({ podPosition, podSize }) => {
  showAdBadge("Advertisement");
  if (podSize > 1) showAdPosition(`Ad ${podPosition} of ${podSize}`);
});
player.on("adTonosSlotEnd", () => hideAdUi());

skipButton.onclick = () => player.skipAd(); // no-op until canSkipAd() is true
ctaButton.onclick = () => player.clickAd();

clickAd() only opens a validated destination: https:/http: links (including https://wa.me/…) open in a new tab with noopener,noreferrer; tel: and mailto: go to the device's handler. Anything else (for example javascript:) is rejected and ad.ctaUrl is absent. The ad_cta_click event is recorded once per ad, however many times the listener clicks.

SynxedWebPlayer renders all of this for you in every mode (idle, mini, wide, compact, large) and for every source, including radio and stations: an "Ad" badge, the brand name in the title, a keyboard-accessible CTA button and a skip countdown that turns into a Skip button. When the break holds more than one ad it also shows which one is playing — "1 of 2" beside the badge, shortened to "1/2" in the narrow mini strip — and folds that into the strip's label for assistive tech. A single-ad break looks exactly as it did before. Pass AdTonos options with the same ads option.

Player Events

Use events to keep your UI synced with playback state.

player.on("stateChange", (state) => {
  console.log(state.status, state.currentTrack);
});

player.on("trackChange", (track) => {
  console.log(track.title, track.artist, track.albumArt);
});

player.on("timeUpdate", ({ currentTime, duration }) => {
  console.log(currentTime, duration);
});

player.on("queueUpdated", (tracks) => {
  console.log(tracks);
});

player.on("connected", () => {
  console.log("Connected to Synxed");
});

player.on("disconnected", (reason) => {
  console.log("Disconnected", reason);
});

player.on("error", (error) => {
  console.error(error);
});

Ad events (breakStart, breakEnd, adStart, adSkipUpdate, adEnd, adTonosSlotStart, adTonosSlotEnd) are covered in Ads.

Voice events:

player.on("voiceStateChange", (state) => {
  console.log(state);
});

player.on("voiceResult", (result) => {
  console.log(result.transcript, result.playlistCode);
});

player.on("voiceUserSpeakingChange", (speaking) => {
  console.log("speaking", speaking);
});

Discovery Helpers

The SDK includes lightweight fetch helpers for station and mix discovery.

import { fetchMixes, fetchStations, fetchStationSchedule } from "@coumtech/sdk";

Fetch Mixes

const mixes = await fetchMixes("https://api.synxed.com", {
  headers: {
    Authorization: "Bearer YOUR_SYNXED_API_KEY",
  },
});

const firstMix = mixes[0];

Then play a mix with the lower-level player:

await player.playMix({
  mixId: firstMix.id,
  listenerId: "user_123",
});

Fetch Stations

const stations = await fetchStations("https://api.synxed.com", {
  headers: {
    Authorization: "Bearer YOUR_SYNXED_API_KEY",
  },
});

const firstStation = stations[0];

Then play a station:

await player.playStation({
  stationId: firstStation.id,
  listenerId: "user_123",
});

Fetch a Station Schedule

const schedule = await fetchStationSchedule(
  "https://api.synxed.com",
  firstStation.id,
  {
    headers: {
      Authorization: "Bearer YOUR_SYNXED_API_KEY",
    },
  },
);

Radio Now Playing

For live radio metadata:

import { fetchRadioNowPlaying } from "@coumtech/sdk";

const info = await fetchRadioNowPlaying("https://api.synxed.com");

if (info) {
  console.log(info.title, info.station, info.isLive);
}

Poll every 10 to 15 seconds if you show this in your own UI. Do not poll faster than that in production.

React Example

import { useEffect, useRef } from "react";
import "@coumtech/sdk/player.css";
import { SynxedWebPlayer } from "@coumtech/sdk";

export function MusicPlayer() {
  const playerRef = useRef<SynxedWebPlayer | null>(null);

  useEffect(() => {
    playerRef.current = SynxedWebPlayer.mount({
      apiKey: "YOUR_SYNXED_API_KEY",
      source: { type: "playlist", playlistCode: "sxpl_YOUR_CODE" },
      mode: "wide",
      resumePlayback: true,
    });

    return () => {
      playerRef.current?.destroy();
      playerRef.current = null;
    };
  }, []);

  return null;
}

Next.js Example

The player needs browser APIs, so mount it from a client component.

"use client";

import { useEffect, useRef } from "react";
import "@coumtech/sdk/player.css";
import { SynxedWebPlayer } from "@coumtech/sdk";

export default function SynxedClientPlayer() {
  const playerRef = useRef<SynxedWebPlayer | null>(null);

  useEffect(() => {
    playerRef.current = SynxedWebPlayer.mount({
      apiKey: process.env.NEXT_PUBLIC_SYNXED_API_KEY!,
      source: { type: "radio" },
    });

    return () => {
      playerRef.current?.destroy();
    };
  }, []);

  return null;
}

Keep browser-exposed keys prefixed with NEXT_PUBLIC_. Do not put private backend secrets in client code.

Plain HTML Example

Copy the browser files into your app's public assets folder:

mkdir -p public/vendor/synxed
cp node_modules/@coumtech/sdk/dist/synxed.js public/vendor/synxed/synxed.js
cp node_modules/@coumtech/sdk/dist/player.css public/vendor/synxed/player.css

PowerShell:

New-Item -ItemType Directory -Force public/vendor/synxed | Out-Null
Copy-Item node_modules/@coumtech/sdk/dist/synxed.js public/vendor/synxed/synxed.js
Copy-Item node_modules/@coumtech/sdk/dist/player.css public/vendor/synxed/player.css

Then use normal HTML tags:

<link rel="stylesheet" href="/vendor/synxed/player.css" />

<script src="/vendor/synxed/synxed.js"></script>
<script>
  const player = window.Synxed.SynxedWebPlayer.mount({
    apiKey: "YOUR_SYNXED_API_KEY",
    source: { type: "radio" },
  });
</script>

Cleanup

Always destroy SDK instances when the owning page, route, component, scene, or game state is removed.

player.destroy();

This closes the WebSocket, stops audio, clears timers, removes lifecycle listeners, and releases voice resources.

TypeScript Reference

SynxedWebPlayer.mount(options)

type SynxedWebPlayerSource =
  | { type: "song"; songId: string }
  | { type: "playlist"; playlistCode: string }
  | { type: "radio" }
  | { type: "mix"; mixCode: string }
  | { type: "station"; stationCode: string };

type SynxedWebPlayerMode = "mini" | "idle" | "wide" | "large" | "compact";

interface SynxedWebPlayerOptions {
  container?: HTMLElement;
  apiKey: string;
  serverUrl?: string;
  source: SynxedWebPlayerSource;
  mode?: SynxedWebPlayerMode;
  width?: string;
  theme?: SynxedWebPlayerTheme;
  position?: SynxedWebPlayerPosition;
  nowPlayingPollMs?: number;
  powerByLabel?: string;
  className?: string;
  style?: Partial<CSSStyleDeclaration>;
  onMiniClick?: () => void;
  /** Idle time before collapsing, in ms. Default 7000. 0 disables auto-minimize. */
  autoMinimizeDelayMs?: number;
  /** What to collapse to. Default "idle". */
  autoMinimizeMode?: "idle" | "mini";
  /** Width of the collapsed player in px. Default 165 (mini) / 34 (idle). */
  miniSize?: number;
  draggable?: boolean;
  enableVoice?: boolean;
  voiceHoldMs?: number;
  listenerId?: string;
  resumePlayback?: boolean;
  debug?: boolean;
  /** AdTonos / VAST only. First-party Synxed ads follow the API key's ad frequency. */
  ads?: SynxedAdsOptions;
}

SynxedPlayer

interface SynxedConfig {
  apiKey: string;
  serverUrl?: string;
  autoConnect?: boolean;
  debug?: boolean;
  gameContext?: string;
  deviceType?: string;
  voice?: VoiceHoldOptions;
  ads?: SynxedAdsOptions;
  resumePlayback?: boolean;
}

interface SynxedAdsOptions {
  enabled?: boolean; // AdTonos / VAST opt-in, default false
  vastUrl?: string; // https only
  optOut?: boolean;
}

Ad breaks

/** Carried by breakStart / breakEnd, and by currentAdBreak. */
interface AdBreakInfo {
  /** Shared by every ad of the break. "" when the backend sends no breakId. */
  breakId: string;
  /** How many ads the break holds. 1 for a single-ad break. */
  podSize: number;
}

// AdInfo (adStart / adEnd / currentAd) and AdTonosSlotInfo
// (adTonosSlotStart / adTonosSlotEnd / currentAdTonosSlot) both add:
//   breakId: string;      // "" on a backend without pods
//   podPosition: number;  // 1-based; 1 for a single-ad break
//   podSize: number;      // 1 for a single-ad break

Tests

npm test
npx tsc --noEmit

See TESTING.md for what the suite covers. Do not commit API keys or hit production from tests.

Production Checklist

  • Import @coumtech/sdk/player.css if using SynxedWebPlayer.
  • Call destroy() when unmounting.
  • Use a stable listenerId if you want resume to work in a custom UI.
  • Keep resumePlayback: false for experiences that must always start fresh.
  • Do not poll radio metadata faster than every 10 to 15 seconds.
  • Use HTTPS in production.
  • Do not expose private backend secrets in browser code.
  • Handle error, disconnected, and stateChange events in custom UIs.

Troubleshooting

The player is invisible or animations are missing

Import the CSS once:

import "@coumtech/sdk/player.css";

Playback does not start automatically

Browsers may block autoplay until the user interacts with the page. Let the user click play, tap the mini player, or trigger playback from a user gesture. When autoplay is blocked the player reports paused (not loading), so a Play button works. An ad that is blocked the same way is reported as ad_error (autoplay_blocked) and the music continues.

Resume does not work in a custom UI

Make sure all of these are true:

  • resumePlayback: true is set on SynxedPlayer
  • the same listenerId is passed to playSong, playPlaylist, or playMix
  • the content is a song, playlist, or mix
  • the previous checkpoint has not expired

Radio or stations do not resume after refresh

This is expected. Live radio and scheduled stations stay live.

Voice does not start

Voice capture requires a user gesture and microphone permission. Call beginVoiceHold() from a pointer, mouse, touch, or keyboard interaction.

Artwork URLs should show Synxed domains

Synxed API responses should return artwork through Synxed proxy URLs. If you see a third-party provider domain in a Synxed response, update the backend response mapper before shipping.

License

Copyright (c) 2026 Coum Technologies Ltd. All rights reserved.

This package is published as UNLICENSED: installing it from npm grants no right to use it. Use is permitted only under a written agreement with Coum Technologies Ltd. The full text ships in the package as LICENSE.

Licensing enquiries: [email protected] synxed.com