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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user