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

@cyberscaling/secure-audio-stream-client

v0.7.0

Published

JavaScript SDK for the Cyberscaling secure audio streaming worker. AES-CTR transit decryption + MSE / ManagedMediaSource playback.

Readme

@cyberscaling/secure-audio-stream-client

JavaScript SDK pour le lecteur audio chiffré musicme / Cyberscaling. Charge un morceau identifié par (cb, disc, track), ouvre une session sur le worker de streaming, télécharge les chunks chiffrés en Range, déchiffre AES-CTR au vol, alimente une MediaSource (ou ManagedMediaSource sur iOS Safari 17.1+) via mp4box.js. Expose un <audio> HTML standard.

Les extraits audio nécessitent le SDK 0.5.0 et un endpoint Worker compatible avec /init-preview. Voir le guide d'intégration autonome des extraits pour le contrat complet, les erreurs, CORS, métriques, royalties, rollout et tests QA.

Installation

bun add @cyberscaling/[email protected]
# ou: pnpm add / npm install / yarn add

Usage minimal

import { SecureAudioPlayer } from '@cyberscaling/secure-audio-stream-client'

const player = new SecureAudioPlayer({
  workerUrl: 'https://secure-stream.musicme.com',
  getToken: async () => {
    const r = await fetch('/api/player-token', { method: 'POST', credentials: 'include' })
    const { token } = await r.json()
    return token
  },
  mode: 'mse', // 'blob' uniquement pour debug / fallback explicite
})

const playerContainer = document.querySelector<HTMLElement>('#player-container')
if (!playerContainer) throw new Error('Missing #player-container')
playerContainer.append(player.audio)

// `context` (requis depuis 0.7.0) déclare le mode d'écoute pour les
// déclaratifs de royalties : 'on_demand' | 'radio' | 'artist_mix'.
await player.load({ cb: 5400863209100, disc: 1, track: 1, context: 'on_demand' })
await player.play()

Extraits audio avec loadPreview()

Pour une preview unitaire, réutilise le même SecureAudioPlayer et appelle la méthode publique du SDK. N'appelle pas directement /init-preview depuis le code applicatif : le SDK gère la session, la clé, les Range, AES-CTR, MSE/MMS/blob, le heartbeat, les métriques et l'annulation.

const result = await player.loadPreview({
  cb: 5400863209100,
  disc: 1,
  track: 1,
  context: 'on_demand', // requis par le type ; jamais compté pour une preview
})

// Exactement :
// {
//   sessionId: string,
//   expiresAt: number,
//   previewSeconds: 60 | 90,
// }

// loadPreview() ne lance jamais la lecture automatiquement.
await player.play()

previewSeconds est un maximum de politique : 60 s par défaut, 90 s uniquement quand le catalogue fournit explicitement isClassical === true. Une piste courte peut produire moins de média. Quand toute la fenêtre tient après 30 s (source ≥90 s pour une preview 60, ≥120 s pour une preview 90), le Worker part de la dernière frontière de sample AAC à ou avant 30 s ; sinon il part de zéro.

La clé et l'IV ne sont jamais retournés dans PreviewLoadResult. Le SDK consomme la paire inline en interne ou utilise /key comme fallback de compatibilité. Un extrait n'est jamais anonyme : getToken doit toujours passer par le backend partenaire et son contrôle d'entitlement.

Un JWT sans claim scope donne l'accès complet (comportement historique) et autorise donc aussi les extraits. Pour un utilisateur qui n'a droit qu'aux extraits, le backend mint un token portant la claim top-level scope: "preview" :

// trois issues : full, preview, ou aucun token
if (!user.canStream && !user.canPreview) throw forbidden()
const scope = user.canStream ? undefined : 'preview'

body: JSON.stringify({
  sub: user.id,
  ttl_seconds: 300,
  ...(scope === 'preview' ? { scope } : {}),
})

N'écris jamais scope: "full" : les deux seuls états valides sont l'absence du champ et "preview". L'omission est volontaire pour les comptes autorisés au full-stream. Un token preview reçoit 403 forbidden_scope sur /init-stream, /offline/license* et les warmups — le SDK le remonte en ForbiddenScopeError (fatal, jamais retenté), à partir de la version 0.6.0.

