icecast-metadata-player
Version:
Simple to use Javascript class that plays an Icecast stream with real-time metadata updates
401 lines (336 loc) • 11.5 kB
JavaScript
import {
IcecastMetadataQueue,
IcecastReadableStream,
} from "icecast-metadata-js";
import CodecParser from "codec-parser";
import {
p,
state,
event,
audioElement,
enableLogging,
enableCodecUpdate,
endpointGenerator,
metadataTypes,
icyMetaInt,
icyCharacterEncoding,
icyDetectionTimeout,
playbackMethod,
fireEvent,
hasIcy,
abortController,
playerState,
SYNCED,
PCM_SYNCED,
SYNCING,
NOT_SYNCED,
noOp,
authentication,
} from "./global.js";
import Player from "./players/Player.js";
import HTML5Player from "./players/HTML5Player.js";
import MediaSourcePlayer from "./players/MediaSourcePlayer.js";
import WebAudioPlayer from "./players/WebAudioPlayer.js";
export default class PlayerFactory {
constructor(icecast) {
const instanceVariables = p.get(icecast);
this._icecast = icecast;
this._audioElement = instanceVariables[audioElement];
this._enableLogging = instanceVariables[enableLogging];
this._enableCodecUpdate = instanceVariables[enableCodecUpdate];
this._playbackMethod = "";
this._newMetadataQueues();
this._player = new Player(this._icecast);
this._player.icecastMetadataQueue = this._icecastMetadataQueue;
this._player.codecUpdateQueue = this._codecUpdateQueue;
this._player.enablePlayButton();
this._unprocessedFrames = [];
this._codecParser = undefined;
this._inputMimeType = "";
this._codec = "";
this._syncPromise = Promise.resolve();
this._syncCancel = noOp;
}
static get supportedPlaybackMethods() {
return [MediaSourcePlayer, WebAudioPlayer, HTML5Player].map((player) =>
player.isSupported ? player.name : "",
);
}
static canPlayType(type) {
return {
mediasource: MediaSourcePlayer.canPlayType(type),
html5: HTML5Player.canPlayType(type),
webaudio: WebAudioPlayer.canPlayType(type),
};
}
get endpoint() {
return this._endpoint;
}
get player() {
return this._player;
}
get playbackMethod() {
return this._playbackMethod;
}
get icyMetaInt() {
return (
this._icecastReadableStream && this._icecastReadableStream.icyMetaInt
);
}
async playStream() {
return this.fetchStream()
.then(async (res) => {
this._icecast[fireEvent](event.STREAM_START);
return this.readIcecastResponse(res).finally(() => {
this._icecast[fireEvent](event.STREAM_END);
});
})
.catch((e) => {
if (this._icecast.state !== state.SWITCHING) throw e;
});
}
async switchStream() {
if (this._icecast.state !== state.PLAYING) {
this._syncCancel();
await this._syncPromise;
}
const instance = p.get(this._icecast);
instance[playerState] = state.SWITCHING;
instance[abortController].abort();
instance[abortController] = new AbortController();
}
async fetchStream() {
const instanceVariables = p.get(this._icecast);
this._endpoint = instanceVariables[endpointGenerator].next().value;
const headers = instanceVariables[hasIcy] ? { "Icy-MetaData": 1 } : {};
// Work around for Icecast implementations that require range
headers["Range"] = "bytes=0-";
if (instanceVariables[authentication]) {
const auth = instanceVariables[authentication];
headers["Authorization"] =
"Basic " + btoa(auth.user + ":" + auth.password);
}
const request = () =>
fetch(this._endpoint, {
method: "GET",
headers,
signal: instanceVariables[abortController].signal,
});
const res = await request().catch((e) => {
// work around for Safari desktop to remove Range header for CORS
// Even though it's a safelisted header, and this shouldn't be needed
// See: https://fetch.spec.whatwg.org/#cors-safelisted-request-header
if (
e.name === "TypeError" &&
(e.message === "Load failed" ||
e.message ===
"Request header field Range is not allowed by Access-Control-Allow-Headers.")
) {
delete headers["Range"];
return request();
}
throw e;
});
if (!res.ok) {
const error = new Error(`${res.status} received from ${res.url}`);
error.name = "HTTP Response Error";
throw error;
}
return res;
}
async readIcecastResponse(res) {
const inputMimeType = res.headers.get("content-type");
const instanceVariables = p.get(this._icecast);
let onCodecHeader;
const codecHeaderPromise = new Promise((resolve) => {
onCodecHeader = resolve;
});
const codecPromise = new Promise((onCodec) => {
this._codecParser = new CodecParser(inputMimeType, {
onCodec,
onCodecHeader,
onCodecUpdate:
this._enableCodecUpdate &&
((...args) => this._player.onCodecUpdate(...args)),
enableLogging: this._enableLogging,
});
});
this._icecastReadableStream = new IcecastReadableStream(res, {
onMetadata: async (metadata) => {
this._player.onMetadata(metadata);
},
onStream: async ({ stream }) => {
this._icecast[fireEvent](event.STREAM, stream);
const frames = [...this._codecParser.parseChunk(stream)];
if (this._player.isAudioPlayer) {
const appendedFrames = [...this._unprocessedFrames, ...frames];
this._unprocessedFrames = [];
await this._player.onStream(appendedFrames);
} else {
this._unprocessedFrames.push(...frames);
}
},
onError: (...args) => this._icecast[fireEvent](event.WARN, ...args),
metadataTypes: instanceVariables[metadataTypes],
icyCharacterEncoding: instanceVariables[icyCharacterEncoding],
icyDetectionTimeout: instanceVariables[icyDetectionTimeout],
...(instanceVariables[icyMetaInt]
? { icyMetaInt: instanceVariables[icyMetaInt] }
: {}),
});
const icecastPromise = this._icecastReadableStream.startReading();
const codec = await codecPromise;
if (!this._player.isAudioPlayer) {
[this._player, this._playbackMethod] = this._buildPlayer(
inputMimeType,
codec,
codecHeaderPromise,
);
}
if (this._player.syncState === SYNCED) {
this._player.start();
} else {
await this._syncPlayer(inputMimeType, codec, codecHeaderPromise);
}
await icecastPromise;
}
async _syncPlayer(inputMimeType, codec, codecHeaderPromise) {
let delayTimeoutId,
canceled = false,
playerStarted = false,
complete;
const oldPlayer = this._player;
const oldIcecastMetadataQueue = this._player.icecastMetadataQueue;
const oldCodecUpdateQueue = this._player.codecUpdateQueue;
this._newMetadataQueues();
// intercept all new metadata updates
oldPlayer.icecastMetadataQueue = this._icecastMetadataQueue;
oldPlayer.codecUpdateQueue = this._codecUpdateQueue;
const startNewPlayer = () => {
playerStarted = true;
if (
this._icecast.state !== state.STOPPING ||
this._icecast.state !== state.STOPPED
) {
oldPlayer.icecastMetadataQueue.purgeMetadataQueue();
oldPlayer.codecUpdateQueue.purgeMetadataQueue();
this._player
.start(Math.max(0, oldPlayer.syncDelay / 1000))
.then(() => oldPlayer.end())
.then(complete);
}
};
this._syncCancel = () => {
canceled = true;
this._icecastMetadataQueue.purgeMetadataQueue();
this._codecUpdateQueue.purgeMetadataQueue();
this._player.icecastMetadataQueue = oldIcecastMetadataQueue;
this._player.codecUpdateQueue = oldCodecUpdateQueue;
if (delayTimeoutId !== undefined && !playerStarted) {
clearTimeout(delayTimeoutId);
startNewPlayer();
}
};
const handleSyncEvent = () => {
return this._player.syncStateUpdate.then((syncState) => {
if (canceled) complete();
else
switch (syncState) {
case SYNCING:
return handleSyncEvent();
case SYNCED: // synced on crc32 hashes
// put old queues back since audio data is crc synced
this._icecastMetadataQueue.purgeMetadataQueue();
this._codecUpdateQueue.purgeMetadataQueue();
this._player.icecastMetadataQueue = oldIcecastMetadataQueue;
this._player.codecUpdateQueue = oldCodecUpdateQueue;
if (
this._icecast.state !== state.STOPPING ||
this._icecast.state !== state.STOPPED
)
this._icecast[playerState] = state.PLAYING;
complete();
break;
case PCM_SYNCED:
case NOT_SYNCED:
// put old queues back so they can be purged when the player is ended
oldPlayer.icecastMetadataQueue = oldIcecastMetadataQueue;
oldPlayer.codecUpdateQueue = oldCodecUpdateQueue;
[this._player, this._playbackMethod] = this._buildPlayer(
inputMimeType,
codec,
codecHeaderPromise,
);
this._unprocessedFrames.push(...oldPlayer.syncFrames);
// start player after delay or immediately
delayTimeoutId = setTimeout(
startNewPlayer,
Math.max(oldPlayer.syncDelay, 0),
);
}
});
};
let stoppingHandler;
this._syncPromise = new Promise((resolve) => {
complete = resolve;
// cancel switch event if stop is called
stoppingHandler = () => {
this._syncCancel();
complete();
};
this._icecast.addEventListener(state.STOPPING, stoppingHandler, {
once: true,
});
handleSyncEvent();
}).finally(() => {
this._icecast.removeEventListener(state.STOPPING, stoppingHandler);
});
}
_newMetadataQueues() {
this._icecastMetadataQueue = new IcecastMetadataQueue({
onMetadataUpdate: (...args) =>
this._icecast[fireEvent](event.METADATA, ...args),
onMetadataEnqueue: (...args) =>
this._icecast[fireEvent](event.METADATA_ENQUEUE, ...args),
paused: true,
});
this._codecUpdateQueue = new IcecastMetadataQueue({
onMetadataUpdate: (...args) =>
this._icecast[fireEvent](event.CODEC_UPDATE, ...args),
paused: true,
});
}
_buildPlayer(inputMimeType, codec, codecHeader) {
// in order of preference
const { [p.get(this._icecast)[playbackMethod]]: firstMethod, ...rest } = {
mediasource: MediaSourcePlayer,
webaudio: WebAudioPlayer,
html5: HTML5Player,
};
let player, method;
for (const Player of Object.values({ firstMethod, ...rest })) {
const support = Player.canPlayType(`${inputMimeType};codecs="${codec}"`);
if (support === "probably" || support === "maybe") {
method = Player.name;
player = new Player(
this._icecast,
this._endpoint,
inputMimeType,
codec,
codecHeader,
);
player.icecastMetadataQueue = this._icecastMetadataQueue;
player.codecUpdateQueue = this._codecUpdateQueue;
break;
}
}
if (!player) {
throw new Error(
`Your browser does not support this audio codec ${inputMimeType}${
codec && `;codecs="${codec}"`
}`,
);
}
return [player, method];
}
}