audio streaming app plyr.fm

feat: jam output device — single-speaker, everyone else controls (#953)

add output_client_id to jam state so only one participant's browser
plays audio. everyone else sees the queue, controls playback, adds
tracks — but their browser doesn't produce sound.

backend:
- output_client_id/output_did in jam state, set_output command
- auto-set output to host on first WS sync
- clear output + pause when output device disconnects or leaves
- fix _close_ws_for_did race: clear output before popping client_id
- validate jam_id in set_output to prevent cross-jam spoofing

frontend playback fixes (discovered during integration):
- autoplay policy: queue.play()/pause() set player.paused synchronously
alongside jam bridge call — WS round-trip broke gesture context
- audio event fight: onplay/onpause handlers skip during jam —
drift correction seeking fired onpause, which paused playback
- output transfer: explicitly pause audio when isOutputDevice flips
false — was returning early without stopping the audio element

frontend UI:
- output status in queue panel and player stripe
- "play here" button for non-output devices
- speaker badge on output participant's avatar
- non-output progress bar interpolation (250ms interval)

12 new tests covering output lifecycle, cross-client commands,
WS replacement race condition, and jam_id validation.

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

authored by zzstoatzz.io

Claude Opus 4.6 and committed by
GitHub
803b0656 a8da66ba

+861 -40
+97
backend/src/backend/_internal/jams.py
··· 45 45 "is_playing": False, 46 46 "progress_ms": 0, 47 47 "server_time_ms": int(time.time() * 1000), 48 + "output_client_id": None, 49 + "output_did": None, 48 50 } 49 51 50 52 ··· 54 56 def __init__(self) -> None: 55 57 self._connections: dict[str, set[WebSocket]] = {} 56 58 self._ws_by_did: dict[str, tuple[str, WebSocket]] = {} # did → (jam_id, ws) 59 + self._ws_client_ids: dict[WebSocket, str] = {} # ws → client_id 57 60 self._reader_tasks: dict[str, asyncio.Task] = {} 58 61 59 62 async def setup(self) -> None: ··· 70 73 await task 71 74 self._reader_tasks.clear() 72 75 self._connections.clear() 76 + self._ws_client_ids.clear() 73 77 74 78 # ── jam lifecycle ────────────────────────────────────────────── 75 79 ··· 172 176 173 177 async def leave_jam(self, jam_id: str, did: str) -> bool: 174 178 """leave a jam. if last participant, end the jam.""" 179 + # check if leaving user is the output device 180 + if entry := self._ws_by_did.get(did): 181 + if entry[0] == jam_id: 182 + ws = entry[1] 183 + if client_id := self._ws_client_ids.get(ws): 184 + await self._clear_output_if_matches(jam_id, client_id) 185 + 175 186 async with db_session() as db: 176 187 # mark participant as left 177 188 cursor = await db.execute( ··· 355 366 state["current_track_id"] = ( 356 367 track_ids[ci] if track_ids and ci < len(track_ids) else None 357 368 ) 369 + elif cmd_type == "set_output": 370 + # validate: the client_id must belong to the sender's WS in THIS jam 371 + requested_client_id = command.get("client_id") 372 + entry = self._ws_by_did.get(did) 373 + if entry and requested_client_id and entry[0] == jam_id: 374 + sender_ws = entry[1] 375 + actual_client_id = self._ws_client_ids.get(sender_ws) 376 + if actual_client_id == requested_client_id: 377 + state["output_client_id"] = requested_client_id 378 + state["output_did"] = did 379 + else: 380 + logger.warning( 381 + "set_output rejected: client_id mismatch for %s", did 382 + ) 383 + return None 384 + else: 385 + return None 358 386 else: 359 387 logger.warning("unknown jam command type: %s", cmd_type) 360 388 return None ··· 413 441 414 442 async def disconnect_ws(self, jam_id: str, ws: WebSocket) -> None: 415 443 """unregister a WebSocket connection.""" 444 + # check if disconnecting WS was the output device 445 + disconnecting_client_id = self._ws_client_ids.pop(ws, None) 446 + if disconnecting_client_id: 447 + await self._clear_output_if_matches(jam_id, disconnecting_client_id) 448 + 416 449 # clean up did tracking 417 450 dids_to_remove = [ 418 451 did ··· 440 473 if not entry: 441 474 return 442 475 old_jam_id, old_ws = entry 476 + # clear output BEFORE removing client_id — disconnect_ws won't be able 477 + # to find the mapping after we pop it 478 + if client_id := self._ws_client_ids.pop(old_ws, None): 479 + await self._clear_output_if_matches(old_jam_id, client_id) 443 480 if old_jam_id in self._connections: 444 481 self._connections[old_jam_id].discard(old_ws) 445 482 with contextlib.suppress(Exception): 446 483 await old_ws.close(code=4010, reason="replaced by new connection") 447 484 485 + async def _clear_output_if_matches(self, jam_id: str, client_id: str) -> None: 486 + """clear output_client_id and pause if it matches the given client_id.""" 487 + async with db_session() as db: 488 + jam = await self._fetch_jam_by_id(db, jam_id) 489 + if not jam or not jam.is_active: 490 + return 491 + if jam.state.get("output_client_id") != client_id: 492 + return 493 + state = dict(jam.state) 494 + state["output_client_id"] = None 495 + state["output_did"] = None 496 + state["is_playing"] = False 497 + jam.state = state 498 + jam.revision += 1 499 + jam.updated_at = datetime.now(UTC) 500 + await db.commit() 501 + await db.refresh(jam) 502 + await self._publish_event( 503 + jam_id, 504 + { 505 + "type": "state", 506 + "revision": jam.revision, 507 + "state": state, 508 + "tracks_changed": False, 509 + "actor": {"did": "system", "type": "output_disconnected"}, 510 + }, 511 + ) 512 + 448 513 async def handle_ws_message( 449 514 self, jam_id: str, did: str, message: dict[str, Any], ws: WebSocket 450 515 ) -> None: ··· 469 534 self, jam_id: str, did: str, message: dict[str, Any], ws: WebSocket 470 535 ) -> None: 471 536 """handle sync/reconnect request from a client.""" 537 + # store client_id from sync message 538 + if client_id := message.get("client_id"): 539 + self._ws_client_ids[ws] = client_id 540 + 541 + # auto-set output to host on first connect if no output set 542 + async with db_session() as db: 543 + jam = await self._fetch_jam_by_id(db, jam_id) 544 + if ( 545 + jam 546 + and jam.is_active 547 + and jam.state.get("output_client_id") is None 548 + and did == jam.host_did 549 + ): 550 + state = dict(jam.state) 551 + state["output_client_id"] = client_id 552 + state["output_did"] = did 553 + jam.state = state 554 + jam.revision += 1 555 + jam.updated_at = datetime.now(UTC) 556 + await db.commit() 557 + await db.refresh(jam) 558 + await self._publish_event( 559 + jam_id, 560 + { 561 + "type": "state", 562 + "revision": jam.revision, 563 + "state": state, 564 + "tracks_changed": False, 565 + "actor": {"did": "system", "type": "auto_output"}, 566 + }, 567 + ) 568 + 472 569 last_id = message.get("last_id") 473 570 474 571 if not last_id:
+3
backend/src/backend/api/jams.py
··· 45 45 position_ms: int | None = None 46 46 track_ids: list[str] | None = None 47 47 index: int | None = None 48 + client_id: str | None = None 48 49 49 50 50 51 class JamResponse(BaseModel): ··· 188 189 command["track_ids"] = body.track_ids 189 190 if body.index is not None: 190 191 command["index"] = body.index 192 + if body.client_id is not None: 193 + command["client_id"] = body.client_id 191 194 192 195 result = await jam_service.handle_command(jam["id"], session.did, command) 193 196 if not result:
+475 -1
backend/tests/api/test_jams.py
··· 10 10 11 11 from backend._internal import Session 12 12 from backend._internal.feature_flags import enable_flag 13 - from backend._internal.jams import JamService 13 + from backend._internal.jams import JamService, jam_service 14 14 from backend.main import app 15 15 from backend.models import Artist 16 16 ··· 581 581 582 582 # DID mapping should point to second socket 583 583 assert service._ws_by_did["did:test:user"] == (jam_id, ws2) 584 + 585 + 586 + # ── output device tests ──────────────────────────────────────────── 587 + 588 + 589 + async def test_create_jam_has_null_output( 590 + test_app: FastAPI, db_session: AsyncSession 591 + ) -> None: 592 + """test that newly created jams have output_client_id = null.""" 593 + async with AsyncClient( 594 + transport=ASGITransport(app=test_app), base_url="http://test" 595 + ) as client: 596 + response = await client.post("/jams/", json={"track_ids": ["t1"]}) 597 + 598 + assert response.status_code == 200 599 + assert response.json()["state"]["output_client_id"] is None 600 + 601 + 602 + async def test_auto_set_output_on_host_sync( 603 + test_app: FastAPI, db_session: AsyncSession 604 + ) -> None: 605 + """test that output_client_id is auto-set to host on first sync with real DB state.""" 606 + from starlette.websockets import WebSocket 607 + 608 + # create jam via API (writes to DB) 609 + async with AsyncClient( 610 + transport=ASGITransport(app=test_app), base_url="http://test" 611 + ) as client: 612 + create_response = await client.post("/jams/", json={"track_ids": ["t1"]}) 613 + code = create_response.json()["code"] 614 + jam_id = create_response.json()["id"] 615 + assert create_response.json()["state"]["output_client_id"] is None 616 + 617 + # connect host WS and send sync with client_id 618 + ws = AsyncMock(spec=WebSocket) 619 + await jam_service.connect_ws(jam_id, ws, "did:test:host") 620 + await jam_service._handle_sync( 621 + jam_id, "did:test:host", {"client_id": "host-abc-123", "last_id": None}, ws 622 + ) 623 + 624 + # verify DB state was updated 625 + async with AsyncClient( 626 + transport=ASGITransport(app=test_app), base_url="http://test" 627 + ) as client: 628 + get_response = await client.get(f"/jams/{code}") 629 + state = get_response.json()["state"] 630 + assert state["output_client_id"] == "host-abc-123" 631 + assert state["output_did"] == "did:test:host" 632 + 633 + # cleanup 634 + await jam_service.disconnect_ws(jam_id, ws) 635 + 636 + 637 + async def test_set_output_command(test_app: FastAPI, db_session: AsyncSession) -> None: 638 + """test set_output command changes output_client_id in state.""" 639 + from starlette.websockets import WebSocket 640 + 641 + async with AsyncClient( 642 + transport=ASGITransport(app=test_app), base_url="http://test" 643 + ) as client: 644 + create_response = await client.post("/jams/", json={"track_ids": ["t1"]}) 645 + code = create_response.json()["code"] 646 + jam_id = create_response.json()["id"] 647 + 648 + # register a WS with a client_id so set_output can validate 649 + ws = AsyncMock(spec=WebSocket) 650 + await jam_service.connect_ws(jam_id, ws, "did:test:host") 651 + jam_service._ws_client_ids[ws] = "my-client-id" 652 + 653 + async with AsyncClient( 654 + transport=ASGITransport(app=test_app), base_url="http://test" 655 + ) as client: 656 + response = await client.post( 657 + f"/jams/{code}/command", 658 + json={"type": "set_output", "client_id": "my-client-id"}, 659 + ) 660 + 661 + assert response.status_code == 200 662 + assert response.json()["state"]["output_client_id"] == "my-client-id" 663 + 664 + # cleanup 665 + await jam_service.disconnect_ws(jam_id, ws) 666 + 667 + 668 + async def test_set_output_validates_client_id( 669 + test_app: FastAPI, db_session: AsyncSession 670 + ) -> None: 671 + """test that set_output rejects client_id that doesn't match sender's WS.""" 672 + from starlette.websockets import WebSocket 673 + 674 + async with AsyncClient( 675 + transport=ASGITransport(app=test_app), base_url="http://test" 676 + ) as client: 677 + create_response = await client.post("/jams/", json={"track_ids": ["t1"]}) 678 + code = create_response.json()["code"] 679 + jam_id = create_response.json()["id"] 680 + 681 + # register WS with one client_id 682 + ws = AsyncMock(spec=WebSocket) 683 + await jam_service.connect_ws(jam_id, ws, "did:test:host") 684 + jam_service._ws_client_ids[ws] = "real-client-id" 685 + 686 + # try to set output with a different client_id 687 + async with AsyncClient( 688 + transport=ASGITransport(app=test_app), base_url="http://test" 689 + ) as client: 690 + response = await client.post( 691 + f"/jams/{code}/command", 692 + json={"type": "set_output", "client_id": "spoofed-client-id"}, 693 + ) 694 + 695 + # should fail — client_id mismatch 696 + assert response.status_code == 400 697 + 698 + # cleanup 699 + await jam_service.disconnect_ws(jam_id, ws) 700 + 701 + 702 + async def test_output_clears_on_disconnect( 703 + test_app: FastAPI, db_session: AsyncSession 704 + ) -> None: 705 + """test that output_client_id clears and playback pauses when output WS disconnects.""" 706 + from starlette.websockets import WebSocket 707 + 708 + # create jam and set output via API 709 + async with AsyncClient( 710 + transport=ASGITransport(app=test_app), base_url="http://test" 711 + ) as client: 712 + create_response = await client.post( 713 + "/jams/", json={"track_ids": ["t1"], "is_playing": True} 714 + ) 715 + code = create_response.json()["code"] 716 + jam_id = create_response.json()["id"] 717 + 718 + # register host WS as output device 719 + ws = AsyncMock(spec=WebSocket) 720 + await jam_service.connect_ws(jam_id, ws, "did:test:host") 721 + jam_service._ws_client_ids[ws] = "host-output-client" 722 + 723 + async with AsyncClient( 724 + transport=ASGITransport(app=test_app), base_url="http://test" 725 + ) as client: 726 + await client.post( 727 + f"/jams/{code}/command", 728 + json={"type": "set_output", "client_id": "host-output-client"}, 729 + ) 730 + 731 + # disconnect output device 732 + await jam_service.disconnect_ws(jam_id, ws) 733 + 734 + # verify DB state: output cleared, playback paused 735 + async with AsyncClient( 736 + transport=ASGITransport(app=test_app), base_url="http://test" 737 + ) as client: 738 + get_response = await client.get(f"/jams/{code}") 739 + state = get_response.json()["state"] 740 + assert state["output_client_id"] is None 741 + assert state["output_did"] is None 742 + assert state["is_playing"] is False 743 + 744 + 745 + async def test_output_clears_on_ws_replacement( 746 + test_app: FastAPI, db_session: AsyncSession 747 + ) -> None: 748 + """regression: _close_ws_for_did must clear output before removing client_id mapping. 749 + 750 + when a user reconnects (new WS replaces old), _close_ws_for_did fires instead of 751 + disconnect_ws. if it pops the client_id first, the output stays pinned to a dead device. 752 + """ 753 + from starlette.websockets import WebSocket 754 + 755 + # create jam and set host as output 756 + async with AsyncClient( 757 + transport=ASGITransport(app=test_app), base_url="http://test" 758 + ) as client: 759 + create_response = await client.post( 760 + "/jams/", json={"track_ids": ["t1"], "is_playing": True} 761 + ) 762 + code = create_response.json()["code"] 763 + jam_id = create_response.json()["id"] 764 + 765 + ws1 = AsyncMock(spec=WebSocket) 766 + await jam_service.connect_ws(jam_id, ws1, "did:test:host") 767 + jam_service._ws_client_ids[ws1] = "old-client-id" 768 + 769 + async with AsyncClient( 770 + transport=ASGITransport(app=test_app), base_url="http://test" 771 + ) as client: 772 + await client.post( 773 + f"/jams/{code}/command", 774 + json={"type": "set_output", "client_id": "old-client-id"}, 775 + ) 776 + 777 + # reconnect — new WS replaces old (this calls _close_ws_for_did internally) 778 + ws2 = AsyncMock(spec=WebSocket) 779 + await jam_service.connect_ws(jam_id, ws2, "did:test:host") 780 + 781 + # output should have been cleared by _close_ws_for_did 782 + async with AsyncClient( 783 + transport=ASGITransport(app=test_app), base_url="http://test" 784 + ) as client: 785 + get_response = await client.get(f"/jams/{code}") 786 + state = get_response.json()["state"] 787 + assert state["output_client_id"] is None, ( 788 + "output_client_id should be cleared when output device's WS is replaced" 789 + ) 790 + assert state["is_playing"] is False 791 + 792 + # cleanup 793 + await jam_service.disconnect_ws(jam_id, ws2) 794 + 795 + 796 + async def test_non_output_disconnect_no_effect() -> None: 797 + """test that a non-output participant disconnecting doesn't affect output.""" 798 + from starlette.websockets import WebSocket 799 + 800 + service = JamService() 801 + jam_id = "test-noeffect" 802 + 803 + ws_host = AsyncMock(spec=WebSocket) 804 + ws_joiner = AsyncMock(spec=WebSocket) 805 + 806 + await service.connect_ws(jam_id, ws_host, "did:test:host") 807 + service._ws_client_ids[ws_host] = "host-client" 808 + 809 + await service.connect_ws(jam_id, ws_joiner, "did:test:joiner") 810 + service._ws_client_ids[ws_joiner] = "joiner-client" 811 + 812 + # disconnect the non-output joiner 813 + await service.disconnect_ws(jam_id, ws_joiner) 814 + 815 + # host's client_id should still be tracked 816 + assert service._ws_client_ids[ws_host] == "host-client" 817 + assert ws_host in service._connections[jam_id] 818 + 819 + # cleanup 820 + await service.disconnect_ws(jam_id, ws_host) 821 + 822 + 823 + # ── cross-client command tests ──────────────────────────────────── 824 + 825 + 826 + async def test_cross_client_next_command( 827 + test_app: FastAPI, db_session: AsyncSession, second_user: str 828 + ) -> None: 829 + """test that a non-host participant can send 'next' and it updates state for all.""" 830 + from backend._internal import require_auth 831 + 832 + # create jam as host with 4 tracks 833 + async with AsyncClient( 834 + transport=ASGITransport(app=test_app), base_url="http://test" 835 + ) as client: 836 + create_response = await client.post( 837 + "/jams/", 838 + json={ 839 + "name": "cross client test", 840 + "track_ids": ["t1", "t2", "t3", "t4"], 841 + "is_playing": True, 842 + }, 843 + ) 844 + code = create_response.json()["code"] 845 + assert create_response.json()["state"]["current_index"] == 0 846 + assert create_response.json()["state"]["current_track_id"] == "t1" 847 + 848 + # second user joins 849 + async def mock_joiner_auth() -> Session: 850 + return MockSession(did=second_user) 851 + 852 + app.dependency_overrides[require_auth] = mock_joiner_auth 853 + 854 + async with AsyncClient( 855 + transport=ASGITransport(app=test_app), base_url="http://test" 856 + ) as client: 857 + join_response = await client.post(f"/jams/{code}/join") 858 + assert join_response.status_code == 200 859 + 860 + # joiner sends "next" command 861 + next_response = await client.post( 862 + f"/jams/{code}/command", json={"type": "next"} 863 + ) 864 + assert next_response.status_code == 200 865 + state = next_response.json()["state"] 866 + assert state["current_index"] == 1 867 + assert state["current_track_id"] == "t2" 868 + assert state["progress_ms"] == 0 869 + # is_playing should be preserved (not changed by "next") 870 + assert state["is_playing"] is True 871 + 872 + # joiner sends "next" again 873 + next2_response = await client.post( 874 + f"/jams/{code}/command", json={"type": "next"} 875 + ) 876 + assert next2_response.status_code == 200 877 + state2 = next2_response.json()["state"] 878 + assert state2["current_index"] == 2 879 + assert state2["current_track_id"] == "t3" 880 + 881 + # verify state from host's perspective 882 + async def mock_host_auth() -> Session: 883 + return MockSession(did="did:test:host") 884 + 885 + app.dependency_overrides[require_auth] = mock_host_auth 886 + 887 + async with AsyncClient( 888 + transport=ASGITransport(app=test_app), base_url="http://test" 889 + ) as client: 890 + get_response = await client.get(f"/jams/{code}") 891 + assert get_response.status_code == 200 892 + host_view = get_response.json() 893 + assert host_view["state"]["current_index"] == 2 894 + assert host_view["state"]["current_track_id"] == "t3" 895 + 896 + 897 + async def test_cross_client_play_pause( 898 + test_app: FastAPI, db_session: AsyncSession, second_user: str 899 + ) -> None: 900 + """test that a non-host participant can play/pause.""" 901 + from backend._internal import require_auth 902 + 903 + # create jam as host (starts paused) 904 + async with AsyncClient( 905 + transport=ASGITransport(app=test_app), base_url="http://test" 906 + ) as client: 907 + create_response = await client.post( 908 + "/jams/", 909 + json={"track_ids": ["t1", "t2"]}, 910 + ) 911 + code = create_response.json()["code"] 912 + assert create_response.json()["state"]["is_playing"] is False 913 + 914 + # joiner joins and sends play 915 + async def mock_joiner_auth() -> Session: 916 + return MockSession(did=second_user) 917 + 918 + app.dependency_overrides[require_auth] = mock_joiner_auth 919 + 920 + async with AsyncClient( 921 + transport=ASGITransport(app=test_app), base_url="http://test" 922 + ) as client: 923 + await client.post(f"/jams/{code}/join") 924 + 925 + play_response = await client.post( 926 + f"/jams/{code}/command", json={"type": "play"} 927 + ) 928 + assert play_response.status_code == 200 929 + assert play_response.json()["state"]["is_playing"] is True 930 + 931 + pause_response = await client.post( 932 + f"/jams/{code}/command", json={"type": "pause"} 933 + ) 934 + assert pause_response.status_code == 200 935 + assert pause_response.json()["state"]["is_playing"] is False 936 + 937 + 938 + async def test_cross_client_seek( 939 + test_app: FastAPI, db_session: AsyncSession, second_user: str 940 + ) -> None: 941 + """test that a non-host participant can seek.""" 942 + from backend._internal import require_auth 943 + 944 + async with AsyncClient( 945 + transport=ASGITransport(app=test_app), base_url="http://test" 946 + ) as client: 947 + create_response = await client.post("/jams/", json={"track_ids": ["t1"]}) 948 + code = create_response.json()["code"] 949 + 950 + async def mock_joiner_auth() -> Session: 951 + return MockSession(did=second_user) 952 + 953 + app.dependency_overrides[require_auth] = mock_joiner_auth 954 + 955 + async with AsyncClient( 956 + transport=ASGITransport(app=test_app), base_url="http://test" 957 + ) as client: 958 + await client.post(f"/jams/{code}/join") 959 + 960 + seek_response = await client.post( 961 + f"/jams/{code}/command", 962 + json={"type": "seek", "position_ms": 45000}, 963 + ) 964 + assert seek_response.status_code == 200 965 + assert seek_response.json()["state"]["progress_ms"] == 45000 966 + 967 + 968 + async def test_cross_client_add_tracks( 969 + test_app: FastAPI, db_session: AsyncSession, second_user: str 970 + ) -> None: 971 + """test that a non-host participant can add tracks.""" 972 + from backend._internal import require_auth 973 + 974 + async with AsyncClient( 975 + transport=ASGITransport(app=test_app), base_url="http://test" 976 + ) as client: 977 + create_response = await client.post("/jams/", json={"track_ids": ["t1"]}) 978 + code = create_response.json()["code"] 979 + 980 + async def mock_joiner_auth() -> Session: 981 + return MockSession(did=second_user) 982 + 983 + app.dependency_overrides[require_auth] = mock_joiner_auth 984 + 985 + async with AsyncClient( 986 + transport=ASGITransport(app=test_app), base_url="http://test" 987 + ) as client: 988 + await client.post(f"/jams/{code}/join") 989 + 990 + add_response = await client.post( 991 + f"/jams/{code}/command", 992 + json={"type": "add_tracks", "track_ids": ["t2", "t3"]}, 993 + ) 994 + assert add_response.status_code == 200 995 + assert add_response.json()["state"]["track_ids"] == ["t1", "t2", "t3"] 996 + assert add_response.json()["tracks_changed"] is True 997 + 998 + 999 + async def test_output_preserved_across_cross_client_commands( 1000 + test_app: FastAPI, db_session: AsyncSession, second_user: str 1001 + ) -> None: 1002 + """test that output_client_id is preserved when non-output sends commands.""" 1003 + from starlette.websockets import WebSocket 1004 + 1005 + from backend._internal import require_auth 1006 + 1007 + # create jam as host 1008 + async with AsyncClient( 1009 + transport=ASGITransport(app=test_app), base_url="http://test" 1010 + ) as client: 1011 + create_response = await client.post( 1012 + "/jams/", 1013 + json={"track_ids": ["t1", "t2", "t3"], "is_playing": True}, 1014 + ) 1015 + code = create_response.json()["code"] 1016 + jam_id = create_response.json()["id"] 1017 + 1018 + # set up host as output device 1019 + ws_host = AsyncMock(spec=WebSocket) 1020 + await jam_service.connect_ws(jam_id, ws_host, "did:test:host") 1021 + jam_service._ws_client_ids[ws_host] = "host-client-id" 1022 + 1023 + async with AsyncClient( 1024 + transport=ASGITransport(app=test_app), base_url="http://test" 1025 + ) as client: 1026 + set_output_response = await client.post( 1027 + f"/jams/{code}/command", 1028 + json={"type": "set_output", "client_id": "host-client-id"}, 1029 + ) 1030 + assert set_output_response.status_code == 200 1031 + assert ( 1032 + set_output_response.json()["state"]["output_client_id"] == "host-client-id" 1033 + ) 1034 + 1035 + # joiner joins and sends next — output_client_id should be preserved 1036 + async def mock_joiner_auth() -> Session: 1037 + return MockSession(did=second_user) 1038 + 1039 + app.dependency_overrides[require_auth] = mock_joiner_auth 1040 + 1041 + async with AsyncClient( 1042 + transport=ASGITransport(app=test_app), base_url="http://test" 1043 + ) as client: 1044 + await client.post(f"/jams/{code}/join") 1045 + 1046 + next_response = await client.post( 1047 + f"/jams/{code}/command", json={"type": "next"} 1048 + ) 1049 + assert next_response.status_code == 200 1050 + state = next_response.json()["state"] 1051 + assert state["current_index"] == 1 1052 + assert state["output_client_id"] == "host-client-id" 1053 + assert state["output_did"] == "did:test:host" 1054 + assert state["is_playing"] is True 1055 + 1056 + # cleanup 1057 + await jam_service.disconnect_ws(jam_id, ws_host)
+35 -13
docs/architecture/jams.md
··· 10 10 11 11 ## playback model 12 12 13 - each client plays audio independently on its own device. the server owns the authoritative state (what track, what position, playing or paused), and clients sync to it. 13 + **single-output device.** one participant's browser plays audio; everyone else is a remote control. the server tracks `output_client_id` (a per-tab UUID stored in `sessionStorage`) and `output_did` in jam state. only the output device loads audio into `<audio>` and drives playback. non-output clients see the full player UI, send commands, and receive state updates — they just don't produce sound. 14 14 15 - **sync is command-driven, not continuous.** the server broadcasts state only when someone does something — play, pause, seek, skip, add a track. between commands, each client free-runs its own audio element. clients may drift apart during uninterrupted playback and that's intentional. the next command re-syncs everyone. 15 + ### output lifecycle 16 + 17 + 1. **create**: `output_client_id` starts as `null` 18 + 2. **host's first WS sync**: server auto-sets `output_client_id` to the host's `client_id` (zero-friction default) 19 + 3. **set_output command**: any participant can claim output for their own device 20 + 4. **output disconnects**: if the output device's WS drops (tab close, refresh), server clears `output_client_id` and pauses playback 21 + 5. **output leaves jam**: same as disconnect — clear and pause 22 + 23 + ### identity 24 + 25 + each browser tab generates a `client_id` (UUID in `sessionStorage`), sent in the WS sync message. server stores `ws → client_id` mapping. this is distinct from `did` (account identity) — `client_id` identifies the device/tab. 26 + 27 + ### sync model 28 + 29 + **sync is command-driven, not continuous.** the server broadcasts state only when someone does something — play, pause, seek, skip, add a track. between commands, the output device free-runs its own audio element. 16 30 17 31 position interpolation: server stores a snapshot `{progress_ms, server_time_ms, is_playing}` on each state transition. clients compute current position as: 18 32 ··· 21 35 if paused: progress_ms 22 36 ``` 23 37 24 - drift correction: if a client's audio element is >2 seconds off from the interpolated server position, it seeks. this only fires when new state arrives via WebSocket, not every frame. 38 + drift correction (output device only): if the audio element is >2 seconds off from the interpolated server position, it seeks. this only fires when new state arrives via WebSocket, not every frame. 39 + 40 + non-output clients: interpolate progress from jam state for the seek bar display (interval-based, every 250ms). no audio loaded. 25 41 26 42 ## data model 27 43 ··· 40 56 "current_track_id": "abc", 41 57 "is_playing": true, 42 58 "progress_ms": 12500, 43 - "server_time_ms": 1708000000000 59 + "server_time_ms": 1708000000000, 60 + "output_client_id": "a1b2c3d4-...", 61 + "output_did": "did:plc:..." 44 62 } 45 63 ``` 46 64 ··· 52 70 53 71 ## commands 54 72 55 - all 9 commands are server-authoritative. clients send requests via WebSocket, server applies them, increments revision, broadcasts result. 73 + all 10 commands are server-authoritative. clients send requests via WebSocket, server applies them, increments revision, broadcasts result. 56 74 57 75 | command | behavior | 58 76 |---------|----------| ··· 65 83 | `play_track` | insert track after current, jump to it, auto-play | 66 84 | `set_index` | jump to specific track index | 67 85 | `remove_track` | remove track, adjust `current_index` if needed | 86 + | `set_output` | set `output_client_id` to sender's client_id (validated server-side) | 68 87 69 88 ## WebSocket protocol 70 89 71 90 client → server: 72 - - `{type: "sync", last_id: string | null}` — initial sync / reconnect 91 + - `{type: "sync", last_id: string | null, client_id: string}` — initial sync / reconnect (client_id identifies this tab) 73 92 - `{type: "command", payload: {type: "play" | "pause" | "seek" | ..., ...}}` — playback commands 74 93 - `{type: "ping"}` — heartbeat 75 94 ··· 133 152 134 153 ### Player.svelte effects 135 154 136 - Player.svelte still imports `jam` directly for incoming state sync. four jam-related effects: 155 + Player.svelte still imports `jam` directly for incoming state sync. key jam-related effects: 137 156 138 - 1. **track sync** — when `jam.active && jam.currentTrack`, drives player track loading from jam state instead of queue 139 - 2. **pause sync** — watches `jam.isPlaying`, sets `player.paused` accordingly. uses `untrack()` to avoid running every frame 140 - 3. **drift correction** — watches `jam.interpolatedProgressMs`, seeks if audio element is >2s off. uses `untrack()` 141 - 4. **position save skip** — when `jam.active`, skips saving playback position to server (jam owns it) 157 + 1. **paused-state-sync** — gates on `jam.isOutputDevice`; only the output device calls `audioElement.play()`/`.pause()`. non-output clients' audio stays silent 158 + 2. **track sync** — when `jam.active && jam.currentTrack`, drives player track loading from jam state instead of queue. sets `shouldAutoPlay` gated on `jam.isOutputDevice` 159 + 3. **play/pause sync** — watches `jam.isPlaying`, sets `player.paused` accordingly. reads `isLoadingTrack` outside `untrack()` so it re-runs after loading 160 + 4. **drift correction** — output device only. watches `jam.interpolatedProgressMs`, seeks if audio element is >2s off 161 + 5. **non-output progress** — non-output clients interpolate progress bar from jam state (250ms interval) 162 + 6. **position save skip** — when `jam.active`, skips saving playback position to server (jam owns it) 142 163 143 164 ### join flow 144 165 ··· 170 191 - **reconnect**: exponential backoff (1s → 30s), sync from last stream ID, drift correction 171 192 - **page refresh**: layout detects active jam via `GET /jams/active` and reconnects 172 193 - **simultaneous commands**: server-authoritative, serialized via `SELECT ... FOR UPDATE` row locking 173 - - **gated tracks**: each client resolves audio independently. non-supporters get toast + skip 194 + - **gated tracks**: output device resolves audio. non-supporters get toast + skip 195 + - **output device refresh**: old WS closes → server clears output + pauses. new WS connects → auto-set restores output to host 174 196 175 197 ## known issues and follow-ups 176 198 ··· 192 214 | `backend/src/backend/models/jam.py` | Jam, JamParticipant SQLAlchemy models | 193 215 | `backend/src/backend/_internal/jams.py` | JamService — commands, state management, Redis Streams | 194 216 | `backend/src/backend/api/jams.py` | REST + WS endpoints, request/response models | 195 - | `backend/tests/api/test_jams.py` | 23 tests covering CRUD, commands, lifecycle, auth, DID socket replacement | 217 + | `backend/tests/api/test_jams.py` | tests covering CRUD, commands, lifecycle, auth, DID socket replacement, output device | 196 218 | `frontend/src/lib/jam.svelte.ts` | JamState singleton — WebSocket, bridge registration | 197 219 | `frontend/src/lib/queue.svelte.ts` | JamBridge interface, bridge routing in queue methods | 198 220 | `frontend/src/lib/components/Queue.svelte` | Jam UI (participants, share, leave, rainbow border) |
+85 -2
frontend/src/lib/components/Queue.svelte
··· 6 6 import { jam } from '$lib/jam.svelte'; 7 7 import { toast } from '$lib/toast.svelte'; 8 8 import { JAMS_FLAG } from '$lib/config'; 9 - import type { Track } from '$lib/types'; 9 + import type { Track, JamParticipant } from '$lib/types'; 10 10 11 11 let draggedIndex = $state<number | null>(null); 12 12 let dragOverIndex = $state<number | null>(null); ··· 53 53 return tracks 54 54 .map((track, index) => ({ track, index })) 55 55 .filter(({ index }) => index > currentIdx); 56 + }); 57 + 58 + const outputParticipant = $derived.by<JamParticipant | null>(() => { 59 + if (!jam.active || !jam.outputDid) return null; 60 + return jam.participants.find((p) => p.did === jam.outputDid) ?? null; 56 61 }); 57 62 58 63 function handleTrackClick(index: number) { ··· 162 167 <div class="jam-meta"> 163 168 <span class="connection-dot" class:connected={jam.connected} class:reconnecting={jam.reconnecting}></span> 164 169 <span class="jam-code">{jam.code}</span> 170 + {#if jam.outputClientId} 171 + <span class="output-status"> 172 + {#if jam.isOutputDevice} 173 + <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> 174 + playing here 175 + {:else} 176 + <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> 177 + {outputParticipant ? `playing on ${outputParticipant.display_name ?? outputParticipant.handle}` : 'playing elsewhere'} 178 + {/if} 179 + </span> 180 + {:else} 181 + <span class="output-status no-output">no output</span> 182 + {/if} 165 183 </div> 166 184 </div> 167 185 <div class="queue-actions"> 186 + {#if !jam.isOutputDevice} 187 + <button class="output-btn" onclick={() => jam.setOutput()} title="play audio on this device"> 188 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 189 + <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon> 190 + <path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path> 191 + <path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path> 192 + </svg> 193 + </button> 194 + {/if} 168 195 <button class="share-btn" onclick={shareJam} title="share jam link"> 169 196 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 170 197 <circle cx="18" cy="5" r="3"></circle> ··· 229 256 {#if jam.active && jam.participants.length > 0} 230 257 <div class="participants-strip"> 231 258 {#each jam.participants as participant (participant.did)} 232 - <div class="participant-chip" title={participant.display_name ?? participant.handle}> 259 + <div class="participant-chip" class:is-output={participant.did === jam.outputDid} title={participant.display_name ?? participant.handle}> 233 260 {#if participant.avatar_url} 234 261 <img src={participant.avatar_url} alt="" class="participant-avatar" /> 235 262 {:else} ··· 237 264 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 238 265 <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> 239 266 <circle cx="12" cy="7" r="4"></circle> 267 + </svg> 268 + </div> 269 + {/if} 270 + {#if participant.did === jam.outputDid} 271 + <div class="speaker-badge"> 272 + <svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor" stroke="none"> 273 + <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon> 240 274 </svg> 241 275 </div> 242 276 {/if} ··· 464 498 465 499 .participant-chip { 466 500 flex-shrink: 0; 501 + position: relative; 467 502 } 468 503 469 504 .participant-avatar { ··· 835 870 836 871 .queue-tracks::-webkit-scrollbar-thumb:hover { 837 872 background: var(--border-emphasis); 873 + } 874 + 875 + .output-status { 876 + display: inline-flex; 877 + align-items: center; 878 + gap: 0.25rem; 879 + font-size: var(--text-xs); 880 + color: var(--text-tertiary); 881 + } 882 + 883 + .output-status.no-output { 884 + color: var(--text-muted); 885 + } 886 + 887 + .output-btn { 888 + display: flex; 889 + align-items: center; 890 + justify-content: center; 891 + width: 32px; 892 + height: 32px; 893 + padding: 0; 894 + background: transparent; 895 + border: 1px solid var(--border-subtle); 896 + color: var(--text-tertiary); 897 + border-radius: var(--radius-sm); 898 + cursor: pointer; 899 + transition: all 0.15s ease; 900 + } 901 + 902 + .output-btn:hover { 903 + color: var(--accent); 904 + border-color: var(--accent); 905 + background: color-mix(in srgb, var(--accent) 10%, transparent); 906 + } 907 + 908 + .speaker-badge { 909 + position: absolute; 910 + bottom: -2px; 911 + right: -2px; 912 + width: 14px; 913 + height: 14px; 914 + border-radius: var(--radius-full); 915 + background: var(--accent); 916 + color: var(--bg-primary); 917 + display: flex; 918 + align-items: center; 919 + justify-content: center; 920 + border: 1.5px solid var(--bg-primary); 838 921 } 839 922 </style>
+110 -11
frontend/src/lib/components/player/Player.svelte
··· 444 444 } 445 445 }); 446 446 447 - // sync paused state with audio element 447 + // sync paused state with audio element (output device only — non-output stays silent) 448 448 $effect(() => { 449 449 if (!player.audioElement || isLoadingTrack) return; 450 + 451 + // non-output jam clients: always pause audio (handles output transfer) 452 + if (jam.active && !jam.isOutputDevice) { 453 + player.audioElement.pause(); 454 + return; 455 + } 450 456 451 457 if (player.paused) { 452 458 player.audioElement.pause(); 453 459 } else { 454 - player.audioElement.play().catch(err => { 455 - console.error('playback failed:', err); 460 + player.audioElement.play().catch((err) => { 461 + console.error('[player] playback failed:', err.name, err.message); 456 462 player.paused = true; 457 463 }); 458 464 } ··· 468 474 const trackChanged = jam.currentTrack.id !== player.currentTrack?.id; 469 475 if (trackChanged) { 470 476 player.currentTrack = jam.currentTrack; 471 - shouldAutoPlay = jam.isPlaying; 477 + shouldAutoPlay = jam.isPlaying && jam.isOutputDevice; 472 478 } 473 479 return; 474 480 } ··· 500 506 // auto-play when track finishes loading 501 507 $effect(() => { 502 508 if (shouldAutoPlay && !isLoadingTrack) { 509 + // if jam paused while track was loading, respect that 510 + if (jam.active && !jam.isPlaying) { 511 + shouldAutoPlay = false; 512 + return; 513 + } 503 514 player.paused = false; 504 515 shouldAutoPlay = false; 505 516 } 506 517 }); 507 518 508 - // sync play/pause from jam state (any participant can play/pause) 509 - // only runs when jam play state changes (from WS messages) 519 + // sync play/pause from jam state (all participants — UI reflects jam state) 520 + // audio element gating is handled separately above 521 + // NOTE: isLoadingTrack must be tracked (outside untrack) so this re-runs after loading 510 522 $effect(() => { 511 523 if (!jam.active) return; 512 524 const jamPlaying = jam.isPlaying; 513 525 const jamTrackId = jam.currentTrack?.id; 526 + const loading = isLoadingTrack; 514 527 untrack(() => { 515 - if (isLoadingTrack) return; 528 + // if paused while loading, cancel pending auto-play so it doesn't override 529 + if (!jamPlaying) shouldAutoPlay = false; 530 + if (loading) return; 516 531 if (!jamTrackId || jamTrackId !== player.currentTrack?.id) return; 517 532 player.paused = !jamPlaying; 518 533 }); 519 534 }); 520 535 521 - // jam drift correction: seek if >2s off from server 536 + // jam drift correction: seek if >2s off from server (output device only) 522 537 // only runs when jam state changes (progressMs/serverTimeMs from WS), not every frame 523 538 $effect(() => { 524 539 if (!jam.active) return; 540 + if (!jam.isOutputDevice) return; 525 541 // track jam state as dependencies (these change on WS messages) 526 542 const serverPos = jam.interpolatedProgressMs / 1000; 527 543 const jamTrackId = jam.currentTrack?.id; ··· 536 552 }); 537 553 }); 538 554 555 + // non-output jam clients: sync progress bar from jam state 556 + // seeks the (paused, silent) audio element so PlaybackControls' rAF picks it up 557 + $effect(() => { 558 + if (!jam.active || jam.isOutputDevice) return; 559 + if (!player.audioElement) return; 560 + // snap position on state changes (pause, seek, track change) 561 + const pos = jam.interpolatedProgressMs / 1000; 562 + if (player.audioElement.readyState >= 1) { 563 + player.audioElement.currentTime = pos; 564 + } 565 + if (!jam.isPlaying) return; 566 + // while playing, smoothly interpolate between state updates 567 + const interval = window.setInterval(() => { 568 + if (player.audioElement && player.audioElement.readyState >= 1) { 569 + player.audioElement.currentTime = jam.interpolatedProgressMs / 1000; 570 + } 571 + }, 250); 572 + return () => window.clearInterval(interval); 573 + }); 574 + 539 575 function handleTrackEnded() { 540 576 if (!queue.autoAdvance) { 541 577 player.reset(); ··· 555 591 </script> 556 592 557 593 {#if player.currentTrack} 558 - <div class="player"> 594 + <div class="player" class:jam-active={jam.active}> 559 595 <audio 560 596 bind:this={player.audioElement} 561 597 bind:currentTime={player.currentTime} 562 598 bind:duration={player.duration} 563 599 bind:volume={player.volume} 564 - onplay={() => player.paused = false} 565 - onpause={() => player.paused = true} 600 + onplay={() => { if (!jam.active) player.paused = false; }} 601 + onpause={() => { if (!jam.active) player.paused = true; }} 566 602 onended={handleTrackEnded} 567 603 ></audio> 568 604 605 + {#if jam.active} 606 + <div class="jam-stripe-label"> 607 + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>{#if jam.isOutputDevice}<path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path>{/if}</svg> 608 + {#if jam.isOutputDevice} 609 + playing here 610 + {:else if jam.outputClientId} 611 + playing elsewhere 612 + <button class="play-here-pill" onclick={() => jam.setOutput()}>play here</button> 613 + {:else} 614 + <span class="muted">no output</span> 615 + <button class="play-here-pill" onclick={() => jam.setOutput()}>play here</button> 616 + {/if} 617 + </div> 618 + {/if} 619 + 569 620 <div class="player-content"> 570 621 <TrackInfo 571 622 track={player.currentTrack} ··· 609 660 grid-template-columns: minmax(160px, 360px) minmax(0, 1fr); 610 661 gap: 1rem; 611 662 } 663 + } 664 + 665 + .player.jam-active { 666 + border-top: 2px solid transparent; 667 + border-image: linear-gradient(90deg, #ff6b6b, #ffd93d, #6bcb77, #4d96ff, #9b59b6, #ff6b6b) 1; 668 + border-image-width: 2px 0 0 0; 669 + } 670 + 671 + .jam-stripe-label { 672 + position: absolute; 673 + top: 0; 674 + left: 50%; 675 + transform: translate(-50%, -50%); 676 + background: var(--glass-bg, var(--bg-tertiary)); 677 + border: 1px solid var(--border-subtle); 678 + border-radius: var(--radius-full); 679 + padding: 0.1rem 0.5rem; 680 + font-size: var(--text-xs); 681 + color: var(--text-tertiary); 682 + white-space: nowrap; 683 + display: inline-flex; 684 + align-items: center; 685 + gap: 0.3rem; 686 + z-index: 1; 687 + backdrop-filter: var(--glass-blur, none); 688 + -webkit-backdrop-filter: var(--glass-blur, none); 689 + } 690 + 691 + .jam-stripe-label .muted { 692 + color: var(--text-muted); 693 + } 694 + 695 + .play-here-pill { 696 + padding: 0 0.375rem; 697 + font-size: var(--text-xs); 698 + font-family: inherit; 699 + background: transparent; 700 + border: none; 701 + border-left: 1px solid var(--border-subtle); 702 + color: var(--text-tertiary); 703 + cursor: pointer; 704 + transition: color 0.15s ease; 705 + margin-left: 0.125rem; 706 + padding-left: 0.375rem; 707 + } 708 + 709 + .play-here-pill:hover { 710 + color: var(--accent); 612 711 } 613 712 614 713 @media (max-width: 768px) {
+40 -3
frontend/src/lib/jam.svelte.ts
··· 18 18 revision = $state(0); 19 19 connected = $state(false); 20 20 reconnecting = $state(false); 21 + clientId = $state<string>(''); 22 + outputClientId = $state<string | null>(null); 23 + outputDid = $state<string | null>(null); 21 24 22 25 private ws: WebSocket | null = null; 23 26 private lastStreamId: string | null = null; ··· 26 29 private visibilityHandler: (() => void) | null = null; 27 30 private currentCode: string | null = null; 28 31 32 + constructor() { 33 + if (browser) { 34 + this.clientId = 35 + sessionStorage.getItem('jam_client_id') ?? crypto.randomUUID(); 36 + sessionStorage.setItem('jam_client_id', this.clientId); 37 + } 38 + } 39 + 29 40 get currentTrack(): Track | null { 30 41 if (this.tracks.length === 0) return null; 31 42 return this.tracks[this.currentIndex] ?? null; ··· 38 49 39 50 get code(): string | null { 40 51 return this.jam?.code ?? null; 52 + } 53 + 54 + get isOutputDevice(): boolean { 55 + return this.clientId !== '' && this.clientId === this.outputClientId; 41 56 } 42 57 43 58 private createBridge(): JamBridge { ··· 207 222 this.sendCommand({ type: 'set_index', index }); 208 223 } 209 224 225 + setOutput(): void { 226 + this.sendCommand({ type: 'set_output', client_id: this.clientId }); 227 + } 228 + 210 229 private sendCommand(payload: Record<string, unknown>): void { 211 - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; 230 + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { 231 + console.warn('[jam] command dropped — ws not open:', payload, { 232 + ws: !!this.ws, 233 + readyState: this.ws?.readyState 234 + }); 235 + return; 236 + } 212 237 this.ws.send(JSON.stringify({ type: 'command', payload })); 213 238 } 214 239 ··· 236 261 this.ws.onopen = () => { 237 262 this.connected = true; 238 263 this.reconnectDelay = RECONNECT_BASE_MS; 239 - // request sync 264 + // request sync with client identity 240 265 this.ws?.send( 241 - JSON.stringify({ type: 'sync', last_id: this.lastStreamId }) 266 + JSON.stringify({ 267 + type: 'sync', 268 + last_id: this.lastStreamId, 269 + client_id: this.clientId 270 + }) 242 271 ); 243 272 }; 244 273 ··· 253 282 254 283 this.ws.onclose = (event) => { 255 284 this.connected = false; 285 + console.warn('[jam] ws closed:', { code: event.code, reason: event.reason }); 256 286 // terminal codes: server rejected us, don't retry 257 287 if (event.code === 4003 || event.code === 4010) { 288 + console.warn('[jam] terminal close — leaving jam (code %d: %s)', event.code, event.reason); 258 289 this.closeWs(); 259 290 this.reset(); 260 291 queue.setJamBridge(null); ··· 337 368 this.isPlaying = state.is_playing; 338 369 this.progressMs = state.progress_ms; 339 370 this.serverTimeMs = state.server_time_ms; 371 + this.outputClientId = state.output_client_id ?? null; 372 + this.outputDid = state.output_did ?? null; 340 373 341 374 if (data.tracks_changed && Array.isArray(data.tracks)) { 342 375 this.tracks = data.tracks as Track[]; ··· 372 405 this.isPlaying = state.is_playing; 373 406 this.progressMs = state.progress_ms; 374 407 this.serverTimeMs = state.server_time_ms; 408 + this.outputClientId = state.output_client_id ?? null; 409 + this.outputDid = state.output_did ?? null; 375 410 376 411 this.syncToQueue(); 377 412 } ··· 393 428 this.serverTimeMs = 0; 394 429 this.revision = 0; 395 430 this.lastStreamId = null; 431 + this.outputClientId = null; 432 + this.outputDid = null; 396 433 } 397 434 398 435 destroy(): void {
+9 -5
frontend/src/lib/queue.svelte.ts
··· 435 435 play(): void { 436 436 if (this.jamBridge) { 437 437 this.jamBridge.play(); 438 - } else { 439 - player.paused = false; 440 438 } 439 + // always set synchronously — in jam mode this ensures audioElement.play() 440 + // fires within the user gesture context (non-output devices are gated in 441 + // Player.svelte's paused-state-sync effect) 442 + player.paused = false; 441 443 } 442 444 443 445 pause(): void { 444 446 if (this.jamBridge) { 445 447 this.jamBridge.pause(); 446 - } else { 447 - player.paused = true; 448 448 } 449 + player.paused = true; 449 450 } 450 451 451 452 togglePlayPause(): void { ··· 538 539 } 539 540 540 541 next() { 541 - if (this.tracks.length === 0) return; 542 + if (this.tracks.length === 0) { 543 + console.warn('[queue.next] tracks empty, bailing'); 544 + return; 545 + } 542 546 543 547 if (this.jamBridge) { 544 548 this.jamBridge.next();
+2
frontend/src/lib/types.ts
··· 170 170 is_playing: boolean; 171 171 progress_ms: number; 172 172 server_time_ms: number; 173 + output_client_id?: string | null; 174 + output_did?: string | null; 173 175 } 174 176
+5 -5
loq.toml
··· 23 23 24 24 [[rules]] 25 25 path = "backend/src/backend/_internal/jams.py" 26 - max_lines = 730 26 + max_lines = 820 27 27 28 28 [[rules]] 29 29 path = "backend/src/backend/api/albums.py" ··· 71 71 72 72 [[rules]] 73 73 path = "backend/tests/api/test_jams.py" 74 - max_lines = 590 74 + max_lines = 1057 75 75 76 76 [[rules]] 77 77 path = "backend/tests/api/test_track_comments.py" ··· 111 111 112 112 [[rules]] 113 113 path = "frontend/src/lib/components/Queue.svelte" 114 - max_lines = 870 114 + max_lines = 922 115 115 116 116 [[rules]] 117 117 path = "frontend/src/lib/components/SearchModal.svelte" ··· 131 131 132 132 [[rules]] 133 133 path = "frontend/src/lib/components/player/Player.svelte" 134 - max_lines = 640 134 + max_lines = 725 135 135 136 136 [[rules]] 137 137 path = "frontend/src/lib/queue.svelte.ts" 138 - max_lines = 725 138 + max_lines = 729 139 139 140 140 [[rules]] 141 141 path = "frontend/src/routes/+layout.svelte"