Le token doit être frais, porter une claim exp obligatoire et rester court : 5 minutes sont recommandées. À la vérification, le Worker refuse un JWT dont exp est à plus de 3 600 secondes dans le futur. En production, utilise RS256 avec clé privée backend et JWKS public ; HS256 est réservé aux chemins legacy/dev. Une entrée allowed_origins est une origine racine stricte (scheme://host[:port]) sans path, query, fragment ni userinfo. Une config legacy avec path ne matche plus. Sans header Origin, ou avec une ancienne allowlist vide, la porte Origin laisse passer mais la validation JWT reste obligatoire.

Un cache miss de génération peut ajouter plusieurs secondes. Affiche un état de chargement et désactive/debounce l'action identique. Ne boucle pas sur les retries. Pour un 502 preview_generation_failed ou une erreur réseau, propose seulement une tentative utilisateur bornée. Un 401 autorise au plus une reprise contrôlée après renouvellement backend d'un JWT frais ; ne relance jamais le même token. Quota, 400, 403 et 404 ne sont jamais retentés.

Usage React (player réutilisable)

Depuis 0.4.0, load() est rappelable sur un player déjà utilisé (teardown implicite du morceau précédent) — un seul SecureAudioPlayer par composant suffit pour jouer plusieurs pistes successives. (Avant 0.4.0, un load() sur un player déjà chargé se comportait de façon non garantie — il fallait recréer un player par piste.)

import { useEffect, useRef } from 'react'
import { SecureAudioPlayer } from '@cyberscaling/secure-audio-stream-client'

function Player() {
  const containerRef = useRef<HTMLDivElement>(null)
  const playerRef = useRef<SecureAudioPlayer | null>(null)

  useEffect(() => {
    const p = new SecureAudioPlayer({
      workerUrl: 'https://secure-stream.musicme.com',
      getToken: async () => {
        const r = await fetch('/api/player-token', { method: 'POST', credentials: 'include' })
        const { token } = await r.json()
        return token
      },
      onError: console.error,
    })
    containerRef.current?.append(p.audio)
    playerRef.current = p
    return () => p.destroy() // démonte le player ; la session expire côté Worker
  }, [])

  async function playTrack(cb: number, disc: number, track: number) {
    const p = playerRef.current
    if (!p) return
    await p.load({ cb, disc, track, context: 'on_demand' }) // ré-utilise le même player — requiert ≥0.4.0
    await p.play()
  }

  return <div ref={containerRef} />
}

Plateformes & modes de lecture

Le partenaire passe toujours mode: 'mse'. Le SDK choisit le backend au runtime selon les capacités du navigateur :

| Plateforme | Backend résolu | Comportement | |---|---|---| | iOS Safari 17.1+, macOS Safari 17.1+ | ManagedMediaSource (MMS) | Streaming progressif, économe en cellulaire. Pause/reprise du fetch loop sur endstreaming/startstreaming. | | Chrome / Firefox / Edge / Safari desktop pré-17.1 | MediaSource (MSE classique) | Streaming progressif standard, comportement inchangé. | | iOS Safari pré-17.1 | blob (fallback auto) | Téléchargement + déchiffrement intégral avant lecture. onError reçoit un avertissement non-fatal mms_fallback: no_media_source. | | Browser sans MediaSource ni ManagedMediaSource | blob (fallback auto) | Idem ci-dessus. |

Aucune modif du code partenaire pour activer iOS. Le SDK auto-détecte ManagedMediaSource et choisit le bon chemin.

Erreurs

Toute erreur surfacée par le SDK est une SecureAudioError (ou une sous-classe), avec deux champs stables :

  • code — identifiant machine, aligné avec error_code de la télémétrie :
    • session_expired — session expirée/évincée (410) ou fingerprint mismatch (403 non-quota) sur /key·/stream. Un 404 y donne stream_404 (seul le heartbeat traite le 404 comme session morte). Ré-load() résout.
    • quota_exceeded — 403 quota_exceeded sur /stream (StreamQuotaError). Ne pas rouvrir de session : chaque tentative facturerait un nouveau /init-stream sans lever le quota.
    • forbidden_scope — 403 forbidden_scope sur /init-stream, /offline/license* ou un warmup (ForbiddenScopeError, severity: 'fatal', depuis 0.6.0). Le token est limité aux extraits : aucun retry ne peut réussir, seul un token de périmètre plus large le peut. Bascule l'UI en mode extrait ou propose l'abonnement.
    • stream_<status> — autre statut HTTP inattendu sur /stream (StreamError).
    • load_error — erreur non classée pendant load()/loadPreview() (parse mp4box, réseau, etc.).
    • aborted — chargement volontairement annulé ou supersédé (LoadAbortedError).
    • mms_fallback — repli automatique vers le mode blob (FallbackWarning, voir ci-dessous).
  • severity — 'fatal' (échec réel) ou 'warning' (avertissement non bloquant).

Signal unique pour l'expiration de session

Si onSessionExpired est fourni, une SessionExpiredError (code: 'session_expired') est acheminée uniquement vers ce callback — elle ne repasse plus par onError. Sans onSessionExpired, elle continue d'arriver via onError comme avant. N'attends pas les deux pour la même erreur.

Annulation volontaire d'un chargement

Un load()/loadPreview()/loadPrefetched() interrompu par destroy() ou par un nouveau chargement rejette une LoadAbortedError (code: 'aborted', severity: 'warning'). Cette annulation attendue ne passe pas par onError et ne déclenche pas onStreamEnded({ reason: 'error' }) ; le chargement remplacé reçoit uniquement son signal onStreamEnded({ reason: 'aborted' }).

FallbackWarning : le son continue de jouer

FallbackWarning (severity: 'warning', code: 'mms_fallback') est délivrée via onError pour compat arrière — teste severity === 'warning' (ou err instanceof FallbackWarning) avant d'afficher une UI d'erreur fatale : la lecture continue normalement via le repli blob.

import { FallbackWarning } from '@cyberscaling/secure-audio-stream-client'

onError: (err) => {
  if (err instanceof FallbackWarning || (err as { severity?: string }).severity === 'warning') {
    console.info('[player] repli non-fatal', err.message)
    return
  }
  showFatalErrorUi(err)
},

Nouveaux callbacks : onSessionOpened, onStreamEnded

onSessionOpened est déclenché juste après un /init-stream facturé, avant /key — donc signalé même si /key échoue ensuite. Pas déclenché par loadPrefetched() (déjà signalé au moment du prefetchSession, avec origin: 'prefetch') et jamais déclenché par loadPreview() : une preview n'est pas une session full-stream facturée.

new SecureAudioPlayer({
  workerUrl, getToken, mode: 'mse',
  onSessionOpened: ({ sessionId, ref, expiresAt, origin }) => {
    console.info('[player] session facturée', sessionId, origin)
  },
})

onStreamEnded signale la fin de flux, au plus une fois par chargement (load(), loadPreview() ou loadPrefetched()) : 'complete' (fichier livré intégralement), 'error' (échec fatal), ou 'aborted' (destroy() ou nouveau chargement en cours de route).

new SecureAudioPlayer({
  workerUrl, getToken, mode: 'mse',
  onStreamEnded: ({ reason }) => console.info('[player] flux terminé :', reason),
})

Prefetch & cache warm-up

Le play→canplay froid coûte typiquement 1.5-2s (cold DO, cold KV catalogue, cold edge cache). Deux helpers réduisent radicalement cette latence dans le scénario réel partenaire — un user qui ouvre une page album puis joue un morceau.

prefetchAlbum(workerUrl, token, cb)

À appeler dès le mount d'une page album, en fire-and-forget. Le SDK orchestre une série de calls /warmup-album côté worker — chaque call traite jusqu'à 8 tracks en pools parallèles :

  1. Résolution catalogue — walk de l'index tracks-db pour le cb, écrit dans album:<cb> KV (TTL 24h). Tous les (disc, track) → mid du cb sont alors instantanément disponibles cross-isolate.
  2. Métadonnées objet — HEAD Scaleway pour chaque mid en parallèle, écrit dans head:<mid> KV (TTL 24h). Évite la HEAD upstream (~150ms) à chaque /init-stream.
  3. Cache edge block 0 — Range 0-1048575 pour chaque mid en parallèle, await caches.default.put. Cloudflare Cache API persistant 7 jours par PoP. Le premier /stream Range du listener final est alors un HIT (~80ms au lieu de ~400-700ms).
import { prefetchAlbum } from '@cyberscaling/secure-audio-stream-client'

// Appel typique sur ouverture d'une page album
useEffect(() => {
  void prefetchAlbum(workerUrl, token, cb).catch(() => {})  // non-fatal
}, [cb])

Pourquoi le SDK chunke: la limite Cloudflare Workers de 50 subrequests par invocation (Bundled plan) est atteinte dès qu'un album dépasse ~10 tracks (chaque track ≈ 4 subrequests : KV head get/put + Scaleway HEAD + Scaleway GET). prefetchAlbum issue donc le 1er batch synchrone (qui retourne tracks = compte total), puis fanout les batches restants en parallèle. Chaque batch HTTP = une invocation Worker séparée avec son propre budget de 50. Albums de toute taille marchent de manière transparente.

Retourne AlbumWarmupReport agrégé ({tracks, head_cached, edge_filled, edge_errors, batches, phases_ms}).

prefetchSession(workerUrl, token, ref)

Pre-crée la session N+1 pendant que N joue. Renvoie un PrefetchedSession activable via player.loadPrefetched(...) → switch instantané sur fin de track (auto-advance gapless).

import { prefetchSession } from '@cyberscaling/secure-audio-stream-client'

// Pendant la lecture de la track N, dès que `currentTime > duration - 5s` :
const next = await prefetchSession(workerUrl, token, { cb, disc: 1, track: N + 1, context: 'on_demand' })

// Sur audio.ended :
await player.loadPrefetched(next)

Combiné avec prefetchAlbum (déjà appelé sur mount), prefetchSession se contente d'un /init-stream warm — ~50ms.

Depuis 0.5.0, PrefetchedSession.ref est optionnel. Le helper prefetchSession() ajoute un snapshot de la référence afin que loadPrefetched() renseigne ses métriques ; un bundle produit par un ancien SDK reste accepté, avec cb/track_ref vides dans ce rapport.

prefetchTracks(workerUrl, token, refs)

Pré-chauffe une liste de refs arbitraires — cas radio / file multi-albums / "à écouter plus tard" — sans dépendre d'un cb unique comme prefetchAlbum. Chunke transparemment par lots de 8 refs côté client (même contrainte de 50 subrequests/invocation), via l'endpoint /warmup-tracks.

import { prefetchTracks } from '@cyberscaling/secure-audio-stream-client'

const report = await prefetchTracks(workerUrl, token, [
  { cb: 5400863209100, disc: 1, track: 1, context: 'on_demand' },
  { cb: 3663729427441, disc: 1, track: 7, context: 'on_demand' },
  // … jusqu'à N, chunké par 8 côté client
])
// report: TracksWarmupReport — { refs_total, refs_warmed, head_cached, edge_filled, edge_errors, not_found, batches, phases_ms }

Playlist l'utilise automatiquement pour son kvLookahead — appelle-le directement seulement pour chauffer une liste que tu ne joues pas (encore).

Caches serveur (transparents)

Aucune action client requise, à connaître pour le debug :

| Cache | Clé | Couverture | TTL | Effet | |---|---|---|---|---| | album:<cb> (KV) | code-barres | cross-isolate | 24h | Skip b-tree walk (~300ms cold) | | head:<mid> (KV) | mid | cross-isolate | 24h | Skip Scaleway HEAD (~150ms cold) | | scaleway/full/<mid> (Cache API) | mid | per-PoP | 7 jours | Fichiers ≤10 MiB cached whole — toute Range = HIT | | scaleway/blk/<mid>/<n> (Cache API) | mid+blockIdx | per-PoP | 7 jours | Fichiers >10 MiB cached en blocks 1 MiB | | previews/<generator>/<mid>/<source-version>/<60\|90> (Cache API) | générateur+source+durée | per-PoP | 30 jours | MP4/AAC remuxé à la demande, sans transcodage | | SessionPoolDO warmth alarm | 4 pool instances | global | self-rearm 15s | putSession reste warm (~6-10ms) malgré inactivité | | lookupCache (module-scoped) | (cb,disc,track) | per-isolate | 5min | Skip KV/R2 sur replay même isolate |

X-Cache: HIT|MISS|PARTIAL est exposé sur les responses /stream pour observer le edge cache. Le header Server-Timing détaille les phases serveur de chaque endpoint (/init-stream, /init-preview, /key, /stream, /warmup-album). La déduplication de génération preview est locale à l'isolate : deux PoP froids peuvent générer le même extrait en parallèle, de façon sûre.

Télémétrie

Active la collecte de métriques côté worker (dataset play_performance) :

new SecureAudioPlayer({
  workerUrl: 'https://secure-stream.musicme.com',
  getToken,
  mode: 'mse',
  metrics: { enabled: true, sampleRate: 1.0 },
  onMetrics: (report) => {
    // report.mode = 'mse' | 'mms' | 'blob' (le backend réellement utilisé)
    // report.asset_kind = 'source' | 'preview'
    // report.outcome = 'canplay' | 'error' | 'aborted'
    // report.phases_ms.* = breakdown latence (get_token, init_session, fetch_key, mse_setup, mp4box_ready, canplay, total)
    console.info('[player] metrics', report)
  },
})

report.mode permet de slicer les latences par plateforme et report.asset_kind de séparer source et preview. Depuis 0.5.0, PlayMetricsReport.asset_kind: 'source' | 'preview' est requis dans le type public. Le Worker l'ajoute à la fin du tableau play_performance (blob index 10) ; un ancien rapport qui l'omet reste accepté comme source.

Le SDK normalise sampleRate dans l'intervalle fini [0, 1]; une valeur non finie (NaN, Infinity) échoue fermée à 0. Les rapports partent en best-effort via fetch(..., { keepalive: true, credentials: 'omit' }) : pas de sendBeacon, jamais de JWT en query string et aucun cookie, quel que soit l'endpoint. Le Bearer streaming est joint seulement à ${workerUrl}/metrics ou à un endpoint custom de la même origine que le Worker. Un collecteur custom cross-origin reçoit le rapport sans ce Bearer et le SDK ne peut lui ajouter ni headers custom, ni credentials. Il doit accepter un POST non authentifié avec validation CORS/body et rate limiting. Un proxy same-origin reçoit lui aussi le POST sans cookies : il ne bénéficie d'aucune auth navigateur implicite et sert seulement de relais server-side, avec validation/rate limit et authentification sortante vers le collecteur. L'auth query historique reste tolérée côté Worker pour les SDK 0.4.x, mais ne doit pas être utilisée ; /metrics limite le body à 64 KiB et valide chaînes, tailles et timings bornés/finis.

Heartbeat & facturation

Pendant la lecture effective, le SDK ping /heartbeat toutes les 10s avec currentTime cumulé. Avant play() — notamment après loadPreview() sans autoplay —, pendant une pause et après ended, aucun heartbeat périodique ne prolonge le TTL. Si la page passe à l'état hidden pendant qu'elle joue, visibilitychange tente une progression complete:false pour couvrir le throttling des timers en arrière-plan. Sur ended, le SDK arrête d'abord le timer puis tente en best-effort un terminal complete:true. Les terminaux de pagehide, destroy() et du remplacement par un nouveau chargement sont également best-effort. Un navigateur peut interrompre ces envois : leur livraison et la mesure associée ne sont pas garanties.

complete:true est un marqueur terminal de mesure, pas une fermeture de session et pas la preuve que la piste a été écoutée jusqu'au bout. Le worker dérive play_full vs play_extract server-side, à partir d'un seuil fixe de 30 secondes de duration_ms cumulé : le premier franchissement produit play_full ; un heartbeat terminal sous ce seuil produit play_extract, qui peut être promu plus tard si la même session reprend et franchit 30 secondes. Seul un heartbeat effectivement reçu pendant la lecture prolonge le TTL ; la session expire naturellement après le dernier heartbeat effectif. pause, l'état non-autoplay et ended ne la maintiennent plus artificiellement. destroy() démonte l'état local mais ne ferme ni ne libère la session serveur. Sans son joué, il tente seulement un terminal best-effort avec duration_ms: 0.

Le heartbeat s'arrête automatiquement dès que le worker répond 410/404 (session déjà morte/évincée) — inutile de le gérer côté intégrateur.

Pour une preview en lecture effective, le heartbeat maintient la session et sa progression. Avant play(), en pause ou après ended, aucun périodique ne prolonge le TTL. Le Worker n'émet ni play_extract, ni play_full, ni complete, et n'envoie aucun message au ledger de redevances. L'observabilité reste séparée via init_preview et PlayMetricsReport.asset_kind === 'preview'.

Plateformes non-web

Le SDK est web-only — il dépend de MediaSource/ManagedMediaSource, fetch, et crypto.subtle du navigateur. Pour les apps mobiles natives ou React Native, voir le guide d'intégration partenaire du repo musicme-onboarding-mcp, section "Plateformes non-web". En résumé :

  • React Native : recommandé via react-native-webview. Deux patterns possibles : (1) WebView qui charge ta webapp partenaire en distant (UI = ta page web), ou (2) WebView invisible (1×1px) qui héberge un bundle SDK minimal, bridgée par message-passing avec une UI 100% React Native — démo complète. Alternative lourde : bridge natif réimplémentant le flow init/key/stream + déchiffrement (cf. guide partenaire §6.5.3-4).
  • Native iOS (Swift) : AVPlayer + AVAssetResourceLoaderDelegate qui appelle /init-stream, intercepte les range requests AVPlayer et déchiffre AES-CTR via CryptoKit. Réimplémente le SDK en ~150 lignes Swift.
  • Native Android (Kotlin) : ExoPlayer + DataSource.Factory custom, déchiffrement via javax.crypto.Cipher. Mêmes endpoints HTTP que le SDK web.

Playlist (auto-advance + dynamic queue)

Playlist n'a pas de mode preview natif. Pour une UI d'extrait unitaire, appelle directement SecureAudioPlayer.loadPreview(). La démo conserve un bridge applicatif mince : player factory vers loadPreview, sessionLookahead: 0 en mode preview, relecture du mode à la piste suivante et Cast désactivé. Ce bridge ne fait pas partie de l'API Playlist.

Costs to know before integrating: sessionLookahead defaults to 2 — the next 2 tracks each get a billed /init-stream session kept warm, on top of the current track. prefetchLeadSeconds defaults to 8 (seconds before audio.ended when the N+1 session prefetch fires) — now actually honored. There is no session-release endpoint: a prefetched-but-unused session (fast skip, queue swap) stays billed until it expires naturally. If that cost matters for your integration, guard against queue churn on your side — e.g. debounce rapid insert/remove/setItems calls that recompute the lookahead window.

import { Playlist } from '@cyberscaling/secure-audio-stream-client'

const playlist = new Playlist({
  workerUrl: 'https://secure-stream.musicme.com',
  getToken: async () => (await fetch('/api/player-token', { method: 'POST', credentials: 'include' }).then(r => r.json())).token,
  items: [
    { cb: 5400863209100, disc: 1, track: 1, context: 'on_demand' },
    { cb: 5400863209100, disc: 1, track: 2, context: 'on_demand' },
    { cb: 3663729427441, disc: 1, track: 7, context: 'on_demand' },
  ],
  onCurrentChange: (curr, prev) => console.info('[playlist] now playing', curr?.ref),
})

const playlistContainer = document.querySelector<HTMLElement>('#player')
if (!playlistContainer) throw new Error('Missing #player')
playlistContainer.append(playlist.audio)
await playlist.play()

// Mutate live:
playlist.insert({ cb: 5400863209100, disc: 1, track: 5, context: 'on_demand' }, /* position */ 1)
playlist.move(playlist.items[2].id, 0)
playlist.remove(playlist.items[1].id)

Each item gets a stable id (auto-generated on insert if you pass a bare TrackRef). Mutations are synchronous; the Playlist re-computes its lookahead window on every change and adapts the prefetch state transparently.

Lookahead policy (defaults — override via constructor opts):

  • sessionLookahead = 2 — the next 2 tracks have their session + key pre-created via prefetchSession. Track-to-track latency is ~50ms.
  • kvLookahead = 5 — the next 5 tracks have their mid + head + edge cache pre-warmed via the new /warmup-tracks endpoint. Cross-album playlists feel as fast as in-album ones.
  • prefetchLeadSeconds = 8 — how early before audio.ended the next-track session prefetch fires.

Events (all optional):

  • onItemsChange(items) — fired after every mutation.
  • onCurrentChange(curr, prev) — fired on play(), next(), prev(), auto-advance.
  • onPrefetchState(e) — {itemId, ref, layer, state} where layer ∈ {session, kv} and state ∈ {pending, ready, error, invalidated}. Use for observability.
  • onError(err, ctx) — ctx?.itemId correlates the failure to the offending track.

Backwards compatibility: SecureAudioPlayer, prefetchSession, prefetchAlbum, prefetchTracks remain exported and unchanged. Playlist is purely additive.

Gapless, not crossfade

Playlist drives a single <audio> element (one SecureAudioPlayer, rebuilt on every advance) — two tracks can never sound simultaneously. Auto-advance is gapless (thanks to session prefetch), but it is not an audio crossfade.

For a real crossfade (two tracks overlapping), drive two SecureAudioPlayers (or two Playlists) yourself — this is supported, just outside Playlist's scope. currentPlayer gives you access to the outgoing track's player during the transition:

const outgoing = playlist.currentPlayer // current track's player, before advancing
// drive fade-out on outgoing.audio.volume while a second SecureAudioPlayer /
// Playlist ramps up the incoming track in parallel.

Migration — de 0.2.x vers 0.3.x

Aucun breaking change. Deux nouveautés à intégrer si tu veux profiter du gain UX :

1) Warmup album à l'ouverture d'une page album

