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
+23 -10
View File
@@ -594,17 +594,21 @@ impl IrohTransport {
self.shared.served_files.lock().unwrap().insert(id, bytes);
}
/// Fetch a chat attachment's bytes from its sender over the file plane. Dials
/// the sender on `FILES_ALPN` (preferring a known full address), writes the
/// 32-byte id, and reads the response bounded by the descriptor's declared
/// size (which the caller has already validated against the global cap). The
/// read limit means a malicious sender can't stream us more than advertised.
pub async fn fetch_attachment(
/// Drop a previously-served blob (e.g. a music track no longer current-or-next).
pub fn forget_attachment(&self, id: AttachmentId) {
self.shared.served_files.lock().unwrap().remove(&id);
}
/// Fetch `size` bytes stored under `id` from peer `from` over the files plane.
/// Shared core of `fetch_attachment` and music-track fetching: dials
/// `FILES_ALPN`, writes the 32-byte id, and reads bounded by `size`.
pub async fn fetch_blob(
&self,
from: EndpointId,
att: &ChatAttachment,
id: AttachmentId,
size: u64,
) -> Result<Vec<u8>, NetError> {
if !crate::files::size_within_cap(att.size) {
if !crate::files::size_within_cap(size) {
return Err(NetError::Other("attachment size out of range".to_string()));
}
let addr = self.shared.addrs.lock().unwrap().get(&from).cloned();
@@ -623,13 +627,13 @@ impl IrohTransport {
.open_bi()
.await
.map_err(|e| NetError::Other(format!("file fetch: open stream failed: {e}")))?;
send.write_all(&att.id)
send.write_all(&id)
.await
.map_err(|e| NetError::Other(format!("file fetch: request write failed: {e}")))?;
send.finish()
.map_err(|e| NetError::Other(format!("file fetch: request finish failed: {e}")))?;
let read = recv.read_to_end(att.size as usize);
let read = recv.read_to_end(size as usize);
let bytes = tokio::time::timeout(FILE_FETCH_TIMEOUT, read)
.await
.map_err(|_| NetError::Other("file fetch: read timed out".to_string()))?
@@ -639,6 +643,15 @@ impl IrohTransport {
}
Ok(bytes)
}
/// Fetch a chat attachment's bytes from its sender over the file plane.
pub async fn fetch_attachment(
&self,
from: EndpointId,
att: &ChatAttachment,
) -> Result<Vec<u8>, NetError> {
self.fetch_blob(from, att.id, att.size).await
}
}
#[async_trait]