@sayna-ai/js-sdk
Version:
Browser SDK for connecting Sayna clients to real-time voice rooms.
230 lines • 8.39 kB
JavaScript
import { ConnectionState, Room, RoomEvent } from "livekit-client";
/**
* SaynaClient wraps LiveKit client logic for browser usage.
*/
export class SaynaClient {
constructor(options) {
var _a, _b;
this.options = options;
this.room = null;
this.isConnecting = false;
this.createdAudioElement = false;
this.attachedAudioTracks = new Set();
this.handleTrackSubscribed = (track, publication, participant) => {
if (!this.enableAudioPlayback || track.kind !== "audio") {
return;
}
const element = this.ensureAudioElement();
if (!element) {
return;
}
track.attach(element);
this.attachedAudioTracks.add(track);
};
this.handleTrackUnsubscribed = (track) => {
if (track.kind === "audio" && this.attachedAudioTracks.has(track)) {
track.detach();
this.attachedAudioTracks.delete(track);
}
};
this.handleRoomDisconnected = () => {
this.detachAllTracks();
this.room = null;
this.isConnecting = false;
};
if (!options.tokenUrl && !options.tokenFetchHandler) {
throw new Error("SaynaClient requires a tokenUrl or tokenFetchHandler");
}
this.enableAudioPlayback = (_a = options.enableAudioPlayback) !== null && _a !== void 0 ? _a : true;
this.audioElement = (_b = options.audioElement) !== null && _b !== void 0 ? _b : null;
}
/**
* Returns the underlying LiveKit room instance if connected.
*/
get currentRoom() {
return this.room;
}
/**
* Returns true when the client is connected to LiveKit.
*/
get isConnected() {
var _a;
return ((_a = this.room) === null || _a === void 0 ? void 0 : _a.state) === ConnectionState.Connected;
}
/**
* Returns the HTMLAudioElement used for remote playback, if any.
*/
get playbackElement() {
return this.audioElement;
}
/**
* Fetches a token (if needed), connects to LiveKit and resolves to the Room instance.
*/
async connect() {
this.assertBrowserEnvironment();
if (this.isConnecting) {
throw new Error("SaynaClient: connect() is already in progress.");
}
if (this.isConnected) {
throw new Error("SaynaClient: already connected to a room.");
}
this.isConnecting = true;
const room = new Room({
adaptiveStream: true,
dynacast: true,
});
this.room = room;
room.on(RoomEvent.TrackSubscribed, this.handleTrackSubscribed);
room.on(RoomEvent.TrackUnsubscribed, this.handleTrackUnsubscribed);
room.on(RoomEvent.Disconnected, this.handleRoomDisconnected);
try {
const tokenResponse = await this.resolveToken();
if (this.enableAudioPlayback) {
this.ensureAudioElement();
}
await room.connect(tokenResponse.liveUrl
.replace("http://", "ws://")
.replace("https://", "wss://"), tokenResponse.token, {
autoSubscribe: true,
});
return room;
}
catch (error) {
await this.safeDisconnect(room);
this.detachAllTracks();
this.room = null;
throw error;
}
finally {
this.isConnecting = false;
}
}
/**
* Enables the microphone and publishes audio track to the room.
*/
async publishMicrophone(options) {
const room = this.requireConnectedRoom("publishMicrophone");
await room.localParticipant.setMicrophoneEnabled(true, options);
}
/**
* Disconnects from the room and cleans up local resources.
*/
async disconnect() {
if (!this.room) {
return;
}
const room = this.room;
this.room = null;
room.off(RoomEvent.TrackSubscribed, this.handleTrackSubscribed);
room.off(RoomEvent.TrackUnsubscribed, this.handleTrackUnsubscribed);
room.off(RoomEvent.Disconnected, this.handleRoomDisconnected);
this.detachAllTracks();
if (room.state !== ConnectionState.Disconnected) {
await room.disconnect();
}
}
async resolveToken() {
let tokenResponse;
if (typeof this.options.tokenFetchHandler === "function") {
tokenResponse = await this.options.tokenFetchHandler();
}
else if (this.options.tokenUrl) {
const requestUrl = this.toAbsoluteUrl(this.options.tokenUrl);
const response = await fetch(requestUrl.toString(), { method: "GET" });
if (!response.ok) {
const errorData = await response.json().catch(() => ({
error: response.statusText,
}));
throw new Error(errorData.error ||
`Request failed: ${response.status} ${response.statusText}`);
}
tokenResponse = (await response.json());
}
else {
throw new Error("SaynaClient: tokenUrl or tokenFetchHandler is required.");
}
if (!tokenResponse || typeof tokenResponse !== "object") {
throw new Error("SaynaClient: token response is not a valid object.");
}
if (!("token" in tokenResponse) ||
typeof tokenResponse.token !== "string") {
throw new Error("SaynaClient: token response is missing a token string.");
}
if (!("liveUrl" in tokenResponse) ||
typeof tokenResponse.liveUrl !== "string") {
throw new Error("SaynaClient: token response is missing a liveUrl string.");
}
return tokenResponse;
}
ensureAudioElement() {
if (!this.enableAudioPlayback) {
return null;
}
if (!this.audioElement) {
if (typeof document !== "undefined" && document.createElement) {
this.audioElement = document.createElement("audio");
this.audioElement.autoplay = true;
this.createdAudioElement = true;
}
else if (typeof Audio !== "undefined") {
this.audioElement = new Audio();
this.audioElement.autoplay = true;
this.createdAudioElement = true;
}
else {
return null;
}
}
return this.audioElement;
}
detachAllTracks() {
for (const track of this.attachedAudioTracks) {
track.detach();
}
this.attachedAudioTracks.clear();
if (this.createdAudioElement && this.audioElement) {
this.audioElement.srcObject = null;
}
}
async safeDisconnect(room) {
try {
if (room.state !== ConnectionState.Disconnected) {
await room.disconnect();
}
}
catch {
// Ignore disconnect errors during cleanup
}
finally {
room.off(RoomEvent.TrackSubscribed, this.handleTrackSubscribed);
room.off(RoomEvent.TrackUnsubscribed, this.handleTrackUnsubscribed);
room.off(RoomEvent.Disconnected, this.handleRoomDisconnected);
}
}
requireConnectedRoom(methodName) {
if (!this.room || this.room.state !== ConnectionState.Connected) {
throw new Error(`SaynaClient: cannot call ${methodName} before connect().`);
}
return this.room;
}
toAbsoluteUrl(tokenUrl) {
if (tokenUrl instanceof URL) {
return new URL(tokenUrl.toString());
}
try {
return new URL(tokenUrl);
}
catch {
if (typeof window === "undefined" || !window.location) {
throw new Error("SaynaClient: relative tokenUrl requires a browser environment.");
}
return new URL(tokenUrl, window.location.href);
}
}
assertBrowserEnvironment() {
if (typeof window === "undefined" || typeof document === "undefined") {
throw new Error("SaynaClient runs in browser environments only.");
}
}
}
//# sourceMappingURL=index.js.map