Si ton intégration actuelle attend que l'utilisateur clique pour lancer la lecture, tu peux pré-chauffer côté serveur dès qu'il atterrit sur la page album. Le premier canplay passe de ~1.5 s à ~400 ms.

import { prefetchAlbum } from '@cyberscaling/secure-audio-stream-client'

// Au mount de la page album, fire-and-forget :
void prefetchAlbum(workerUrl, token, cb).catch(console.warn)

Le helper chunke transparemment côté client si l'album a plus de 8 tracks (limite CF subrequest). Aucune action côté backend partenaire.

2) Remplacer la gestion manuelle "next track" par Playlist

Avant (0.2.x) tu écrivais :

const player = new SecureAudioPlayer({ workerUrl, getToken, audioElement: audio })
player.audio.addEventListener('ended', () => {
  const next = myQueue.shift()
  if (next) void player.load(next)
})
await player.load(myQueue[0])

Avec 0.3.x — pré-fetch + auto-advance gapless + mutations live offertes :

import { Playlist } from '@cyberscaling/secure-audio-stream-client'

const playlist = new Playlist({
  workerUrl,
  getToken,
  audioElement: audio,           // optionnel ; sinon Playlist en crée un
  items: myQueue,                // [{cb, disc, track}, ...] ou [{ref, meta}, ...]
  sessionLookahead: 2,           // défauts ; ajuste si besoin
  kvLookahead: 5,
  onCurrentChange: (curr, prev) => updateUi(curr),
})

