@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.
Maintainers
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 addUsage 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é avecerror_codede 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 donnestream_404(seul le heartbeat traite le 404 comme session morte). Ré-load()résout.quota_exceeded— 403quota_exceededsur/stream(StreamQuotaError). Ne pas rouvrir de session : chaque tentative facturerait un nouveau/init-streamsans lever le quota.forbidden_scope— 403forbidden_scopesur/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 pendantload()/loadPreview()(parse mp4box, réseau, etc.).aborted— chargement volontairement annulé ou supersédé (LoadAbortedError).mms_fallback— repli automatique vers le modeblob(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 :
- Résolution catalogue — walk de l'index
tracks-dbpour lecb, écrit dansalbum:<cb>KV (TTL 24h). Tous les(disc, track) → midducbsont alors instantanément disponibles cross-isolate. - Métadonnées objet —
HEADScaleway pour chaquemiden parallèle, écrit danshead:<mid>KV (TTL 24h). Évite la HEAD upstream (~150ms) à chaque/init-stream. - Cache edge block 0 —
Range 0-1048575pour chaquemiden parallèle,await caches.default.put. Cloudflare Cache API persistant 7 jours par PoP. Le premier/streamRange 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+AVAssetResourceLoaderDelegatequi appelle/init-stream, intercepte les range requests AVPlayer et déchiffre AES-CTR viaCryptoKit. Réimplémente le SDK en ~150 lignes Swift. - Native Android (Kotlin) : ExoPlayer +
DataSource.Factorycustom, déchiffrement viajavax.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 viaprefetchSession. Track-to-track latency is ~50ms.kvLookahead = 5— the next 5 tracks have their mid + head + edge cache pre-warmed via the new/warmup-tracksendpoint. Cross-album playlists feel as fast as in-album ones.prefetchLeadSeconds = 8— how early beforeaudio.endedthe next-track session prefetch fires.
Events (all optional):
onItemsChange(items)— fired after every mutation.onCurrentChange(curr, prev)— fired onplay(),next(),prev(), auto-advance.onPrefetchState(e)—{itemId, ref, layer, state}wherelayer ∈ {session, kv}andstate ∈ {pending, ready, error, invalidated}. Use for observability.onError(err, ctx)—ctx?.itemIdcorrelates 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)— constructeurplayer.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— leHTMLAudioElementà insérer dans le DOMplayer.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 viaload())prefetchSession(workerUrl, token, ref) : PrefetchedSession— pre-crée la session N+1 pourplayer.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_kindest requis ForbiddenScopeError— erreur exportée (depuis 0.6.0) pour un tokenscope: "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.
