audio streaming app plyr.fm

fix: eliminate jam "no output" state + restructure header UI (#963)

backend: add fallback output on disconnect (prefers host), broaden
auto-output to any connecting client, not just host.

frontend: split jam header into two rows (identity+actions / output+mode),
remove "no output" UI branch, add defensive mobile overflow CSS.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

authored by zzstoatzz.io

Claude Opus 4.6 and committed by
GitHub
013952d7 1d4d0f84

+348 -119
+38 -7
backend/src/backend/_internal/jams.py
··· 453 453 with contextlib.suppress(Exception): 454 454 await old_ws.close(code=4010, reason="replaced by new connection") 455 455 456 + def _find_fallback_output( 457 + self, jam_id: str, host_did: str, *, exclude_client_ids: set[str] | None = None 458 + ) -> tuple[str, str] | None: 459 + """find a connected client to be fallback output. returns (did, client_id) or None. prefers host.""" 460 + fallback: tuple[str, str] | None = None 461 + for ws in self._connections.get(jam_id, set()): 462 + if not (client_id := self._ws_client_ids.get(ws)): 463 + continue 464 + if exclude_client_ids and client_id in exclude_client_ids: 465 + continue 466 + for did, (jid, w) in self._ws_by_did.items(): 467 + if jid == jam_id and w is ws: 468 + if did == host_did: 469 + return (did, client_id) # host preferred 470 + if fallback is None: 471 + fallback = (did, client_id) 472 + break 473 + return fallback 474 + 456 475 async def _clear_output_if_matches(self, jam_id: str, client_id: str) -> None: 457 - """clear output_client_id and pause if it matches the given client_id.""" 476 + """clear output_client_id if it matches the given client_id. tries fallback before pausing.""" 458 477 async with db_session() as db: 459 478 jam = await self._fetch_jam_by_id(db, jam_id) 460 479 if not jam or not jam.is_active: ··· 463 482 return # no single output to clear 464 483 if jam.state.get("output_client_id") != client_id: 465 484 return 485 + 466 486 state = copy.deepcopy(jam.state) 467 - state["output_client_id"] = None 468 - state["output_did"] = None 469 - state["is_playing"] = False 487 + 488 + # try to find a fallback output device 489 + if fallback := self._find_fallback_output( 490 + jam_id, jam.host_did, exclude_client_ids={client_id} 491 + ): 492 + fallback_did, fallback_client_id = fallback 493 + state["output_client_id"] = fallback_client_id 494 + state["output_did"] = fallback_did 495 + actor_type = "output_fallback" 496 + else: 497 + state["output_client_id"] = None 498 + state["output_did"] = None 499 + state["is_playing"] = False 500 + actor_type = "output_disconnected" 501 + 470 502 jam.state = state 471 503 jam.revision += 1 472 504 jam.updated_at = datetime.now(UTC) ··· 479 511 "revision": jam.revision, 480 512 "state": state, 481 513 "tracks_changed": False, 482 - "actor": {"did": "system", "type": "output_disconnected"}, 514 + "actor": {"did": "system", "type": actor_type}, 483 515 }, 484 516 ) 485 517 ··· 511 543 if client_id := message.get("client_id"): 512 544 self._ws_client_ids[ws] = client_id 513 545 514 - # auto-set output to host on first connect if no output set 546 + # auto-set output on connect if no output set (any client, not just host) 515 547 async with db_session() as db: 516 548 jam = await self._fetch_jam_by_id(db, jam_id) 517 549 if ( ··· 519 551 and jam.is_active 520 552 and jam.state.get("output_mode", "one_speaker") == "one_speaker" 521 553 and jam.state.get("output_client_id") is None 522 - and did == jam.host_did 523 554 ): 524 555 state = copy.deepcopy(jam.state) 525 556 state["output_client_id"] = client_id
+209 -3
backend/tests/api/test_jams.py
··· 760 760 await jam_service.disconnect_ws(jam_id, ws) 761 761 762 762 763 - async def test_output_clears_on_disconnect( 763 + async def test_output_disconnect_no_remaining_pauses( 764 764 test_app: FastAPI, db_session: AsyncSession 765 765 ) -> None: 766 - """test that output_client_id clears and playback pauses when output WS disconnects.""" 766 + """test that output clears and playback pauses when the only client disconnects (no fallback).""" 767 767 from starlette.websockets import WebSocket 768 768 769 769 # create jam and set output via API ··· 789 789 json={"type": "set_output", "client_id": "host-output-client"}, 790 790 ) 791 791 792 - # disconnect output device 792 + # disconnect output device (no other clients connected) 793 793 await jam_service.disconnect_ws(jam_id, ws) 794 794 795 795 # verify DB state: output cleared, playback paused ··· 1357 1357 ) 1358 1358 1359 1359 assert response.status_code == 400 1360 + 1361 + 1362 + # ── output fallback tests ────────────────────────────────────────── 1363 + 1364 + 1365 + async def test_output_fallback_on_disconnect( 1366 + test_app: FastAPI, db_session: AsyncSession, second_user: str 1367 + ) -> None: 1368 + """test that output reassigns to remaining client when output WS disconnects.""" 1369 + from starlette.websockets import WebSocket 1370 + 1371 + from backend._internal import require_auth 1372 + 1373 + async with AsyncClient( 1374 + transport=ASGITransport(app=test_app), base_url="http://test" 1375 + ) as client: 1376 + create_response = await client.post( 1377 + "/jams/", json={"track_ids": ["t1"], "is_playing": True} 1378 + ) 1379 + code = create_response.json()["code"] 1380 + jam_id = create_response.json()["id"] 1381 + 1382 + # second user joins 1383 + async def mock_joiner_auth() -> Session: 1384 + return MockSession(did=second_user) 1385 + 1386 + app.dependency_overrides[require_auth] = mock_joiner_auth 1387 + 1388 + async with AsyncClient( 1389 + transport=ASGITransport(app=test_app), base_url="http://test" 1390 + ) as client: 1391 + await client.post(f"/jams/{code}/join") 1392 + 1393 + # restore host auth 1394 + async def mock_host_auth() -> Session: 1395 + return MockSession(did="did:test:host") 1396 + 1397 + app.dependency_overrides[require_auth] = mock_host_auth 1398 + 1399 + # connect both WSs 1400 + ws_host = AsyncMock(spec=WebSocket) 1401 + ws_joiner = AsyncMock(spec=WebSocket) 1402 + await jam_service.connect_ws(jam_id, ws_host, "did:test:host") 1403 + jam_service._ws_client_ids[ws_host] = "host-client" 1404 + await jam_service.connect_ws(jam_id, ws_joiner, second_user) 1405 + jam_service._ws_client_ids[ws_joiner] = "joiner-client" 1406 + 1407 + # set joiner as output device 1408 + app.dependency_overrides[require_auth] = mock_joiner_auth 1409 + async with AsyncClient( 1410 + transport=ASGITransport(app=test_app), base_url="http://test" 1411 + ) as client: 1412 + await client.post( 1413 + f"/jams/{code}/command", 1414 + json={"type": "set_output", "client_id": "joiner-client"}, 1415 + ) 1416 + 1417 + # disconnect the output device (joiner) 1418 + await jam_service.disconnect_ws(jam_id, ws_joiner) 1419 + 1420 + # output should have fallen back to host, playback should NOT be paused 1421 + app.dependency_overrides[require_auth] = mock_host_auth 1422 + async with AsyncClient( 1423 + transport=ASGITransport(app=test_app), base_url="http://test" 1424 + ) as client: 1425 + get_response = await client.get(f"/jams/{code}") 1426 + state = get_response.json()["state"] 1427 + assert state["output_client_id"] == "host-client" 1428 + assert state["output_did"] == "did:test:host" 1429 + assert state["is_playing"] is True 1430 + 1431 + await jam_service.disconnect_ws(jam_id, ws_host) 1432 + 1433 + 1434 + async def test_output_fallback_prefers_host( 1435 + test_app: FastAPI, db_session: AsyncSession, second_user: str 1436 + ) -> None: 1437 + """test that fallback prefers host over other connected clients.""" 1438 + from starlette.websockets import WebSocket 1439 + 1440 + from backend._internal import require_auth 1441 + 1442 + # create a third user 1443 + third_did = "did:test:third" 1444 + third_artist = Artist( 1445 + did=third_did, 1446 + handle="test.third", 1447 + display_name="Test Third", 1448 + ) 1449 + db_session.add(third_artist) 1450 + await enable_flag(db_session, third_did, "jams") 1451 + await db_session.commit() 1452 + 1453 + async with AsyncClient( 1454 + transport=ASGITransport(app=test_app), base_url="http://test" 1455 + ) as client: 1456 + create_response = await client.post( 1457 + "/jams/", json={"track_ids": ["t1"], "is_playing": True} 1458 + ) 1459 + code = create_response.json()["code"] 1460 + jam_id = create_response.json()["id"] 1461 + 1462 + # join second and third users 1463 + for did in [second_user, third_did]: 1464 + 1465 + async def _auth(did: str = did) -> Session: 1466 + return MockSession(did=did) 1467 + 1468 + app.dependency_overrides[require_auth] = _auth 1469 + async with AsyncClient( 1470 + transport=ASGITransport(app=test_app), base_url="http://test" 1471 + ) as client: 1472 + await client.post(f"/jams/{code}/join") 1473 + 1474 + # connect all three WSs 1475 + ws_host = AsyncMock(spec=WebSocket) 1476 + ws_joiner = AsyncMock(spec=WebSocket) 1477 + ws_third = AsyncMock(spec=WebSocket) 1478 + await jam_service.connect_ws(jam_id, ws_host, "did:test:host") 1479 + jam_service._ws_client_ids[ws_host] = "host-client" 1480 + await jam_service.connect_ws(jam_id, ws_joiner, second_user) 1481 + jam_service._ws_client_ids[ws_joiner] = "joiner-client" 1482 + await jam_service.connect_ws(jam_id, ws_third, third_did) 1483 + jam_service._ws_client_ids[ws_third] = "third-client" 1484 + 1485 + # set third user as output 1486 + async def mock_third_auth() -> Session: 1487 + return MockSession(did=third_did) 1488 + 1489 + app.dependency_overrides[require_auth] = mock_third_auth 1490 + async with AsyncClient( 1491 + transport=ASGITransport(app=test_app), base_url="http://test" 1492 + ) as client: 1493 + await client.post( 1494 + f"/jams/{code}/command", 1495 + json={"type": "set_output", "client_id": "third-client"}, 1496 + ) 1497 + 1498 + # disconnect the output (third user) 1499 + await jam_service.disconnect_ws(jam_id, ws_third) 1500 + 1501 + # fallback should prefer host over joiner 1502 + async def mock_host_auth() -> Session: 1503 + return MockSession(did="did:test:host") 1504 + 1505 + app.dependency_overrides[require_auth] = mock_host_auth 1506 + async with AsyncClient( 1507 + transport=ASGITransport(app=test_app), base_url="http://test" 1508 + ) as client: 1509 + get_response = await client.get(f"/jams/{code}") 1510 + state = get_response.json()["state"] 1511 + assert state["output_client_id"] == "host-client" 1512 + assert state["output_did"] == "did:test:host" 1513 + assert state["is_playing"] is True 1514 + 1515 + await jam_service.disconnect_ws(jam_id, ws_host) 1516 + await jam_service.disconnect_ws(jam_id, ws_joiner) 1517 + 1518 + 1519 + async def test_non_host_auto_output_on_sync( 1520 + test_app: FastAPI, db_session: AsyncSession, second_user: str 1521 + ) -> None: 1522 + """test that a non-host gets auto-assigned as output when no output is set.""" 1523 + from starlette.websockets import WebSocket 1524 + 1525 + from backend._internal import require_auth 1526 + 1527 + async with AsyncClient( 1528 + transport=ASGITransport(app=test_app), base_url="http://test" 1529 + ) as client: 1530 + create_response = await client.post("/jams/", json={"track_ids": ["t1"]}) 1531 + code = create_response.json()["code"] 1532 + jam_id = create_response.json()["id"] 1533 + assert create_response.json()["state"]["output_client_id"] is None 1534 + 1535 + # join as second user 1536 + async def mock_joiner_auth() -> Session: 1537 + return MockSession(did=second_user) 1538 + 1539 + app.dependency_overrides[require_auth] = mock_joiner_auth 1540 + async with AsyncClient( 1541 + transport=ASGITransport(app=test_app), base_url="http://test" 1542 + ) as client: 1543 + await client.post(f"/jams/{code}/join") 1544 + 1545 + # connect joiner WS and send sync with client_id (no host WS connected) 1546 + ws = AsyncMock(spec=WebSocket) 1547 + await jam_service.connect_ws(jam_id, ws, second_user) 1548 + await jam_service._handle_sync( 1549 + jam_id, second_user, {"client_id": "joiner-abc-123", "last_id": None}, ws 1550 + ) 1551 + 1552 + # verify non-host was auto-assigned as output 1553 + async def mock_host_auth() -> Session: 1554 + return MockSession(did="did:test:host") 1555 + 1556 + app.dependency_overrides[require_auth] = mock_host_auth 1557 + async with AsyncClient( 1558 + transport=ASGITransport(app=test_app), base_url="http://test" 1559 + ) as client: 1560 + get_response = await client.get(f"/jams/{code}") 1561 + state = get_response.json()["state"] 1562 + assert state["output_client_id"] == "joiner-abc-123" 1563 + assert state["output_did"] == second_user 1564 + 1565 + await jam_service.disconnect_ws(jam_id, ws)
+99 -104
frontend/src/lib/components/Queue.svelte
··· 162 162 <div class="queue" class:jam-mode={jam.active}> 163 163 <div class="queue-header"> 164 164 {#if jam.active} 165 - <div class="jam-info"> 166 - <span class="jam-name">{jam.jam?.name ?? 'jam'}</span> 167 - <div class="jam-meta"> 165 + <div class="jam-header-row"> 166 + <div class="jam-identity"> 168 167 <span class="connection-dot" class:connected={jam.connected} class:reconnecting={jam.reconnecting}></span> 168 + <span class="jam-name">{jam.jam?.name ?? 'jam'}</span> 169 169 <span class="jam-code">{jam.code}</span> 170 + </div> 171 + <div class="queue-actions"> 172 + {#if upcoming.length > 0} 173 + <button 174 + class="clear-btn" 175 + onclick={() => queue.clearUpNext()} 176 + title="clear upcoming tracks" 177 + > 178 + clear 179 + </button> 180 + {/if} 181 + <button class="share-btn" onclick={shareJam} title="share jam link"> 182 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 183 + <circle cx="18" cy="5" r="3"></circle> 184 + <circle cx="6" cy="12" r="3"></circle> 185 + <circle cx="18" cy="19" r="3"></circle> 186 + <line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line> 187 + <line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line> 188 + </svg> 189 + </button> 190 + <button class="leave-btn" onclick={leaveJam} title="leave jam"> 191 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 192 + <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> 193 + <polyline points="16 17 21 12 16 7"></polyline> 194 + <line x1="21" y1="12" x2="9" y2="12"></line> 195 + </svg> 196 + </button> 197 + </div> 198 + </div> 199 + <div class="jam-output-row"> 200 + <span class="output-status"> 170 201 {#if jam.outputMode === 'everyone'} 171 - <span class="output-status">everyone plays</span> 172 - {:else if jam.outputClientId} 173 - <span class="output-status"> 174 - {#if jam.isOutputDevice} 175 - <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path><path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg> 176 - playing here 177 - {:else} 178 - <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg> 179 - {outputParticipant ? `playing on ${outputParticipant.display_name ?? outputParticipant.handle}` : 'playing elsewhere'} 180 - {/if} 181 - </span> 202 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path><path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg> 203 + everyone plays 204 + {:else if jam.isOutputDevice} 205 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path><path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg> 206 + playing here 182 207 {:else} 183 - <span class="output-status no-output">no output</span> 208 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg> 209 + <span class="output-name">{outputParticipant ? (outputParticipant.display_name ?? outputParticipant.handle) : 'elsewhere'}</span> 210 + <button class="pill-btn" onclick={() => jam.setOutput()}>play here</button> 184 211 {/if} 185 - </div> 186 - </div> 187 - <div class="queue-actions"> 212 + </span> 188 213 {#if jam.isHost} 189 - <button class="mode-toggle" onclick={() => jam.setMode(jam.outputMode === 'everyone' ? 'one_speaker' : 'everyone')} title={jam.outputMode === 'everyone' ? 'switch to one speaker' : 'let everyone play'}> 214 + <button class="pill-btn" onclick={() => jam.setMode(jam.outputMode === 'everyone' ? 'one_speaker' : 'everyone')} title={jam.outputMode === 'everyone' ? 'switch to one speaker' : 'let everyone play'}> 190 215 {jam.outputMode === 'everyone' ? 'one speaker' : 'everyone'} 191 216 </button> 192 217 {/if} 193 - {#if jam.outputMode !== 'everyone' && !jam.isOutputDevice} 194 - <button class="output-btn" onclick={() => jam.setOutput()} title="play audio on this device"> 195 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 196 - <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon> 197 - <path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path> 198 - <path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path> 199 - </svg> 200 - </button> 201 - {/if} 202 - {#if upcoming.length > 0} 203 - <button 204 - class="clear-btn" 205 - onclick={() => queue.clearUpNext()} 206 - title="clear upcoming tracks" 207 - > 208 - clear 209 - </button> 210 - {/if} 211 - <button class="share-btn" onclick={shareJam} title="share jam link"> 212 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 213 - <circle cx="18" cy="5" r="3"></circle> 214 - <circle cx="6" cy="12" r="3"></circle> 215 - <circle cx="18" cy="19" r="3"></circle> 216 - <line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line> 217 - <line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line> 218 - </svg> 219 - </button> 220 - <button class="leave-btn" onclick={leaveJam} title="leave jam"> 221 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 222 - <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> 223 - <polyline points="16 17 21 12 16 7"></polyline> 224 - <line x1="21" y1="12" x2="9" y2="12"></line> 225 - </svg> 226 - </button> 227 218 </div> 228 219 {:else} 229 220 <h2>queue</h2> ··· 422 413 border-image: linear-gradient(90deg, #ff6b6b, #ffd93d, #6bcb77, #4d96ff, #9b59b6, #ff6b6b) 1; 423 414 } 424 415 425 - .jam-info { 416 + .jam-header-row { 417 + display: flex; 418 + justify-content: space-between; 419 + align-items: center; 420 + } 421 + 422 + .jam-identity { 426 423 display: flex; 427 - flex-direction: column; 428 - gap: 0.2rem; 424 + align-items: center; 425 + gap: 0.375rem; 429 426 min-width: 0; 427 + overflow: hidden; 428 + } 429 + 430 + .jam-output-row { 431 + display: flex; 432 + justify-content: space-between; 433 + align-items: center; 434 + gap: 0.5rem; 435 + } 436 + 437 + .output-name { 438 + max-width: 140px; 439 + overflow: hidden; 440 + text-overflow: ellipsis; 441 + white-space: nowrap; 442 + display: inline-block; 443 + vertical-align: bottom; 444 + } 445 + 446 + .pill-btn { 447 + padding: 0.125rem 0.5rem; 448 + font-size: var(--text-xs); 449 + font-family: inherit; 450 + background: transparent; 451 + border: 1px solid var(--border-subtle); 452 + color: var(--text-tertiary); 453 + border-radius: var(--radius-full); 454 + cursor: pointer; 455 + transition: all 0.15s ease; 456 + white-space: nowrap; 457 + } 458 + 459 + .pill-btn:hover { 460 + color: var(--accent); 461 + border-color: var(--accent); 430 462 } 431 463 432 464 .jam-name { ··· 437 469 white-space: nowrap; 438 470 overflow: hidden; 439 471 text-overflow: ellipsis; 440 - } 441 - 442 - .jam-meta { 443 - display: flex; 444 - align-items: center; 445 - gap: 0.375rem; 472 + min-width: 0; 473 + flex-shrink: 1; 446 474 } 447 475 448 476 .connection-dot { ··· 472 500 font-size: var(--text-xs); 473 501 color: var(--text-tertiary); 474 502 font-family: monospace; 503 + flex-shrink: 0; 475 504 } 476 505 477 506 .share-btn, ··· 542 571 display: flex; 543 572 justify-content: space-between; 544 573 align-items: center; 574 + } 575 + 576 + .jam-mode .queue-header { 577 + flex-direction: column; 578 + align-items: stretch; 579 + gap: 0.375rem; 545 580 } 546 581 547 582 .queue-actions { ··· 891 926 gap: 0.25rem; 892 927 font-size: var(--text-xs); 893 928 color: var(--text-tertiary); 894 - } 895 - 896 - .output-status.no-output { 897 - color: var(--text-muted); 898 - } 899 - 900 - .mode-toggle { 901 - padding: 0.125rem 0.5rem; 902 - font-size: var(--text-xs); 903 - font-family: inherit; 904 - background: transparent; 905 - border: 1px solid var(--border-subtle); 906 - color: var(--text-tertiary); 907 - border-radius: var(--radius-sm); 908 - cursor: pointer; 909 - transition: all 0.15s ease; 910 - } 911 - 912 - .mode-toggle:hover { 913 - color: var(--accent); 914 - border-color: var(--accent); 915 - } 916 - 917 - .output-btn { 918 - display: flex; 919 - align-items: center; 920 - justify-content: center; 921 - width: 32px; 922 - height: 32px; 923 - padding: 0; 924 - background: transparent; 925 - border: 1px solid var(--border-subtle); 926 - color: var(--text-tertiary); 927 - border-radius: var(--radius-sm); 928 - cursor: pointer; 929 - transition: all 0.15s ease; 930 - } 931 - 932 - .output-btn:hover { 933 - color: var(--accent); 934 - border-color: var(--accent); 935 - background: color-mix(in srgb, var(--accent) 10%, transparent); 929 + min-width: 0; 930 + overflow: hidden; 936 931 } 937 932 938 933 .speaker-badge {
+1 -4
frontend/src/lib/components/player/Player.svelte
··· 609 609 <span class="muted">everyone plays</span> 610 610 {:else if jam.isOutputDevice} 611 611 playing here 612 - {:else if jam.outputClientId} 612 + {:else} 613 613 playing elsewhere 614 - <button class="play-here-pill" onclick={() => jam.setOutput()}>play here</button> 615 - {:else} 616 - <span class="muted">no output</span> 617 614 <button class="play-here-pill" onclick={() => jam.setOutput()}>play here</button> 618 615 {/if} 619 616 </div>
+1 -1
loq.toml
··· 71 71 72 72 [[rules]] 73 73 path = "backend/tests/api/test_jams.py" 74 - max_lines = 1359 74 + max_lines = 1565 75 75 76 76 [[rules]] 77 77 path = "backend/tests/api/test_track_comments.py"