await playlist.play()

Le prefetchSession du prochain track est lancé pendant la lecture du courant, donc audio.ended → loadPrefetched() est ~50 ms au lieu de ~1.5 s. La queue peut muter pendant la lecture sans interruption.

3) Pattern UX "playlist persistante + lectures ponctuelles"

Ce que la démo demos/webapp fait : conserver la playlist utilisateur indépendante des lectures ad-hoc (un album → "Play all", un clic sur un titre seul). Idée : un seul <audio> global + une seule instance Playlist, mais une logique applicative qui distingue "sauvegardée" (LS-backed, modifiée seulement par +) et "éphémère" (album entier, ou track standalone, qui ne touche pas la sauvegardée).

// Dans ton store applicatif :
playTrack(ref, meta) {
  // ne touche pas savedQueue
  playlist.setItems([{ ref, meta }])
  void playlist.play()
}

enqueue(ref, meta) {
  savedQueue.push({ id: genId(), ref, meta })
  if (mode === 'queue') playlist.insert({ ref, meta }, savedQueue.length - 1)
  persistToLocalStorage()
}

playFromSavedQueue() {
  mode = 'queue'
  playlist.setItems(savedQueue.map(it => ({ ref: it.ref, meta: it.meta })))
  void playlist.play()
}

Code complet à recopier : demos/webapp/public/playlist-store.ts (singleton, < 200 lignes, sans framework).

