A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at v4 567 lines 18 kB view raw
1import { describe, it } from "@std/testing/bdd"; 2import { expect } from "@std/expect"; 3 4import { testWeb } from "@tests/common/index.ts"; 5 6describe("components/engine/audio", () => { 7 it("has default volume of 0.75", async () => { 8 const result = await testWeb(async () => { 9 const mod = await import("~/components/engine/audio/element.js"); 10 const engine = new mod.CLASS(); 11 document.body.append(engine); 12 return engine.volume(); 13 }); 14 15 expect(result).toBe(0.75); 16 }); 17 18 it("adjustVolume updates the global volume signal", async () => { 19 const result = await testWeb(async () => { 20 const mod = await import("~/components/engine/audio/element.js"); 21 const engine = new mod.CLASS(); 22 document.body.append(engine); 23 engine.adjustVolume({ volume: 0.5 }); 24 return engine.volume(); 25 }); 26 27 expect(result).toBe(0.5); 28 }); 29 30 it("adjustVolume clamps to the provided value", async () => { 31 const result = await testWeb(async () => { 32 const mod = await import("~/components/engine/audio/element.js"); 33 const engine = new mod.CLASS(); 34 document.body.append(engine); 35 engine.adjustVolume({ volume: 1.0 }); 36 return engine.volume(); 37 }); 38 39 expect(result).toBe(1.0); 40 }); 41 42 it("isPlaying returns false with no items", async () => { 43 const result = await testWeb(async () => { 44 const mod = await import("~/components/engine/audio/element.js"); 45 const engine = new mod.CLASS(); 46 document.body.append(engine); 47 return engine.isPlaying(); 48 }); 49 50 expect(result).toBe(false); 51 }); 52 53 it("supply with URL items updates the items signal", async () => { 54 const result = await testWeb(async () => { 55 const mod = await import("~/components/engine/audio/element.js"); 56 const { trackA } = await import("~/testing/sample/tracks.js"); 57 const engine = new mod.CLASS(); 58 document.body.append(engine); 59 60 engine.supply({ 61 audio: [ 62 { 63 id: "audio-a", 64 url: "/testing/sample/audio.mp3", 65 isPreload: false, 66 track: trackA, 67 }, 68 { 69 id: "audio-b", 70 url: "/testing/sample/audio.mp3", 71 isPreload: false, 72 track: trackA, 73 }, 74 ], 75 }); 76 77 return engine.items().map((i) => i.id); 78 }); 79 80 expect(result).toEqual(["audio-a", "audio-b"]); 81 }); 82 83 it("supply with same IDs does not update items signal", async () => { 84 const result = await testWeb(async () => { 85 const mod = await import("~/components/engine/audio/element.js"); 86 const { trackA } = await import("~/testing/sample/tracks.js"); 87 const engine = new mod.CLASS(); 88 document.body.append(engine); 89 90 const item = { 91 id: "audio-a", 92 url: "/testing/sample/audio.mp3", 93 isPreload: false, 94 track: trackA, 95 }; 96 97 engine.supply({ audio: [item] }); 98 const itemsAfterFirst = engine.items(); 99 100 engine.supply({ audio: [item] }); 101 const itemsAfterSecond = engine.items(); 102 103 // Same reference means the signal was not updated 104 return itemsAfterFirst === itemsAfterSecond; 105 }); 106 107 expect(result).toBe(true); 108 }); 109 110 it("supply replaces items when IDs change", async () => { 111 const result = await testWeb(async () => { 112 const mod = await import("~/components/engine/audio/element.js"); 113 const { trackA } = await import("~/testing/sample/tracks.js"); 114 const engine = new mod.CLASS(); 115 document.body.append(engine); 116 117 engine.supply({ 118 audio: [{ 119 id: "audio-a", 120 url: "/testing/sample/audio.mp3", 121 isPreload: false, 122 track: trackA, 123 }], 124 }); 125 126 engine.supply({ 127 audio: [{ 128 id: "audio-b", 129 url: "/testing/sample/audio.mp3", 130 isPreload: false, 131 track: trackA, 132 }], 133 }); 134 135 return engine.items().map((i) => i.id); 136 }); 137 138 expect(result).toEqual(["audio-b"]); 139 }); 140 141 it("supply with isPreload change triggers items update", async () => { 142 const result = await testWeb(async () => { 143 const mod = await import("~/components/engine/audio/element.js"); 144 const { trackA } = await import("~/testing/sample/tracks.js"); 145 const engine = new mod.CLASS(); 146 document.body.append(engine); 147 148 engine.supply({ 149 audio: [{ 150 id: "audio-a", 151 url: "/testing/sample/audio.mp3", 152 isPreload: true, 153 track: trackA, 154 }], 155 }); 156 157 engine.supply({ 158 audio: [{ 159 id: "audio-a", 160 url: "/testing/sample/audio.mp3", 161 isPreload: false, 162 track: trackA, 163 }], 164 }); 165 166 return engine.items()[0]?.isPreload; 167 }); 168 169 expect(result).toBe(false); 170 }); 171 172 it("persists volume to localStorage", async () => { 173 const stored = await testWeb(async () => { 174 const mod = await import("~/components/engine/audio/element.js"); 175 const engine = new mod.CLASS(); 176 document.body.append(engine); 177 engine.adjustVolume({ volume: 0.3 }); 178 179 for (let i = 0; i < localStorage.length; i++) { 180 const key = localStorage.key(i)!; 181 if (key.includes("engine/audio") && key.endsWith("/volume")) { 182 return localStorage.getItem(key); 183 } 184 } 185 return null; 186 }); 187 188 expect(stored).toBe("0.3"); 189 }); 190 191 it("restores volume from localStorage on connect", async () => { 192 const result = await testWeb(async () => { 193 const mod = await import("~/components/engine/audio/element.js"); 194 195 // Set volume with first engine instance 196 const engine1 = new mod.CLASS(); 197 document.body.append(engine1); 198 engine1.adjustVolume({ volume: 0.4 }); 199 200 // Second instance reads from localStorage 201 const engine2 = new mod.CLASS(); 202 document.body.append(engine2); 203 return engine2.volume(); 204 }); 205 206 expect(result).toBe(0.4); 207 }); 208 209 // Sample audio tests 210 211 it("state returns undefined for unknown audio id", async () => { 212 const result = await testWeb(async () => { 213 const mod = await import("~/components/engine/audio/element.js"); 214 const { trackA } = await import("~/testing/sample/tracks.js"); 215 const engine = new mod.CLASS(); 216 document.body.append(engine); 217 218 engine.supply({ 219 audio: [{ 220 id: "audio-a", 221 url: "/testing/sample/audio.mp3", 222 isPreload: false, 223 track: trackA, 224 }], 225 }); 226 227 return engine.state("no-such-id"); 228 }); 229 230 expect(result).toBeUndefined(); 231 }); 232 233 it("state has initial loadingState of loading before audio loads", async () => { 234 const result = await testWeb(async () => { 235 const mod = await import("~/components/engine/audio/element.js"); 236 const { trackA } = await import("~/testing/sample/tracks.js"); 237 const engine = new mod.CLASS(); 238 document.body.append(engine); 239 240 engine.supply({ 241 audio: [{ 242 id: "audio-a", 243 url: "/testing/sample/audio.mp3", 244 isPreload: false, 245 track: trackA, 246 }], 247 }); 248 249 const st = engine.state("audio-a"); 250 return { 251 loadingState: st?.loadingState(), 252 currentTime: st?.currentTime(), 253 isPlaying: st?.isPlaying(), 254 hasEnded: st?.hasEnded(), 255 }; 256 }); 257 258 expect(result.loadingState).toBe("loading"); 259 expect(result.currentTime).toBe(0); 260 expect(result.isPlaying).toBe(false); 261 expect(result.hasEnded).toBe(false); 262 }); 263 264 it("loadingState becomes loaded after audio loads", async () => { 265 const result = await testWeb(async () => { 266 const mod = await import("~/components/engine/audio/element.js"); 267 const { trackA } = await import("~/testing/sample/tracks.js"); 268 const engine = new mod.CLASS(); 269 document.body.append(engine); 270 271 engine.supply({ 272 audio: [{ 273 id: "audio-a", 274 url: "/testing/sample/audio.mp3", 275 isPreload: false, 276 track: trackA, 277 }], 278 }); 279 280 const audioEl = engine.querySelector( 281 'de-audio-item[id="audio-a"]:not([preload]) audio', 282 ) as HTMLAudioElement; 283 284 await new Promise<void>((resolve, reject) => { 285 const st = engine.state("audio-a"); 286 if (st?.loadingState() === "loaded") { resolve(); return; } 287 const timer = setTimeout( 288 () => reject(new Error("timeout waiting for audio load")), 289 10000, 290 ); 291 const done = () => { clearTimeout(timer); resolve(); }; 292 audioEl.addEventListener("canplay", done, { once: true }); 293 audioEl.addEventListener("suspend", done, { once: true }); 294 audioEl.addEventListener( 295 "error", 296 () => { clearTimeout(timer); reject(new Error("audio load error")); }, 297 { once: true }, 298 ); 299 }); 300 301 return engine.state("audio-a")?.loadingState(); 302 }); 303 304 expect(result).toBe("loaded"); 305 }); 306 307 it("duration is positive after audio loads", async () => { 308 const result = await testWeb(async () => { 309 const mod = await import("~/components/engine/audio/element.js"); 310 const { trackA } = await import("~/testing/sample/tracks.js"); 311 const engine = new mod.CLASS(); 312 document.body.append(engine); 313 314 engine.supply({ 315 audio: [{ 316 id: "audio-a", 317 url: "/testing/sample/audio.mp3", 318 isPreload: false, 319 track: trackA, 320 }], 321 }); 322 323 const audioEl = engine.querySelector( 324 'de-audio-item[id="audio-a"]:not([preload]) audio', 325 ) as HTMLAudioElement; 326 327 await new Promise<void>((resolve, reject) => { 328 if (audioEl.duration > 0 && isFinite(audioEl.duration)) { 329 resolve(); 330 return; 331 } 332 const timer = setTimeout( 333 () => reject(new Error("timeout waiting for duration")), 334 10000, 335 ); 336 const done = () => { clearTimeout(timer); resolve(); }; 337 audioEl.addEventListener("durationchange", done, { once: true }); 338 audioEl.addEventListener( 339 "error", 340 () => { clearTimeout(timer); reject(new Error("audio load error")); }, 341 { once: true }, 342 ); 343 }); 344 345 return engine.state("audio-a")?.duration(); 346 }); 347 348 expect(result).toBeGreaterThan(0); 349 }); 350 351 it("currentTime is 0 before seek", async () => { 352 const result = await testWeb(async () => { 353 const mod = await import("~/components/engine/audio/element.js"); 354 const { trackA } = await import("~/testing/sample/tracks.js"); 355 const engine = new mod.CLASS(); 356 document.body.append(engine); 357 358 engine.supply({ 359 audio: [{ 360 id: "audio-a", 361 url: "/testing/sample/audio.mp3", 362 isPreload: false, 363 track: trackA, 364 }], 365 }); 366 367 const audioEl = engine.querySelector( 368 'de-audio-item[id="audio-a"]:not([preload]) audio', 369 ) as HTMLAudioElement; 370 371 await new Promise<void>((resolve, reject) => { 372 const st = engine.state("audio-a"); 373 if (st?.loadingState() === "loaded") { resolve(); return; } 374 const timer = setTimeout( 375 () => reject(new Error("timeout")), 376 10000, 377 ); 378 const done = () => { clearTimeout(timer); resolve(); }; 379 audioEl.addEventListener("canplay", done, { once: true }); 380 audioEl.addEventListener("suspend", done, { once: true }); 381 audioEl.addEventListener( 382 "error", 383 () => { clearTimeout(timer); reject(new Error("audio load error")); }, 384 { once: true }, 385 ); 386 }); 387 388 return engine.state("audio-a")?.currentTime(); 389 }); 390 391 expect(result).toBe(0); 392 }); 393 394 it("seek by currentTime updates audio element currentTime", async () => { 395 const result = await testWeb(async () => { 396 const mod = await import("~/components/engine/audio/element.js"); 397 const { trackA } = await import("~/testing/sample/tracks.js"); 398 const engine = new mod.CLASS(); 399 document.body.append(engine); 400 401 engine.supply({ 402 audio: [{ 403 id: "audio-a", 404 url: "/testing/sample/audio.mp3", 405 isPreload: false, 406 track: trackA, 407 }], 408 }); 409 410 const audioEl = engine.querySelector( 411 'de-audio-item[id="audio-a"]:not([preload]) audio', 412 ) as HTMLAudioElement; 413 414 await new Promise<void>((resolve, reject) => { 415 const st = engine.state("audio-a"); 416 if (st?.loadingState() === "loaded") { resolve(); return; } 417 const timer = setTimeout(() => reject(new Error("timeout")), 10000); 418 const done = () => { clearTimeout(timer); resolve(); }; 419 audioEl.addEventListener("canplay", done, { once: true }); 420 audioEl.addEventListener("suspend", done, { once: true }); 421 audioEl.addEventListener( 422 "error", 423 () => { clearTimeout(timer); reject(new Error("audio load error")); }, 424 { once: true }, 425 ); 426 }); 427 428 engine.seek({ audioId: "audio-a", currentTime: 1 }); 429 430 return audioEl.currentTime; 431 }); 432 433 expect(result).toBe(1); 434 }); 435 436 it("seek by percentage updates audio element currentTime proportionally", async () => { 437 const result = await testWeb(async () => { 438 const mod = await import("~/components/engine/audio/element.js"); 439 const { trackA } = await import("~/testing/sample/tracks.js"); 440 const engine = new mod.CLASS(); 441 document.body.append(engine); 442 443 engine.supply({ 444 audio: [{ 445 id: "audio-a", 446 url: "/testing/sample/audio.mp3", 447 isPreload: false, 448 track: trackA, 449 }], 450 }); 451 452 const audioEl = engine.querySelector( 453 'de-audio-item[id="audio-a"]:not([preload]) audio', 454 ) as HTMLAudioElement; 455 456 await new Promise<void>((resolve, reject) => { 457 if (audioEl.duration > 0 && isFinite(audioEl.duration)) { resolve(); return; } 458 const timer = setTimeout(() => reject(new Error("timeout")), 10000); 459 const done = () => { clearTimeout(timer); resolve(); }; 460 audioEl.addEventListener("durationchange", done, { once: true }); 461 audioEl.addEventListener( 462 "error", 463 () => { clearTimeout(timer); reject(new Error("audio load error")); }, 464 { once: true }, 465 ); 466 }); 467 468 engine.seek({ audioId: "audio-a", percentage: 0.5 }); 469 470 return { 471 currentTime: audioEl.currentTime, 472 expected: 0.5 * audioEl.duration, 473 }; 474 }); 475 476 expect(result.currentTime).toBe(result.expected); 477 }); 478 479 it("play sets isPlaying to true", async () => { 480 const result = await testWeb(async () => { 481 const mod = await import("~/components/engine/audio/element.js"); 482 const { trackA } = await import("~/testing/sample/tracks.js"); 483 const engine = new mod.CLASS(); 484 document.body.append(engine); 485 486 engine.supply({ 487 audio: [{ 488 id: "audio-a", 489 url: "/testing/sample/audio.mp3", 490 isPreload: false, 491 track: trackA, 492 }], 493 }); 494 495 const audioEl = engine.querySelector( 496 'de-audio-item[id="audio-a"]:not([preload]) audio', 497 ) as HTMLAudioElement; 498 499 await new Promise<void>((resolve, reject) => { 500 const st = engine.state("audio-a"); 501 if (st?.loadingState() === "loaded") { resolve(); return; } 502 const timer = setTimeout(() => reject(new Error("timeout")), 10000); 503 const done = () => { clearTimeout(timer); resolve(); }; 504 audioEl.addEventListener("canplay", done, { once: true }); 505 audioEl.addEventListener("suspend", done, { once: true }); 506 audioEl.addEventListener( 507 "error", 508 () => { clearTimeout(timer); reject(new Error("audio load error")); }, 509 { once: true }, 510 ); 511 }); 512 513 // play() sets isPlaying optimistically before the audio.play() promise settles 514 engine.play({ audioId: "audio-a", volume: 0.5 }); 515 516 return engine.state("audio-a")?.isPlaying(); 517 }); 518 519 expect(result).toBe(true); 520 }); 521 522 it("pause resets isPlaying to false", async () => { 523 const result = await testWeb(async () => { 524 const mod = await import("~/components/engine/audio/element.js"); 525 const { trackA } = await import("~/testing/sample/tracks.js"); 526 const engine = new mod.CLASS(); 527 document.body.append(engine); 528 529 engine.supply({ 530 audio: [{ 531 id: "audio-a", 532 url: "/testing/sample/audio.mp3", 533 isPreload: false, 534 track: trackA, 535 }], 536 }); 537 538 const audioEl = engine.querySelector( 539 'de-audio-item[id="audio-a"]:not([preload]) audio', 540 ) as HTMLAudioElement; 541 542 await new Promise<void>((resolve, reject) => { 543 const st = engine.state("audio-a"); 544 if (st?.loadingState() === "loaded") { resolve(); return; } 545 const timer = setTimeout(() => reject(new Error("timeout")), 10000); 546 const done = () => { clearTimeout(timer); resolve(); }; 547 audioEl.addEventListener("canplay", done, { once: true }); 548 audioEl.addEventListener("suspend", done, { once: true }); 549 audioEl.addEventListener( 550 "error", 551 () => { clearTimeout(timer); reject(new Error("audio load error")); }, 552 { once: true }, 553 ); 554 }); 555 556 engine.play({ audioId: "audio-a", volume: 0.5 }); 557 engine.pause({ audioId: "audio-a" }); 558 559 // Wait for pause event to propagate 560 await new Promise<void>((resolve) => setTimeout(resolve, 50)); 561 562 return engine.state("audio-a")?.isPlaying(); 563 }); 564 565 expect(result).toBe(false); 566 }); 567});