/
/
/
1import { SendspinCore } from "./core/core.js";
2import { AudioScheduler } from "./audio/scheduler.js";
3import { SILENT_AUDIO_SRC } from "./silent-audio.generated.js";
4// Platform detection utilities
5function detectIsAndroid() {
6 if (typeof navigator === "undefined")
7 return false;
8 return /Android/i.test(navigator.userAgent);
9}
10function detectIsIOS() {
11 if (typeof navigator === "undefined")
12 return false;
13 return (/iPad|iPhone|iPod/.test(navigator.userAgent) ||
14 (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1));
15}
16function detectIsMobile() {
17 return detectIsAndroid() || detectIsIOS();
18}
19function detectIsCastRuntime() {
20 if (typeof navigator === "undefined")
21 return false;
22 return /CrKey/i.test(navigator.userAgent);
23}
24function detectIsSafari() {
25 if (typeof navigator === "undefined")
26 return false;
27 const ua = navigator.userAgent;
28 return /Safari/i.test(ua) && !/Chrome/i.test(ua);
29}
30function detectIsMac() {
31 if (typeof navigator === "undefined")
32 return false;
33 return /Macintosh/i.test(navigator.userAgent);
34}
35function detectIsWindows() {
36 if (typeof navigator === "undefined")
37 return false;
38 return /Windows/i.test(navigator.userAgent);
39}
40/**
41 * Get platform-specific default static delay in milliseconds.
42 * Based on testing across various platforms and browsers.
43 */
44function getDefaultSyncDelay() {
45 if (detectIsIOS())
46 return 250;
47 if (detectIsAndroid())
48 return 200;
49 if (detectIsMac())
50 return detectIsSafari() ? 190 : 150;
51 if (detectIsWindows())
52 return 250;
53 // Linux and others
54 return 200;
55}
56// Add a small cushion beyond the measured buffered runway so delayed timer
57// delivery does not cut playback off just before the last scheduled audio ends.
58const DISCONNECT_PLAYBACK_RESET_GRACE_MS = 250;
59export class SendspinPlayer {
60 constructor(config) {
61 this.ownsAudioElement = false;
62 this.disconnectPlaybackResetTimeout = null;
63 this.suppressDisconnectPlaybackReset = false;
64 // Auto-detect platform
65 const isAndroid = detectIsAndroid();
66 const isCastRuntime = detectIsCastRuntime();
67 const isMobile = detectIsMobile();
68 // Determine output mode
69 const outputMode = config.audioElement || isMobile ? "media-element" : "direct";
70 this.ownsAudioElement =
71 outputMode === "media-element" && !config.audioElement;
72 if (this.ownsAudioElement && typeof document === "undefined") {
73 throw new Error("SendspinPlayer requires a DOM document to use media-element output without a provided audioElement.");
74 }
75 let storage = null;
76 if (config.storage !== undefined) {
77 storage = config.storage;
78 }
79 else if (typeof localStorage !== "undefined") {
80 storage = localStorage;
81 }
82 // Create core (protocol + decoding). It resolves the effective initial
83 // delay, so read it back below for the scheduler's starting value.
84 this.core = new SendspinCore({
85 playerId: config.playerId,
86 baseUrl: config.baseUrl,
87 clientName: config.clientName,
88 webSocket: config.webSocket,
89 codecs: config.codecs,
90 bufferCapacity: config.bufferCapacity ??
91 (outputMode === "media-element" ? 1024 * 1024 * 5 : 1024 * 1024 * 1.5),
92 syncDelay: config.syncDelay,
93 defaultSyncDelay: getDefaultSyncDelay(),
94 storage,
95 requiredLeadTimeMs: config.requiredLeadTimeMs,
96 minBufferMs: config.minBufferMs,
97 useHardwareVolume: config.useHardwareVolume,
98 onVolumeCommand: config.onVolumeCommand,
99 onDelayCommand: config.onDelayCommand,
100 getExternalVolume: config.getExternalVolume,
101 reconnect: config.reconnect,
102 onStateChange: config.onStateChange,
103 });
104 const syncDelay = this.core.getSyncDelayMs();
105 // Create scheduler (Web Audio playback)
106 this.scheduler = new AudioScheduler({
107 stateManager: this.core._stateManager,
108 timeFilter: this.core._timeFilter,
109 outputMode,
110 audioElement: config.audioElement,
111 isAndroid,
112 isCastRuntime,
113 ownsAudioElement: this.ownsAudioElement,
114 silentAudioSrc: isAndroid ? SILENT_AUDIO_SRC : undefined,
115 syncDelayMs: syncDelay,
116 useHardwareVolume: config.useHardwareVolume ?? false,
117 correctionMode: config.correctionMode ?? "sync",
118 storage,
119 useOutputLatencyCompensation: config.useOutputLatencyCompensation ?? true,
120 correctionThresholds: config.correctionThresholds,
121 });
122 // Wire core events to scheduler
123 this.core.onAudioData = (chunk) => {
124 this.scheduler.handleDecodedChunk(chunk);
125 };
126 this.core.onStreamStart = (format, isFormatUpdate) => {
127 this.scheduler.initAudioContext();
128 this.scheduler.resumeAudioContext();
129 if (!isFormatUpdate) {
130 this.scheduler.clearBuffers();
131 }
132 this.scheduler.startAudioElement();
133 };
134 this.core.onStreamClear = () => {
135 this.scheduler.clearBuffers();
136 };
137 this.core.onStreamEnd = () => {
138 this.scheduler.clearBuffers();
139 this.scheduler.stopAudioElement();
140 };
141 this.core.onVolumeUpdate = () => {
142 this.scheduler.updateVolume();
143 };
144 this.core.onSyncDelayChange = (delayMs) => {
145 this.scheduler.setSyncDelay(delayMs);
146 };
147 // Wire connection lifecycle for disconnect playback deferral
148 this.core.onConnectionOpen = () => {
149 this.cancelPendingDisconnectPlaybackReset();
150 };
151 this.core.onConnectionClose = () => {
152 if (this.suppressDisconnectPlaybackReset) {
153 return;
154 }
155 this.scheduleDisconnectPlaybackReset();
156 };
157 }
158 cancelPendingDisconnectPlaybackReset() {
159 if (this.disconnectPlaybackResetTimeout !== null) {
160 clearTimeout(this.disconnectPlaybackResetTimeout);
161 this.disconnectPlaybackResetTimeout = null;
162 }
163 }
164 resetPlaybackStateAfterDisconnect() {
165 this.disconnectPlaybackResetTimeout = null;
166 if (this.core.isConnected) {
167 return;
168 }
169 this.scheduler.clearBuffers();
170 this.core.resetPlaybackState();
171 this.scheduler.stopAudioElement();
172 if (typeof navigator !== "undefined" && navigator.mediaSession) {
173 navigator.mediaSession.playbackState = "paused";
174 }
175 }
176 scheduleDisconnectPlaybackReset() {
177 this.cancelPendingDisconnectPlaybackReset();
178 const runwaySec = this.scheduler.measureBufferedPlaybackRunwaySec();
179 if (runwaySec <= 0) {
180 this.resetPlaybackStateAfterDisconnect();
181 return;
182 }
183 this.disconnectPlaybackResetTimeout = setTimeout(() => {
184 this.resetPlaybackStateAfterDisconnect();
185 }, runwaySec * 1000 + DISCONNECT_PLAYBACK_RESET_GRACE_MS);
186 }
187 // Connect to Sendspin server
188 async connect() {
189 this.suppressDisconnectPlaybackReset = false;
190 return this.core.connect();
191 }
192 /**
193 * Disconnect from Sendspin server
194 * @param reason - Optional reason for disconnecting (default: 'restart')
195 */
196 disconnect(reason = "restart") {
197 this.cancelPendingDisconnectPlaybackReset();
198 this.suppressDisconnectPlaybackReset = true;
199 this.core.disconnect(reason);
200 // Close scheduler
201 this.scheduler.close();
202 // Reset MediaSession playbackState (if available)
203 if (typeof navigator !== "undefined" && navigator.mediaSession) {
204 navigator.mediaSession.playbackState = "none";
205 navigator.mediaSession.metadata = null;
206 }
207 }
208 // Set volume (0-100)
209 setVolume(volume) {
210 this.core.setVolume(volume);
211 }
212 // Set muted state
213 setMuted(muted) {
214 this.core.setMuted(muted);
215 }
216 // Set static delay (in milliseconds, 0-5000)
217 setSyncDelay(delayMs) {
218 this.core.setSyncDelay(delayMs);
219 }
220 /**
221 * Update the reported startup lead time at runtime (ms). Reported to the
222 * server via client/state. Debounce calls to avoid reacting to transient
223 * fluctuations. Throws RangeError if not a non-negative finite number.
224 */
225 setRequiredLeadTimeMs(leadTimeMs) {
226 this.core.setRequiredLeadTimeMs(leadTimeMs);
227 }
228 /**
229 * Update the reported minimum ongoing buffer duration at runtime (ms).
230 * Reported to the server via client/state. Debounce calls to avoid reacting
231 * to transient fluctuations. Throws RangeError if not a non-negative finite
232 * number.
233 */
234 setMinBufferMs(minBufferMs) {
235 this.core.setMinBufferMs(minBufferMs);
236 }
237 /**
238 * Set the sync correction mode at runtime.
239 */
240 setCorrectionMode(mode) {
241 this.scheduler.setCorrectionMode(mode);
242 }
243 // ========================================
244 // Controller Commands (sent to server)
245 // ========================================
246 /**
247 * Send a controller command to the server.
248 */
249 sendCommand(command, params) {
250 this.core.sendCommand(command, params);
251 }
252 // Getters for reactive state
253 get isPlaying() {
254 return this.core.isPlaying;
255 }
256 get volume() {
257 return this.core.volume;
258 }
259 get muted() {
260 return this.core.muted;
261 }
262 get playerState() {
263 return this.core.playerState;
264 }
265 get currentFormat() {
266 return this.core.currentFormat;
267 }
268 get isConnected() {
269 return this.core.isConnected;
270 }
271 // Get current correction mode
272 get correctionMode() {
273 return this.scheduler.correctionMode;
274 }
275 // Time sync info for debugging
276 get timeSyncInfo() {
277 return this.core.timeSyncInfo;
278 }
279 /** Get current server time in microseconds using synchronized clock */
280 getCurrentServerTimeUs() {
281 return this.core.getCurrentServerTimeUs();
282 }
283 /** Get current track progress with real-time position calculation */
284 get trackProgress() {
285 return this.core.trackProgress;
286 }
287 // Sync info for debugging/display
288 get syncInfo() {
289 return this.scheduler.syncInfo;
290 }
291}
292// Re-export types for convenience
293export * from "./types.js";
294export { SendspinTimeFilter } from "./core/time-filter.js";
295export { SendspinCore } from "./core/core.js";
296export { SendspinDecoder } from "./audio/decoder.js";
297export { AudioScheduler } from "./audio/scheduler.js";
298// Export platform detection utilities
299export { detectIsAndroid, detectIsIOS, detectIsMobile, detectIsCastRuntime, getDefaultSyncDelay, };
300//# sourceMappingURL=index.js.map
301