4) Pré-warmup pour playlists cross-album

Si ta playlist contient des refs venant de plusieurs albums (radio, mix, recommandations), Playlist chauffe automatiquement les 5 prochaines via /warmup-tracks. Aucune intégration supplémentaire. Si tu veux warmer une liste sans la jouer (ex. page "à écouter plus tard") :

import { prefetchTracks } from '@cyberscaling/secure-audio-stream-client'

void prefetchTracks(workerUrl, token, [
  { cb: 5400863209100, disc: 1, track: 1, context: 'on_demand' },
  { cb: 3663729427441, disc: 1, track: 7, context: 'on_demand' },
  // … jusqu'à N — le helper chunke par 8 côté client
]).catch(console.warn)

API

Voir les exports du package et dist/index.d.ts pour la surface typée complète :

  • SecureAudioPlayer(options) — constructeur
  • player.load(ref) — ouvre une session pour (cb, disc, track)
  • player.loadPreview(ref) : Promise<PreviewLoadResult> — charge sans autoplay une preview 60/90 s et retourne seulement { sessionId, expiresAt, previewSeconds }
  • player.loadPrefetched(prefetched) — active une session pré-créée (gapless)
  • player.play() / player.pause() / player.seek(time) — wrappers <audio>
  • player.audio — le HTMLAudioElement à insérer dans le DOM
  • player.destroy() — annule les chargements, démonte l'audio et les listeners, puis tente le heartbeat terminal en best-effort ; la session facturée n'est ni fermée ni libérée et expire naturellement côté worker après le dernier heartbeat effectivement reçu (player réutilisable ensuite via load())
  • prefetchSession(workerUrl, token, ref) : PrefetchedSession — pre-crée la session N+1 pour player.loadPrefetched (gapless auto-advance)
  • prefetchAlbum(workerUrl, token, cb) : AlbumWarmupReport — fire-and-forget sur mount d'album, warm les caches album KV + head KV + edge block 0 de toutes les tracks
  • types preview exportés : PreviewSeconds, InitPreviewResponse, PreviewLoadResult, AssetKind; PlayMetricsReport.asset_kind est requis
  • ForbiddenScopeError — erreur exportée (depuis 0.6.0) pour un token scope: "preview" refusé sur une route full

Tests

bun run test       # vitest, helpers purs (detectMediaSourceCtor, WaitGate)
bun run typecheck  # tsc --noEmit
bun run build      # tsup → dist/ (lib publish artifacts: ESM + CJS + .d.ts)
bun run build:demo # vite build → ../dist-demo/ (demo SPA, dev only)

Les chemins DOM-heavy (loadMse, MMS event wiring) sont validés manuellement sur la matrice browser du plan iOS/MMS.

Changelog

Voir CHANGELOG.md — livré dans le package npm.

Publication (mainteneurs)

Procédure de release (npm org, token, workflow tag-triggered) : client/RELEASING.md sur GitHub.

License

MIT — voir LICENSE.