| name | beadline-room |
| description | Domain knowledge and architecture for the beadline room design — host-authority playback sync, cast status mirroring to guests, and live-recast on playback settings change. Covers the Rust sharing protocol, the host broadcast loop, and the Dart room/cast view-model plumbing. |
What I Do
- Document the room architecture: one host, N guests, host-authoritative playback.
- Explain how casting obeys the room design (cast is a host-side concern; guests only see a status mirror).
- Document the
CastStatusSync message and the guest "host is casting" placeholder flow.
- Document the settings-change → live recast pipeline (audio mode / lyric mode apply to an active cast without a restart).
- Enforce these design constraints when changes are proposed.
Design Principles
- Host authority: Playback state lives on the host device. Guests never drive the media pipeline; they send
PlaybackActions and receive state mirrors.
- Casting is host-only: Only the host runs the cast session (
CastSession in beadline-cast). Guests are notified about the cast via a status mirror, but never connect to the TV themselves.
- Push, don't poll: The host pushes state to guests on a fixed 2 s timer (
_roomSyncTimer in home_page.dart). Guests apply messages to a single RoomState and rebuild.
- One-way settings flow: Playback settings (audio source, accompaniment mode, lyric mode) are chosen by the host. A change while casting triggers a recast — the transcode pipeline restarts with new arguments and the TV playback resumes from its current position.
- Wire shape is the contract: Protocol messages use a snake_case
messageType tag, camelCase fields, and skip_serializing_if semantics on the Rust side. Dart fromJson must tolerate omitted optional fields (Rust Option::None is skipped, not null).
Message Protocol (lib/models/sharing_protocol.dart)
All messages extend SharingMessage with a static messageType. Wire: {"type": "...", ...fields}.
| Class | messageType | Direction | Purpose |
|---|
HandshakeRequest | handshake_request | guest→host | Join request with display name |
HandshakeAck | handshake_ack | host→guest | Accepts/rejects, carries permissions + host name |
CatalogRequest/Response/Update | catalog_* | both | Library catalog sync (superseded by UnionLibraryUpdate in Rust; removed from Dart) |
QueueChange | queue_change | host→guest | Queue mutation notice |
QueueSync | queue_sync | host→guest | Full queue state |
PlaybackSync | playback_sync | host→guest | Required fields: queueIndex, positionMs, durationMs, isPlaying, device, updatedAt |
CastStatusSync | cast_status_sync | host→guest | Host cast mirror: active, deviceName?, phase?, playerState?, positionMs?, durationMs?, isPlaying?, timestamp |
PlaybackAction | playback_action | guest→host | play/pause/seek/next/prev |
Heartbeat / HeartbeatAck | heartbeat* | both | Liveness |
SessionModeChange | session_mode_change | host→guest | host/guest mode switch |
RoomSettings / |
SharingMessage.fromJson returns null for unknown type values — old clients degrade gracefully when new messages appear.
CastStatusSync (host → guests)
- Rust:
CastStatusSync variant in crates/beadline-sharing/src/protocol.rs (serde rename cast_status_sync; deviceName/playerState skip when None).
- Dart: class in
sharing_protocol.dart, re-exported from lib/sharing/sharing_layer.dart.
- Published by the host in
HomePage._broadcastPlaybackToGuests() — the same 2 s timer as PlaybackSync — mirroring CastViewModel.snapshot (deviceName, phase.name, playerState?.name, position/duration, isPlaying == CastPlayerState.playing).
- Inactive cast →
active: false message, which clears the guest placeholder.
Guest rendering
RoomState carries hostCastActive, hostCastDeviceName, hostCastPhase, hostCastPositionMs, hostCastDurationMs, hostCastIsPlaying — preserved through bump() and withRemotePlayback().
DisplayScreen video branch: room.hostCastActive → _buildHostCastingPlaceholder (cast icon + hostCasting i18n key + device name + phase label via _hostCastPhaseLabel, an opaque-string switch defaulting to idle).
Settings Change → Live Recast (host side)
Flow in lib/player/cast_view_model.dart:
ref.listen(roomViewModelProvider, ...) → _maybeRecastOnSettingsChange() on every RoomState bump.
- Build a 3-part signature from the synchronous Rust cache —
getRoomSync().settings (lib/room/room_state.dart): audioMode | lyricsMode.name | sourceSelection. RoomState itself has no settings; the cache is authoritative.
- Diff against
_lastCastSettingsSignature; if a cast is active and the signature changed → _startCast(preserveTvState: true, startAtMs: currentTvPosition).
_startCast gates lyrics (LyricsMode.off → null lyrics; otherwise parsed document, burned-in via ASS or WebVTT) and passes audioPathOrUrl into CastMedia and _startAndroidTranscode (via AndroidTranscodeState.audioSource).
Audio remap semantics (local files only)
- Condition: local video (
kind == 'local', hasVideo) with a separate audio file (audioPathOrUrl from _castAudioPathOrUrl() in player_engine.dart, local/url origin, must differ from the display path).
- Android: remap forces a transcode via FFmpegKit (
buildArgs with a second -i, its own -ss, and -map 1:a:0? when remapping). Remote URLs fall back to the device's native audio — no remap.
- Desktop/other: direct AV1 remux (
beadline-cast transcode pipeline).
- No FFmpegKit available → remap dropped with a warning.
- Both
buildArgs and restartAt accept the audioSource parameter; the ffmpeg -vf filter line must precede output options (mirrors Rust build_command).
Rust Cast Semantics (crates/beadline-cast)
HlsTranscoder::start(ffmpeg, src, dir, None, Some(audio)) — optional second input for audio remap; per-input -ss keeps audio aligned with video after seek.
seek_to restarts the pipeline when the target is outside the generated window.
CastStatusSync is a protocol concern (beadline-sharing), not a cast concern — the host's Dart layer mirrors the CastSession snapshot into it.
Constraints When Changing
- Never hand-edit
frb_generated.*; after Rust signature changes run flutter_rust_bridge_codegen generate (needs D:\flutter\bin on PATH).
- i18n: edit only
.i18n.json, regenerate with dart run slang build.
- Protocol changes: keep Rust serde rename snake_case, keep optional-field skipping, and keep Dart
fromJson tolerant of absent fields (backward compatibility with older room peers).
- New settings that affect the transcode/cast must extend the settings signature in
_maybeRecastOnSettingsChange — otherwise changes won't apply to an active cast.
RoomState must preserve hostCast* fields through both bump() and withRemotePlayback() (easy to drop on copyWith changes).
- Guest UI must not reach into cast internals; it only reads
RoomState.hostCast* fields.
Encapsulation Rule (enforced)
The rest of the app never asks "am I in a remote room" — it asks two facade questions on RoomNotifier:
- "May I act?" — the effective capability getters:
canControlPlayback, canSkip, canSeek, canAddToQueue, canRemoveFromQueue, canRearrangeQueue, canViewLibrary, canViewPlaylists, canShuffleQueue, canUseRemoveAfterPlay. Host/local → always true; guest → the host's grant (myPermissions).
- "Where does the action land?" — the routing ops:
sendPlaybackAction, playQueueEntry, removeQueueEntry, reorderQueue, seekTo, syncSettings. Host/local → drives the local engine/Rust state; guest → sends the corresponding PlaybackAction/QueueChange/SettingsChange to the host. Call sites never branch on role.
- "What is my playback surface?" —
canPlayLocally (host/local: true, guest: false; the guest's engine is only a mirror) and isViewingRemoteCatalog (guest shows the host's catalog).
Direct reads of isGuest / isHosting / isLocalRoom / myPermissions / activeRoomState are restricted to:
lib/sharing/** (the room layer itself) and lib/room/** (the Rust cache)
lib/views/home_page.dart, lib/sharing/room_join_view.dart, lib/settings/settings_section_room.dart (the room UX shell)
lib/player/display_screen.dart may read RoomState.hostCast* only (notification mirror, never to drive behavior)
Everything else — player widgets, queue view, library view, settings sheet, source selector — must use the capability getters and routing ops. Verify with: rg -n "\.isGuest|\.isHosting|\.myPermissions|activeRoomState" lib (allowlisted files only may appear).