feat(music): W22 shared listening + release 0.6.0

Personal playlist on a dedicated music player (browse/play/prev/next/
seek/volume/reorder/remove, .pls/.m3u import), plus per-person shared
listening: broadcast your track over presence, peers tune in and stream
it point-to-point over the files plane. Playback is timeline-synced
(play/pause/skip/seek mirror with no drift) with gapless prefetch of the
next track and independent per-source volume per listener.

In the 3-column layout the playlist gets its own card stacked under the
chat, with a resizable divider and its own scrollbar; other layouts keep
it in the Controls panel.

Breaking wire change: gossip protocol v5 (presence gains music fields),
so 0.6.0 peers cannot share a swarm with 0.5.x. Version bumped 0.5.1 ->
0.6.0; CHANGELOG updated.

Untrusted-input handling: broadcast track name sanitized and size
cap-checked at gossip ingest, fetched bytes confirmed audio before
decode, only the descriptor rides gossip (bytes go point-to-point, one
fetch in flight).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 06:04:31 -04:00
co-authored by Claude Opus 4.8
parent c2e27c3367
commit bca2ccd6a4
15 changed files with 1440 additions and 28 deletions
+58
View File
@@ -22,6 +22,38 @@ pub enum NetError {
Other(String),
}
/// A peer's currently-broadcast music track + playback timeline (W22). Rides
/// gossip presence so listeners can tune in, follow track changes, and keep in
/// sync. Untrusted like `name`/`game`: the `name` is sanitized and `size` is
/// cap-checked at gossip ingest. Bytes never ride gossip — they are fetched
/// point-to-point over the files plane by `id`, exactly like a chat attachment.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MusicPresence {
/// Files-plane handle to fetch this track's bytes (minted per track by the DJ).
pub id: crate::files::AttachmentId,
/// Sanitized display name (track filename). Untrusted; cleaned at ingest.
pub name: String,
/// Byte length, bounds the listener's fetch. Must be `<= MAX_ATTACHMENT_BYTES`.
pub size: u64,
/// True while the DJ has the track paused.
pub paused: bool,
/// Wall-clock ms (UNIX epoch) of the timeline anchor. While playing, the true
/// playhead is `position_ms + (now_ms - anchor_ms)`; while paused it is
/// frozen at `position_ms`. Re-stamped on every play/pause/seek.
pub anchor_ms: u64,
/// Playhead position (ms) at `anchor_ms`.
pub position_ms: u64,
/// Files-plane handle for the DJ's NEXT track, so listeners can prefetch it
/// for a gapless skip. `None` when there is no distinct next track (single
/// item playlist) or the DJ isn't ready. Equals a future `id` once that
/// track plays.
#[serde(default)]
pub next_id: Option<crate::files::AttachmentId>,
/// Byte length of the next track; bounds the prefetch. Cap-checked at ingest.
#[serde(default)]
pub next_size: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PeerState {
pub name: String,
@@ -48,6 +80,10 @@ pub struct PeerState {
/// Defaulted so peers/configs predating the field still deserialize.
#[serde(default)]
pub game: Option<String>,
/// This peer's currently-broadcast music track and playback timeline, or
/// `None` when not broadcasting. Defaulted so pre-W22 peers deserialize.
#[serde(default)]
pub music: Option<MusicPresence>,
}
/// The locally-owned, "sticky" pieces of our own presence: the identity fields
@@ -70,6 +106,8 @@ pub struct SelfPresence {
/// (see `crate::sanitize::sanitize_game_label`) before being stored here, so
/// the outgoing announce carries a safe value.
pub game: Option<String>,
/// Our current broadcast timeline, or `None` when not broadcasting / not playing.
pub music: Option<MusicPresence>,
}
impl SelfPresence {
@@ -89,6 +127,7 @@ impl SelfPresence {
sharing,
avatar: self.avatar.clone(),
game: self.game.clone(),
music: self.music.clone(),
}
}
}
@@ -307,6 +346,7 @@ mod tests {
sharing: None,
avatar: crate::avatar::Avatar::default(),
game: None,
music: None,
}
}
@@ -422,6 +462,7 @@ mod tests {
name: "Alice".to_string(),
avatar: crate::avatar::Avatar::default(),
game: Some("Half-Life 2".to_string()),
music: None,
};
// Volatile fields come from the call; sticky fields from the struct.
let muted = presence.to_state(true, addr.clone(), Some("ticket".to_string()));
@@ -445,4 +486,21 @@ mod tests {
let deserialized: PeerState = serde_json::from_str(&serialized).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn music_presence_serde_round_trip() {
let original = MusicPresence {
id: [3u8; 32],
name: "track.ogg".to_string(),
size: 1234,
paused: false,
anchor_ms: 1_700_000_000_000,
position_ms: 42_000,
next_id: None,
next_size: None,
};
let serialized = serde_json::to_string(&original).unwrap();
let deserialized: MusicPresence = serde_json::from_str(&serialized).unwrap();
assert_eq!(original, deserialized);
}
}