diff --git a/CHANGELOG.md b/CHANGELOG.md index 44cbb83..90a69be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to PeerSpeak are documented here. +## [0.6.0] — 2026-06-28 + +### Added +- **Shared music listening (W22).** A new **Playlist** panel lets you build a personal queue of local audio files and play them on a dedicated music player — Browse to add tracks, play/pause, previous/next, seek, per-track reorder, remove, and a local volume slider, all persisted across sessions. `.pls` and `.m3u` playlists can be imported (remote and non-audio entries are skipped). +- **Tune in to a friend's music.** Flip **"Let others tune in"** and peers see your current track under the **Public** tab; one click on **Listen** streams it to them. Playback is **timeline-synced** — play, pause, skip, and seek mirror across everyone with no drift — and the next track is **prefetched for gapless** transitions. Each listener gets an independent **per-source volume**, so music sits under voice at whatever level they like; voice chat stays fully audible throughout. +- **Standalone Playlist card in the 3-Column layout.** The playlist now lives in its own card stacked under the chat, with a draggable divider to resize it and its own scrollbar when space is tight. The other layouts keep the playlist in the Controls panel. + +### Security +- Shared-music metadata is treated as untrusted: the broadcast track name is sanitized and its size is cap-checked at gossip ingest, fetched bytes are confirmed to be audio before decoding, and only a small descriptor ever rides gossip — track bytes move point-to-point over the existing files plane, one fetch in flight at a time. + +### Changed +- **Wire protocol bump (gossip v5).** Shared listening adds presence fields, so **0.6.0 peers cannot share a swarm with 0.5.x peers** — everyone in a room must update together. + +[0.6.0]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.0 + ## [0.5.1] — 2026-06-27 ### Added diff --git a/Cargo.lock b/Cargo.lock index 528c49c..0660fe5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "peerspeak" -version = "0.5.1" +version = "0.6.0" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index acbb6c5..4125960 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "peerspeak" -version = "0.5.1" +version = "0.6.0" edition = "2024" description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)" # Application crate, not a crates.io library — refuse `cargo publish` and let diff --git a/src/app/mod.rs b/src/app/mod.rs index dc14de6..1352f7c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -237,6 +237,9 @@ pub enum DividerKind { /// Horizontal divider between the main row and the Chat dock (resizes the /// Chat dock height). Chat, + /// Horizontal divider between Chat and the standalone Playlist card in the + /// 3-column layout (resizes the Playlist card height). + ThreeColPlaylist, /// Vertical divider between Chat and Controls in the 3-column layout (resizes /// the Controls panel width). Controls, @@ -261,6 +264,11 @@ const CHAT_MIN_H: f32 = 110.0; /// Minimum height reserved above the Chat dock (header + main row) when resizing /// the dock (px). const ABOVE_CHAT_MIN_H: f32 = 300.0; +/// Minimum height of the standalone Playlist card in the 3-column layout (px). +const THREECOL_PLAYLIST_MIN_H: f32 = 150.0; +/// Minimum height reserved for the Chat above the Playlist card in the 3-column +/// layout when resizing the card (px). +const THREECOL_CHAT_MIN_H: f32 = 160.0; /// Thickness of a draggable divider (px). const DIVIDER_THICKNESS: f32 = 8.0; /// Upper bound for waiting on orderly core shutdown before letting the window exit. @@ -282,6 +290,13 @@ fn clamp_chat_height(height: f32, window_h: f32) -> f32 { height.clamp(CHAT_MIN_H, max) } +/// Clamp the 3-column Playlist card height so neither it nor the Chat above it +/// drops below its minimum, given the current window height. +fn clamp_threecol_playlist_height(height: f32, window_h: f32) -> f32 { + let max = (window_h - THREECOL_CHAT_MIN_H).max(THREECOL_PLAYLIST_MIN_H); + height.clamp(THREECOL_PLAYLIST_MIN_H, max) +} + /// Minimum width of the Chat column / drawer (px). const CHAT_MIN_W: f32 = 200.0; @@ -300,6 +315,7 @@ fn clamp_chat_drawer_width(width: f32, window_w: f32) -> f32 { width.clamp(CHAT_MIN_W, max) } +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone)] pub enum AppMessage { NicknameChanged(String), @@ -402,6 +418,36 @@ pub enum AppMessage { /// Toggle whether one universal level governs every clip (checked) or each /// clip keeps its own level (unchecked). ToggleUniversalClipVolume(bool), + /// Switch the music panel tab (Personal/Public). + MusicSelectTab(MusicTab), + /// Open the native multi-file picker to add audio files to the playlist. + MusicBrowse, + /// Picked audio file paths to append to the playlist (None = cancelled). + MusicFilesPicked(Option>), + /// Play the playlist track at this index. + MusicPlayIndex(usize), + /// Toggle play/pause of the current music track. + MusicPlayPause, + /// Advance to the next / previous playlist track. + MusicNext, + MusicPrev, + /// Seek the current music track to this 0.0..=1.0 fraction. + MusicSeek(f32), + /// Set the local music playback volume (and persist it). + MusicSetVolume(f32), + /// Set the tuned-in source's music playback volume (and persist it). + MusicSetSourceVolume(f32), + /// Remove the playlist track at this index. + MusicRemove(usize), + /// Move a personal playlist track one slot up/down. + MusicMoveUp(usize), + MusicMoveDown(usize), + /// Toggle whether our local playlist track is advertised for tune-in. + MusicToggleBroadcast(bool), + /// Tune the music sink into a peer's advertised music timeline. + MusicListen(EndpointId), + /// Stop listening to a peer's music timeline. + MusicStopListen, /// Redraw cadence while an inline clip is active. AudioTick, /// Send the current chat input line (Enter or the Send button). @@ -519,6 +565,13 @@ fn core_subscription() -> impl iced::futures::Stream { }) } +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ClockSkewBanner { skew_secs: u64, @@ -526,6 +579,20 @@ struct ClockSkewBanner { expires_at: std::time::Instant, } +/// One entry in the personal music playlist (Phase 1: a local file). +#[derive(Debug, Clone)] +struct MusicTrack { + /// Absolute path on disk. Persisted (as a String) in config. + path: std::path::PathBuf, + /// Display name derived from the file name. + name: String, +} + +/// Which music tab is shown. Phase 1: only `Personal` is functional; `Public` +/// is a stub placeholder for the Phase 2 tune-in view. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MusicTab { Personal, Public } + pub struct AppState { name: String, ticket_input: String, @@ -595,6 +662,35 @@ pub struct AppState { /// (`config.clip_volume_universal == false`). In-memory only; absent clips /// default to unity. Universal mode ignores this and uses `config.clip_volume`. clip_volumes: HashMap, + /// Dedicated player for the music playlist (separate from `clip_player`, which + /// serves chat-audio attachments, so the two never interrupt each other). + music_player: ClipPlayer, + music_status: SharedClipStatus, + /// Personal playlist, loaded from `config.music_playlist` at startup. + music_playlist: Vec, + /// Index into `music_playlist` of the track currently loaded in `music_player`. + music_current: Option, + /// True while a music track is meant to be playing — used to distinguish a + /// natural track end (auto-advance) from a user stop/pause in `AudioTick`. + music_active: bool, + /// Selected music tab. + music_tab: MusicTab, + /// Persisted preference: let others tune into our current personal track. + music_broadcasting: bool, + /// Files-plane id for the track we're currently serving/broadcasting. + music_broadcast_id: Option, + /// Files-plane id/size for the track currently advertised as upcoming. + music_broadcast_next: Option<(usize, crate::files::AttachmentId, u64)>, + /// Peer whose shared music timeline currently owns the music sink. + music_listening_to: Option, + /// Source track id currently loaded in the music sink. + music_listen_loaded: Option, + /// Source track id currently being fetched. + music_listen_inflight: Option, + /// Prefetched next-track bytes from the tuned-in source. + music_prefetch: Option<(crate::files::AttachmentId, Vec)>, + /// Source next-track id currently being prefetched. + music_prefetch_inflight: Option, /// Last known window size, tracked so divider clamps stay valid on resize. /// (The divider positions themselves are persisted in `config`.) window_size: Size, @@ -692,6 +788,16 @@ pub struct AppState { impl AppState { fn reset_room_state(&mut self) { self.clip_player.stop(); + self.music_player.stop(); + self.music_current = None; + self.music_active = false; + self.music_broadcast_id = None; + self.music_broadcast_next = None; + self.music_listening_to = None; + self.music_listen_loaded = None; + self.music_listen_inflight = None; + self.music_prefetch = None; + self.music_prefetch_inflight = None; self.peers.clear(); self.audio_levels.clear(); self.locally_muted.clear(); @@ -747,6 +853,8 @@ impl Default for AppState { config.participants_width = clamp_participants_width(config.participants_width, ww); config.chat_height = clamp_chat_height(config.chat_height, wh); + config.threecol_playlist_height = + clamp_threecol_playlist_height(config.threecol_playlist_height, wh); config.controls_width = clamp_controls_width(config.controls_width, ww); config.chat_drawer_width = clamp_chat_drawer_width(config.chat_drawer_width, ww); notify::set_enabled(config.notifications_enabled); @@ -795,6 +903,20 @@ impl Default for AppState { let background_image = load_background_bytes(&config); let (clip_player, clip_status) = ClipPlayer::new(config.clip_volume); + let (music_player, music_status) = ClipPlayer::new(config.music_volume); + let music_broadcasting = config.music_broadcast; + let music_playlist = config + .music_playlist + .iter() + .map(|p| { + let path = std::path::PathBuf::from(p); + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| p.clone()); + MusicTrack { path, name } + }) + .collect(); Self { // Pre-fill the nickname with the last one used (or "Peer" by default). @@ -833,6 +955,20 @@ impl Default for AppState { clip_player, clip_status, clip_volumes: HashMap::new(), + music_player, + music_status, + music_playlist, + music_current: None, + music_active: false, + music_tab: MusicTab::Personal, + music_broadcasting, + music_broadcast_id: None, + music_broadcast_next: None, + music_listening_to: None, + music_listen_loaded: None, + music_listen_inflight: None, + music_prefetch: None, + music_prefetch_inflight: None, chat_input: String::new(), window_size: Size::new(ww, wh), layout_picker_open: false, @@ -985,7 +1121,13 @@ fn initial_window_position( fn subscription(state: &AppState) -> Subscription { let core_sub = Subscription::run(core_subscription).map(AppMessage::UiEventReceived); let event_sub = iced::event::listen().map(AppMessage::EventOccurred); - let audio_sub = if status_snapshot(&state.clip_status).playing_id.is_some() { + let clip_playing = status_snapshot(&state.clip_status).playing_id.is_some(); + let music_playing = status_snapshot(&state.music_status).playing_id.is_some(); + let audio_sub = if clip_playing + || music_playing + || state.music_active + || state.music_listening_to.is_some() + { iced::time::every(std::time::Duration::from_millis(250)).map(|_| AppMessage::AudioTick) } else { Subscription::none() @@ -1331,6 +1473,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.locally_muted.remove(&id); state.connecting.remove(&id); state.ever_connected.remove(&id); + if state.music_listening_to == Some(id) { + state.music_listening_to = None; + state.music_player.stop(); + state.music_listen_loaded = None; + state.music_listen_inflight = None; + } notify::play(Sound::PeerLeave, state.config.custom_sound_peer_leave.as_deref()); } // Core-only recovery phase: presentation for this state lands in @@ -1347,6 +1495,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { } UiEvent::PeerUpdated { id, state: peer_state } => { state.peers.insert(id, peer_state); + if state.music_listening_to == Some(id) { + reconcile_listen(state); + } } UiEvent::PeerConnecting { id } => { if let Some(sound) = @@ -1424,6 +1575,46 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.attachments.insert(key, AttachmentState::Failed(error.clone()), None); state.status_message = format!("Attachment failed: {error}"); } + UiEvent::MusicReady { from, id, data } => { + if state.music_listening_to == Some(from) + && state.music_listen_inflight == Some(id) + { + state.music_listen_inflight = None; + if crate::files::is_probably_audio(&data) { + state.music_player.play(id, data); + state.music_player.set_volume(effective_music_volume(state)); + state.music_listen_loaded = Some(id); + reconcile_listen(state); + } else { + state.music_player.stop(); + state.music_listen_loaded = None; + state.status_message = + "Shared track is not valid supported audio.".to_string(); + } + } + } + UiEvent::MusicPrefetched { from, id, data } => { + if state.music_listening_to == Some(from) + && state.music_prefetch_inflight == Some(id) + { + state.music_prefetch_inflight = None; + if crate::files::is_probably_audio(&data) { + state.music_prefetch = Some((id, data)); + } + } + } + UiEvent::MusicFetchFailed { from, id, error } => { + if state.music_listening_to == Some(from) + && state.music_listen_inflight == Some(id) + { + state.music_listen_inflight = None; + state.status_message = format!("Music fetch failed: {error}"); + } else if state.music_listening_to == Some(from) + && state.music_prefetch_inflight == Some(id) + { + state.music_prefetch_inflight = None; + } + } UiEvent::AudioAppsListed { apps, app_audio_supported } => { // Only meaningful while the picker is open; if the user // already cancelled, drop it. @@ -1808,6 +1999,14 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.window_size.height, ); } + DividerKind::ThreeColPlaylist => { + // The Playlist card sits at the bottom of the middle column; dragging the + // divider down (positive delta) gives Chat more room and shrinks the card. + state.config.threecol_playlist_height = clamp_threecol_playlist_height( + state.config.threecol_playlist_height - delta, + state.window_size.height, + ); + } DividerKind::Controls => { // Controls sits on the right; dragging the divider right (positive // delta) gives Chat more room and shrinks Controls. @@ -2246,6 +2445,172 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.clip_player.set_volume(effective_clip_volume(state, id)); } } + AppMessage::MusicSelectTab(tab) => { + state.music_tab = tab; + } + AppMessage::MusicBrowse => { + return Task::perform( + async { + let handles = rfd::AsyncFileDialog::new() + .add_filter("Audio & playlists", &["mp3", "flac", "ogg", "oga", "wav", "m3u", "m3u8", "pls"]) + .set_title("Add music to your playlist") + .pick_files() + .await; + handles.map(|hs| { + hs.into_iter() + .map(|h| h.path().to_path_buf()) + .collect() + }) + }, + AppMessage::MusicFilesPicked, + ); + } + AppMessage::MusicFilesPicked(picked) => { + if let Some(paths) = picked { + for path in paths { + if let Some(kind) = crate::playlist::playlist_kind(&path) { + match std::fs::read_to_string(&path) { + Ok(contents) => { + let base_dir = path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")); + for entry in crate::playlist::parse_playlist(&contents, base_dir, kind) { + let name = entry + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| entry.to_string_lossy().into_owned()); + state.music_playlist.push(MusicTrack { path: entry, name }); + } + } + Err(e) => { + state.status_message = + format!("Couldn't read playlist '{}': {e}", path.display()); + } + } + } else { + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string_lossy().into_owned()); + state.music_playlist.push(MusicTrack { path, name }); + } + } + persist_music_playlist(state); + } + } + AppMessage::MusicPlayIndex(i) => { + play_music_index(state, i); + } + AppMessage::MusicPlayPause => { + let snap = status_snapshot(&state.music_status); + if snap.playing_id.is_some() { + if snap.paused { + state.music_player.resume(); + state.music_active = true; + broadcast_music_timeline_at(state, false, snap.position); + } else { + state.music_player.pause(); + state.music_active = false; + broadcast_music_timeline_at(state, true, snap.position); + } + } else if let Some(i) = state.music_current.or(if state.music_playlist.is_empty() { None } else { Some(0) }) { + play_music_index(state, i); + } + } + AppMessage::MusicNext => { + if let Some(next) = music_step(state, 1) { + play_music_index(state, next); + } + } + AppMessage::MusicPrev => { + if let Some(prev) = music_step(state, -1) { + play_music_index(state, prev); + } + } + AppMessage::MusicSeek(fraction) => { + let snap = status_snapshot(&state.music_status); + if let Some(total) = snap.total { + let target = seek_target(fraction, total); + state.music_player.seek(target); + broadcast_music_timeline_at(state, snap.paused, target); + } + } + AppMessage::MusicSetVolume(volume) => { + let volume = volume.clamp(0.0, 2.0); + state.config.music_volume = volume; + if state.music_listening_to.is_none() { + state.music_player.set_volume(volume); + } + state.config.save(); + } + AppMessage::MusicSetSourceVolume(volume) => { + let volume = volume.clamp(0.0, 2.0); + if let Some(peer) = state.music_listening_to { + state.config.music_source_volume.insert(peer.to_string(), volume); + state.music_player.set_volume(volume); + state.config.save(); + } + } + AppMessage::MusicRemove(i) => { + if i < state.music_playlist.len() { + if state.music_current == Some(i) { + state.music_player.stop(); + state.music_current = None; + state.music_active = false; + stop_music_broadcast(state); + } + state.music_playlist.remove(i); + if state.music_current.map(|c| c > i).unwrap_or(false) { + state.music_current = state.music_current.map(|c| c - 1); + } + persist_music_playlist(state); + } + } + AppMessage::MusicMoveUp(i) => { + if i > 0 { + music_swap(state, i, i.wrapping_sub(1)); + } + } + AppMessage::MusicMoveDown(i) => { + if i + 1 < state.music_playlist.len() { + music_swap(state, i, i + 1); + } + } + AppMessage::MusicToggleBroadcast(enabled) => { + state.music_broadcasting = enabled; + state.config.music_broadcast = enabled; + state.config.save(); + if enabled { + if state.music_listening_to.is_none() + && status_snapshot(&state.music_status).playing_id.is_some() + { + let snap = status_snapshot(&state.music_status); + broadcast_current_music_file(state, snap.paused, snap.position); + } + } else { + stop_music_broadcast(state); + } + } + AppMessage::MusicListen(peer) => { + state.music_listening_to = Some(peer); + state.music_player.stop(); + state.music_active = false; + state.music_current = None; + state.music_listen_loaded = None; + state.music_listen_inflight = None; + state.music_prefetch = None; + state.music_prefetch_inflight = None; + stop_music_broadcast(state); + reconcile_listen(state); + } + AppMessage::MusicStopListen => { + state.music_listening_to = None; + state.music_player.stop(); + state.music_listen_loaded = None; + state.music_listen_inflight = None; + state.music_prefetch = None; + state.music_prefetch_inflight = None; + } AppMessage::AudioTick => { let clip = status_snapshot(&state.clip_status); if let Some(failure) = clip.failure { @@ -2256,6 +2621,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.status_message = format!("Audio playback failed: {}", failure.error); state.clip_player.stop(); } + reconcile_listen(state); + if state.music_active && state.music_listening_to.is_none() { + let snap = status_snapshot(&state.music_status); + if snap.playing_id.is_none() && !snap.paused { + if let Some(next) = music_step(state, 1) { + play_music_index(state, next); + } else { + state.music_active = false; + stop_music_broadcast(state); + } + } + } } AppMessage::OpenUrl(url) => { // Defence in depth: only ever hand http(s) URLs to the opener. The @@ -2339,6 +2716,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { clamp_participants_width(state.config.participants_width, size.width); state.config.chat_height = clamp_chat_height(state.config.chat_height, size.height); + state.config.threecol_playlist_height = + clamp_threecol_playlist_height(state.config.threecol_playlist_height, size.height); state.config.controls_width = clamp_controls_width(state.config.controls_width, size.width); state.config.chat_drawer_width = @@ -2586,6 +2965,354 @@ fn play_ready_audio(state: &mut AppState, key: AttachmentKey) { } } +/// Synthesize a stable per-slot AttachmentId for the music player's status +/// bookkeeping (music tracks are local files, not real attachments). Encoding the +/// playlist index keeps ids distinct per slot; only used internally by the player. +fn music_slot_id(index: usize) -> crate::files::AttachmentId { + let mut id = [0u8; 32]; + id[..8].copy_from_slice(&(index as u64).to_le_bytes()); + id +} + +/// Compute the next playlist index in `dir` (+1/-1), wrapping, or None if empty. +fn music_step(state: &AppState, dir: isize) -> Option { + let len = state.music_playlist.len(); + if len == 0 { + return None; + } + let cur = state.music_current.unwrap_or(0) as isize; + Some((cur + dir).rem_euclid(len as isize) as usize) +} + +fn persist_music_playlist(state: &mut AppState) { + state.config.music_playlist = state + .music_playlist + .iter() + .map(|t| t.path.to_string_lossy().into_owned()) + .collect(); + state.config.save(); +} + +fn music_swap(state: &mut AppState, a: usize, b: usize) { + if a == b || a >= state.music_playlist.len() || b >= state.music_playlist.len() { + return; + } + state.music_playlist.swap(a, b); + if state.music_current == Some(a) { + state.music_current = Some(b); + } else if state.music_current == Some(b) { + state.music_current = Some(a); + } + persist_music_playlist(state); +} + +fn effective_music_volume(state: &AppState) -> f32 { + if let Some(peer) = state.music_listening_to { + state + .config + .music_source_volume + .get(&peer.to_string()) + .copied() + .unwrap_or(state.config.music_volume) + } else { + state.config.music_volume + } +} + +fn can_broadcast_music(state: &AppState) -> bool { + state.music_broadcasting && state.music_listening_to.is_none() +} + +fn stop_music_broadcast(state: &mut AppState) { + let old_current = state.music_broadcast_id.take(); + let old_next = state.music_broadcast_next.take().map(|(_, id, _)| id); + forget_stale_music_tracks(state, old_current, old_next, None, None); + state.music_broadcast_id = None; + state.music_broadcast_next = None; + let _ = state.controller.send(CoreCommand::SetMusicPresence(None)); +} + +fn forget_stale_music_tracks( + state: &AppState, + old_current: Option, + old_next: Option, + new_current: Option, + new_next: Option, +) { + for id in [old_current, old_next].into_iter().flatten() { + if Some(id) != new_current && Some(id) != new_next { + let _ = state.controller.send(CoreCommand::ForgetMusicTrack(id)); + } + } +} + +fn music_presence_for( + id: crate::files::AttachmentId, + name: String, + size: u64, + paused: bool, + position: std::time::Duration, + next_id: Option, + next_size: Option, +) -> crate::network::MusicPresence { + crate::network::MusicPresence { + id, + name: crate::files::sanitize_filename(&name), + size, + paused, + anchor_ms: now_ms(), + position_ms: position.as_millis() as u64, + next_id, + next_size, + } +} + +struct MusicPresenceUpdate { + id: crate::files::AttachmentId, + name: String, + size: u64, + paused: bool, + position: std::time::Duration, + next_id: Option, + next_size: Option, +} + +fn send_music_presence(state: &AppState, update: MusicPresenceUpdate) { + let _ = state.controller.send(CoreCommand::SetMusicPresence(Some( + music_presence_for( + update.id, + update.name, + update.size, + update.paused, + update.position, + update.next_id, + update.next_size, + ), + ))); +} + +fn broadcast_track( + state: &mut AppState, + current_index: usize, + name: String, + bytes: &[u8], + paused: bool, + position: std::time::Duration, +) { + if !can_broadcast_music(state) { + return; + } + let old_current = state.music_broadcast_id; + let old_next = state.music_broadcast_next; + let reuse_current = old_next + .filter(|(idx, _, _)| *idx == current_index) + .map(|(_, id, _)| id); + let id = reuse_current.unwrap_or_else(rand::random); + if reuse_current.is_none() { + let _ = state.controller.send(CoreCommand::ServeMusicTrack { + id, + data: Arc::new(bytes.to_vec()), + }); + } + state.music_broadcast_id = Some(id); + + let mut next_id = None; + let mut next_size = None; + state.music_broadcast_next = None; + if state.music_playlist.len() >= 2 + && let Some(next_index) = music_step(state, 1) + && next_index != current_index + && let Some(next_track) = state.music_playlist.get(next_index) + && let Ok(next_bytes) = std::fs::read(&next_track.path) + && crate::files::is_probably_audio(&next_bytes) + { + let id: crate::files::AttachmentId = rand::random(); + let size = next_bytes.len() as u64; + let _ = state.controller.send(CoreCommand::ServeMusicTrack { + id, + data: Arc::new(next_bytes), + }); + next_id = Some(id); + next_size = Some(size); + state.music_broadcast_next = Some((next_index, id, size)); + } + + forget_stale_music_tracks( + state, + old_current, + old_next.map(|(_, id, _)| id), + Some(id), + next_id, + ); + send_music_presence(state, MusicPresenceUpdate { + id, + name, + size: bytes.len() as u64, + paused, + position, + next_id, + next_size, + }); +} + +fn broadcast_current_music_file( + state: &mut AppState, + paused: bool, + position: std::time::Duration, +) { + let Some(index) = state.music_current else { return; }; + let Some(track) = state.music_playlist.get(index) else { return; }; + match std::fs::read(&track.path) { + Ok(bytes) if crate::files::is_probably_audio(&bytes) => { + broadcast_track(state, index, track.name.clone(), &bytes, paused, position); + } + Ok(_) | Err(_) => stop_music_broadcast(state), + } +} + +fn broadcast_music_timeline_at( + state: &AppState, + paused: bool, + position: std::time::Duration, +) { + if !can_broadcast_music(state) { + return; + } + let Some(id) = state.music_broadcast_id else { return; }; + let Some(index) = state.music_current else { return; }; + let Some(track) = state.music_playlist.get(index) else { return; }; + let Ok(meta) = std::fs::metadata(&track.path) else { return; }; + let (next_id, next_size) = state + .music_broadcast_next + .map(|(_, id, size)| (Some(id), Some(size))) + .unwrap_or((None, None)); + send_music_presence(state, MusicPresenceUpdate { + id, + name: track.name.clone(), + size: meta.len(), + paused, + position, + next_id, + next_size, + }); +} + +/// Read a playlist track's bytes from disk and start the music player on it. +fn play_music_index(state: &mut AppState, index: usize) { + let Some(track) = state.music_playlist.get(index) else { return; }; + let path = track.path.clone(); + let name = track.name.clone(); + match std::fs::read(&path) { + Ok(bytes) if crate::files::is_probably_audio(&bytes) => { + let broadcast_bytes = bytes.clone(); + state.music_current = Some(index); + state.music_active = true; + state.music_player.play(music_slot_id(index), bytes); + state.music_player.set_volume(effective_music_volume(state)); + broadcast_track(state, index, name, &broadcast_bytes, false, std::time::Duration::ZERO); + } + Ok(_) => { + state.music_player.stop(); + state.music_current = Some(index); + state.music_active = false; + stop_music_broadcast(state); + state.status_message = + format!("'{}' is not a supported audio file.", name); + } + Err(e) => { + state.music_player.stop(); + state.music_current = Some(index); + state.music_active = false; + stop_music_broadcast(state); + state.status_message = format!("Couldn't read '{}': {e}", name); + } + } +} + +fn reconcile_listen(state: &mut AppState) { + let Some(peer) = state.music_listening_to else { return; }; + let Some(music) = state.peers.get(&peer).and_then(|p| p.music.clone()) else { + if state.music_listen_loaded.is_some() + || state.music_listen_inflight.is_some() + || state.music_prefetch.is_some() + || state.music_prefetch_inflight.is_some() + { + state.music_player.stop(); + state.music_listen_loaded = None; + state.music_listen_inflight = None; + state.music_prefetch = None; + state.music_prefetch_inflight = None; + state.status_message = "Source stopped broadcasting".to_string(); + } + return; + }; + + if Some(music.id) != state.music_listen_loaded { + if let Some((id, data)) = state.music_prefetch.take().filter(|(id, _)| *id == music.id) { + state.music_player.play(id, data); + state.music_player.set_volume(effective_music_volume(state)); + state.music_listen_loaded = Some(id); + state.music_listen_inflight = None; + } else if Some(music.id) != state.music_listen_inflight { + state.music_player.stop(); + state.music_listen_loaded = None; + state.music_listen_inflight = Some(music.id); + state.status_message = format!("Loading {}…", music.name); + let _ = state.controller.send(CoreCommand::FetchMusic { + from: peer, + id: music.id, + size: music.size, + }); + return; + } + } + + if Some(music.id) != state.music_listen_loaded { + return; + } + + let snap = status_snapshot(&state.music_status); + let mut expected = if music.paused { + music.position_ms + } else { + music.position_ms.saturating_add(now_ms().saturating_sub(music.anchor_ms)) + }; + if let Some(total) = snap.total { + expected = expected.min(total.as_millis() as u64); + } + let expected_duration = std::time::Duration::from_millis(expected); + + if music.paused && !snap.paused { + state.music_player.pause(); + } else if !music.paused && snap.paused { + state.music_player.resume(); + state.music_player.seek(expected_duration); + } + + let local_ms = snap.position.as_millis() as u64; + if !music.paused && local_ms.abs_diff(expected) > 300 { + state.music_player.seek(expected_duration); + } + + if let (Some(next_id), Some(next_size)) = (music.next_id, music.next_size) { + let cached = state + .music_prefetch + .as_ref() + .is_some_and(|(id, _)| *id == next_id); + if Some(next_id) != state.music_listen_loaded + && !cached + && Some(next_id) != state.music_prefetch_inflight + { + state.music_prefetch_inflight = Some(next_id); + let _ = state.controller.send(CoreCommand::PrefetchMusic { + from: peer, + id: next_id, + size: next_size, + }); + } + } +} + /// Write a ready attachment's bytes to a user-chosen location via a native save /// dialog. The default filename comes from the (already-sanitized) descriptor. /// Build a Task that opens the native save dialog off the UI thread and writes @@ -4571,6 +5298,269 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let mute_kind = if state.is_muted { IconKind::MicOff } else { IconKind::Mic }; let deafen_kind = if state.is_deafened { IconKind::Deafen } else { IconKind::Headphones }; + let music_panel: Element<'_, AppMessage> = { + let music_status = status_snapshot(&state.music_status); + let music_playing = music_status.playing_id.is_some(); + let play_label = if music_playing && music_status.paused { + "▶" + } else if music_playing { + "⏸" + } else { + "▶" + }; + let personal_selected = state.music_tab == MusicTab::Personal; + let public_selected = state.music_tab == MusicTab::Public; + let tab_row = row![ + button(text("Personal").size(12).align_x(iced::alignment::Horizontal::Center)) + .on_press(AppMessage::MusicSelectTab(MusicTab::Personal)) + .style(b_style( + if personal_selected { color_blue } else { color_surface }, + color_blue, + if personal_selected { color_crust } else { color_text }, + 6.0, + )) + .padding(8) + .width(iced::Length::Fill), + button(text("Public").size(12).align_x(iced::alignment::Horizontal::Center)) + .on_press(AppMessage::MusicSelectTab(MusicTab::Public)) + .style(b_style( + if public_selected { color_blue } else { color_surface }, + color_blue, + if public_selected { color_crust } else { color_text }, + 6.0, + )) + .padding(8) + .width(iced::Length::Fill), + ] + .spacing(8); + + let content: Element<'_, AppMessage> = match state.music_tab { + MusicTab::Personal => { + let mut tracks = Column::new().spacing(6); + if state.music_playlist.is_empty() { + tracks = tracks.push( + text("No tracks yet — Browse to add audio.") + .size(12) + .color(color_subtext), + ); + } else { + let last = state.music_playlist.len().saturating_sub(1); + for (i, track) in state.music_playlist.iter().enumerate() { + let selected = state.music_current == Some(i); + let up = { + let btn = button(text("▲").size(13)) + .style(b_style(color_surface, color_overlay, color_text, 6.0)) + .padding(6); + if i > 0 { btn.on_press(AppMessage::MusicMoveUp(i)) } else { btn } + }; + let down = { + let btn = button(text("▼").size(13)) + .style(b_style(color_surface, color_overlay, color_text, 6.0)) + .padding(6); + if i < last { btn.on_press(AppMessage::MusicMoveDown(i)) } else { btn } + }; + tracks = tracks.push( + row![ + button(text(&track.name).size(13)) + .on_press(AppMessage::MusicPlayIndex(i)) + .style(b_style( + if selected { color_blue } else { color_surface }, + color_blue, + if selected { color_crust } else { color_text }, + 6.0, + )) + .padding(6) + .width(iced::Length::Fill), + up, + down, + button(text("×").size(13)) + .on_press(AppMessage::MusicRemove(i)) + .style(b_style(color_surface, color_overlay, color_text, 6.0)) + .padding(6), + ] + .spacing(6) + .align_y(iced::alignment::Vertical::Center), + ); + } + } + let elapsed = format_clip_time(music_status.position); + let duration = music_status + .total + .map(format_clip_time) + .unwrap_or_else(|| "--:--".to_string()); + column![ + row![ + button(text("⏮").size(13)) + .on_press(AppMessage::MusicPrev) + .style(b_style(color_surface, color_blue, color_text, 6.0)) + .padding(7), + button(text(play_label).size(13)) + .on_press(AppMessage::MusicPlayPause) + .style(b_style(color_blue, color_lavender, color_crust, 6.0)) + .padding(7), + button(text("⏭").size(13)) + .on_press(AppMessage::MusicNext) + .style(b_style(color_surface, color_blue, color_text, 6.0)) + .padding(7), + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center), + slider( + 0.0..=1.0, + clip_progress(music_status.position, music_status.total), + AppMessage::MusicSeek, + ) + .step(0.001), + text(format!("{elapsed} / {duration}")) + .size(11) + .color(color_subtext), + text("Music volume").size(11).color(color_subtext), + slider(0.0..=2.0, state.config.music_volume, AppMessage::MusicSetVolume) + .step(0.01), + button(text("Browse").size(12).align_x(iced::alignment::Horizontal::Center)) + .on_press(AppMessage::MusicBrowse) + .style(b_style(color_surface, color_blue, color_text, 6.0)) + .padding(8) + .width(iced::Length::Fill), + checkbox(state.music_broadcasting) + .label("Let others tune in") + .text_size(12) + .on_toggle(AppMessage::MusicToggleBroadcast), + scrollable(tracks) + .height(iced::Length::Fixed(160.0)) + .width(iced::Length::Fill), + ] + .spacing(8) + .into() + } + MusicTab::Public => { + let mut broadcast_rows = Column::new().spacing(6); + let self_id = state.self_id.parse::().ok(); + let mut count = 0usize; + for (id, peer) in &state.peers { + if Some(*id) == self_id { + continue; + } + let Some(music) = &peer.music else { continue; }; + count += 1; + broadcast_rows = broadcast_rows.push( + row![ + column![ + text(&peer.name).size(12).color(color_text), + text(&music.name).size(11).color(color_subtext), + ] + .spacing(2) + .width(iced::Length::Fill), + button(text("Listen").size(12)) + .on_press(AppMessage::MusicListen(*id)) + .style(b_style(color_surface, color_blue, color_text, 6.0)) + .padding(6), + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center), + ); + } + if count == 0 { + broadcast_rows = broadcast_rows.push( + text("No one is broadcasting.") + .size(12) + .color(color_subtext), + ); + } + + let listen_block: Element<'_, AppMessage> = + if let Some(peer) = state.music_listening_to { + let name = state + .peers + .get(&peer) + .map(|p| p.name.clone()) + .unwrap_or_else(|| "source".to_string()); + let elapsed = format_clip_time(music_status.position); + let duration = music_status + .total + .map(format_clip_time) + .unwrap_or_else(|| "--:--".to_string()); + column![ + row![ + text(format!("▶ Listening to {name}")) + .size(12) + .color(color_blue) + .width(iced::Length::Fill), + button(text("Stop").size(12)) + .on_press(AppMessage::MusicStopListen) + .style(b_style(color_surface, color_maroon, color_text, 6.0)) + .padding(6), + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center), + text(format!("{elapsed} / {duration}")) + .size(11) + .color(color_subtext), + text("Listen volume").size(11).color(color_subtext), + slider(0.0..=2.0, effective_music_volume(state), AppMessage::MusicSetSourceVolume) + .step(0.01), + ] + .spacing(8) + .into() + } else { + column![].into() + }; + + column![ + listen_block, + text("Broadcasting now").size(12).color(color_blue), + scrollable(broadcast_rows) + .height(iced::Length::Fixed(160.0)) + .width(iced::Length::Fill), + ] + .spacing(8) + .into() + } + }; + + container( + column![ + tab_row, + content, + ] + .spacing(10) + ) + .style(c_style(color_mantle, color_surface, 6.0)) + .padding(10) + .width(iced::Length::Fill) + .into() + }; + // W22: in the 3-column layout the Playlist gets its own card stacked under Chat + // (built below in the ThreeColumn body arm); in every other layout it stays in + // the Controls panel. `music_panel` is consumed by exactly one of these. + let three_col = matches!(state.config.room_layout, RoomLayout::ThreeColumn); + let (ctrl_music, playlist_card): (Element<'_, AppMessage>, Option>) = + if three_col { + let card = container( + column![ + text("Playlist").size(18).color(color_blue), + vertical_space(8.0), + scrollable(music_panel) + .width(iced::Length::Fill) + .height(iced::Length::Fill), + ] + ) + .style(c_style(color_mantle, color_surface, 8.0)) + .padding(12) + .width(iced::Length::Fill) + .height(iced::Length::Fill); + (column![].into(), Some(card.into())) + } else { + ( + column![ + vertical_space(20.0), + text("Playlist").size(18).color(color_blue), + music_panel, + ] + .into(), + None, + ) + }; let ctrl_buttons = column![ button(btn_content(mute_kind, mute_text, mute_fg)) .on_press(AppMessage::ToggleMutePressed) @@ -4673,7 +5663,8 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .style(b_style(share_bg, share_hover, share_fg, 8.0)) .padding(14) .width(iced::Length::Fill) - } + }, + ctrl_music, ]; // Leave is the exit control, so it's pinned below the scrolling controls @@ -4969,13 +5960,8 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .width(iced::Length::Fixed(DIVIDER_THICKNESS)) .height(iced::Length::Fill) }; - let hdiv = || { - Canvas::new(Divider { - kind: DividerKind::Chat, - vertical: false, - line: color_surface, - grip: color_lavender, - }) + let hdiv = |kind| { + Canvas::new(Divider { kind, vertical: false, line: color_surface, grip: color_lavender }) .width(iced::Length::Fill) .height(iced::Length::Fixed(DIVIDER_THICKNESS)) }; @@ -5000,12 +5986,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .padding(12) .width(iced::Length::Fill) .height(iced::Length::Fixed(state.config.chat_height)); - column![main, hdiv(), chat].into() + column![main, hdiv(DividerKind::Chat), chat].into() } RoomLayout::ThreeColumn => { - // Participants is fixed and Controls is fixed, so cap Participants - // (shared with the 2-panel layouts, where it's much wider) to leave - // the centre Chat column at least a minimum width. let avail = state.window_size.width - 30.0; // outer padding let pw3 = pw.min( (avail - state.config.controls_width - CHAT_MIN_W - 2.0 * DIVIDER_THICKNESS) @@ -5016,10 +5999,19 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .padding(12) .width(iced::Length::Fill) .height(iced::Length::Fill); + // The Playlist card was built above iff this is the 3-column layout. + let card = playlist_card.expect("playlist_card is Some for ThreeColumn"); + let middle = column![ + chat, + hdiv(DividerKind::ThreeColPlaylist), + container(card).height(iced::Length::Fixed(state.config.threecol_playlist_height)), + ] + .width(iced::Length::Fill) + .height(iced::Length::Fill); row![ peers_panel.width(iced::Length::Fixed(pw3)), vdiv(DividerKind::Panels), - chat, + middle, vdiv(DividerKind::Controls), control_panel.width(iced::Length::Fixed(state.config.controls_width)), ] @@ -6572,6 +7564,7 @@ mod tests { sharing: None, avatar: crate::avatar::Avatar::default(), game: None, + music: None, }); state.audio_levels.insert(peer, 0.5); state.locally_muted.insert(peer); @@ -6611,6 +7604,13 @@ mod tests { peer_ahead: true, expires_at: now, }); + state.music_broadcast_id = Some(attachment_id); + state.music_broadcast_next = Some((0, attachment_id, 128)); + state.music_listening_to = Some(peer); + state.music_listen_loaded = Some(attachment_id); + state.music_listen_inflight = Some(attachment_id); + state.music_prefetch = Some((attachment_id, vec![1, 2, 3])); + state.music_prefetch_inflight = Some(attachment_id); state.clip_status.lock().unwrap().playing_id = Some(attachment_id); state.reset_room_state(); @@ -6639,6 +7639,13 @@ mod tests { assert!(!state.share_audio_app_active); assert!(state.share_app_audio_supported, "reset is optimistic by default"); assert!(state.clock_skew_warning.is_none()); + assert!(state.music_broadcast_id.is_none()); + assert!(state.music_broadcast_next.is_none()); + assert!(state.music_listening_to.is_none()); + assert!(state.music_listen_loaded.is_none()); + assert!(state.music_listen_inflight.is_none()); + assert!(state.music_prefetch.is_none()); + assert!(state.music_prefetch_inflight.is_none()); for _ in 0..50 { if crate::audio::clip_player::status_snapshot(&state.clip_status).playing_id.is_none() { diff --git a/src/bin/test_net.rs b/src/bin/test_net.rs index 9eb7d77..6c488f5 100644 --- a/src/bin/test_net.rs +++ b/src/bin/test_net.rs @@ -65,6 +65,7 @@ async fn main() -> Result<(), Box> { sharing: None, avatar: Default::default(), game: None, + music: None, }; room_a.join(&ticket_str, state_a, vec![]).await?; println!("Node A joined topic."); @@ -85,6 +86,7 @@ async fn main() -> Result<(), Box> { sharing: None, avatar: Default::default(), game: None, + music: None, }; room_b.join(&ticket_str, state_b, vec![]).await?; println!("Node B joined topic."); diff --git a/src/config.rs b/src/config.rs index 2080985..b60a5a9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -126,6 +126,10 @@ fn default_chat_height() -> f32 { 180.0 } +fn default_threecol_playlist_height() -> f32 { + 220.0 +} + fn default_controls_width() -> f32 { 280.0 } @@ -160,6 +164,16 @@ pub struct AppConfig { /// level shared by every uploaded clip so the slider sticks across plays. #[serde(default = "default_volume")] pub clip_volume: f32, + /// W22 music: the user's personal playlist as local file PATHS (not bytes). + /// Loaded into memory at startup; missing files are skipped/marked on play. + #[serde(default)] + pub music_playlist: Vec, + /// W22 music: local playback gain for the dedicated music player (1.0 = unity). + #[serde(default = "default_volume")] + pub music_volume: f32, + /// W22 music: opt-in shared listening broadcast toggle. Local preference. + #[serde(default)] + pub music_broadcast: bool, /// When true, `clip_volume` governs every clip. When false, each clip keeps /// its own (in-memory) level and the universal slider is inactive. #[serde(default = "default_true")] @@ -182,6 +196,11 @@ pub struct AppConfig { pub participants_width: f32, #[serde(default = "default_chat_height")] pub chat_height: f32, + /// Height (px) of the standalone Playlist card stacked under Chat in the + /// 3-column layout. Resized via its own horizontal divider; re-clamped to the + /// window on load/resize. Only used by `RoomLayout::ThreeColumn`. + #[serde(default = "default_threecol_playlist_height")] + pub threecol_playlist_height: f32, /// Controls panel width for the 3-column layout (px). #[serde(default = "default_controls_width")] pub controls_width: f32, @@ -287,6 +306,11 @@ pub struct AppConfig { /// string. Local preference only; never sent to peers. Absent entry = unity. #[serde(default)] pub peer_volume: HashMap, + /// Per-source music listen volume/gain (`1.0` = unity), keyed by peer node id + /// string. Local preference only; never sent to peers. Absent entry falls + /// back to `music_volume`. + #[serde(default)] + pub music_source_volume: HashMap, /// Per-peer listener-side noise-gate threshold (normalized RMS, `0.0` = off), /// keyed by peer node id string. Local preference only; never sent to peers. /// Absent entry = gate disabled (pass-through). @@ -321,6 +345,9 @@ impl Default for AppConfig { input_volume: 1.0, output_volume: 1.0, clip_volume: 1.0, + music_playlist: Vec::new(), + music_volume: 1.0, + music_broadcast: false, clip_volume_universal: true, network_mode: NetworkMode::default(), presence_mode: crate::presence::PresenceMode::default(), @@ -328,6 +355,7 @@ impl Default for AppConfig { notifications_enabled: true, participants_width: default_participants_width(), chat_height: default_chat_height(), + threecol_playlist_height: default_threecol_playlist_height(), controls_width: default_controls_width(), chat_drawer_width: default_chat_drawer_width(), room_layout: RoomLayout::default(), @@ -360,6 +388,7 @@ impl Default for AppConfig { peer_eq: HashMap::new(), peer_pan: HashMap::new(), peer_volume: HashMap::new(), + music_source_volume: HashMap::new(), peer_gate: HashMap::new(), hotkeys: crate::hotkeys::HotkeyMap::default(), window_width: default_window_width(), @@ -516,6 +545,7 @@ mod tests { assert!(deserialized.peer_eq.is_empty()); assert!(deserialized.peer_pan.is_empty()); assert!(deserialized.peer_volume.is_empty()); + assert!(deserialized.music_source_volume.is_empty()); assert!(deserialized.peer_gate.is_empty()); assert_eq!( crate::hotkeys::format_binding( @@ -668,6 +698,9 @@ mod tests { assert_eq!(def.input_volume, 1.0); assert_eq!(def.output_volume, 1.0); assert_eq!(def.clip_volume, 1.0); + assert!(def.music_playlist.is_empty()); + assert_eq!(def.music_volume, 1.0); + assert!(!def.music_broadcast); assert!(def.clip_volume_universal); // Missing in JSON → unity (serde default). @@ -676,6 +709,9 @@ mod tests { assert_eq!(cfg_missing.input_volume, 1.0); assert_eq!(cfg_missing.output_volume, 1.0); assert_eq!(cfg_missing.clip_volume, 1.0); + assert!(cfg_missing.music_playlist.is_empty()); + assert_eq!(cfg_missing.music_volume, 1.0); + assert!(!cfg_missing.music_broadcast); // Configs predating the toggle default to universal mode. assert!(cfg_missing.clip_volume_universal); @@ -684,6 +720,9 @@ mod tests { input_volume: 1.5, output_volume: 0.25, clip_volume: 0.7, + music_playlist: vec!["/tmp/song.ogg".to_string()], + music_volume: 0.6, + music_broadcast: true, clip_volume_universal: false, ..AppConfig::default() }; @@ -692,6 +731,9 @@ mod tests { assert_eq!(round_tripped.input_volume, 1.5); assert_eq!(round_tripped.output_volume, 0.25); assert_eq!(round_tripped.clip_volume, 0.7); + assert_eq!(round_tripped.music_playlist, vec!["/tmp/song.ogg".to_string()]); + assert_eq!(round_tripped.music_volume, 0.6); + assert!(round_tripped.music_broadcast); assert!(!round_tripped.clip_volume_universal); } diff --git a/src/core/messages.rs b/src/core/messages.rs index e882e10..11bafd0 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -61,6 +61,17 @@ pub enum CoreCommand { /// (used for on-demand file/chip downloads; images are auto-fetched on /// receipt). Replies with `AttachmentReady`/`AttachmentFailed`. FetchAttachment { from: EndpointId, attachment: crate::files::ChatAttachment }, + /// Register `data` as fetchable under `id` for room members (the current + /// broadcast track). Called once per track when broadcasting. + ServeMusicTrack { id: crate::files::AttachmentId, data: std::sync::Arc> }, + /// Drop a music blob that is no longer current-or-next. + ForgetMusicTrack(crate::files::AttachmentId), + /// Set (or clear) our broadcast music timeline and re-announce presence. + SetMusicPresence(Option), + /// Fetch a source peer's current track bytes after tuning into them. + FetchMusic { from: EndpointId, id: crate::files::AttachmentId, size: u64 }, + /// Fetch a source peer's advertised next track bytes before it becomes current. + PrefetchMusic { from: EndpointId, id: crate::files::AttachmentId, size: u64 }, /// Set the pixelpass binary location (config override, empty = use `$PATH`). /// Sent at startup so screen-share can resolve the binary. SetPixelpassPath(Option), @@ -163,6 +174,22 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass { from: _, attachment: _, } + | CoreCommand::ServeMusicTrack { + id: _, + data: _, + } + | CoreCommand::ForgetMusicTrack(_) + | CoreCommand::SetMusicPresence(_) + | CoreCommand::FetchMusic { + from: _, + id: _, + size: _, + } + | CoreCommand::PrefetchMusic { + from: _, + id: _, + size: _, + } | CoreCommand::SetPixelpassPath(_) | CoreCommand::ListAudioApps | CoreCommand::StartScreenShare { audio_app: _ } @@ -221,6 +248,12 @@ pub enum UiEvent { AttachmentReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec }, /// An attachment fetch failed (sender gone, too large, decode error, etc.). AttachmentFailed { from: EndpointId, id: crate::files::AttachmentId, error: String }, + /// A tuned-in source's track bytes arrived; play them in the music sink. + MusicReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec }, + /// A tuned-in source's next-track bytes arrived; cache them for a gapless swap. + MusicPrefetched { from: EndpointId, id: crate::files::AttachmentId, data: Vec }, + /// A music-track fetch failed (source gone, too large, etc.). + MusicFetchFailed { from: EndpointId, id: crate::files::AttachmentId, error: String }, /// The apps currently producing audio, for the screen-share audio picker /// (A23). Sorted, deduplicated `application.name`s; empty when nothing is /// playing or enumeration isn't available. `app_audio_supported` reports diff --git a/src/core/mod.rs b/src/core/mod.rs index 35d6491..11e28df 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -865,6 +865,52 @@ fn spawn_attachment_fetch( }); } +fn spawn_music_fetch( + transport: Arc, + ui_tx: mpsc::Sender, + from: EndpointId, + id: crate::files::AttachmentId, + size: u64, +) { + tokio::spawn(async move { + match transport.fetch_blob(from, id, size).await { + Ok(data) => { + let _ = ui_tx + .send(UiEvent::MusicReady { from, id, data }) + .await; + } + Err(e) => { + let _ = ui_tx + .send(UiEvent::MusicFetchFailed { from, id, error: e.to_string() }) + .await; + } + } + }); +} + +fn spawn_music_prefetch( + transport: Arc, + ui_tx: mpsc::Sender, + from: EndpointId, + id: crate::files::AttachmentId, + size: u64, +) { + tokio::spawn(async move { + match transport.fetch_blob(from, id, size).await { + Ok(data) => { + let _ = ui_tx + .send(UiEvent::MusicPrefetched { from, id, data }) + .await; + } + Err(e) => { + let _ = ui_tx + .send(UiEvent::MusicFetchFailed { from, id, error: e.to_string() }) + .await; + } + } + }); +} + /// Finalize and clear the active recording, if any, emitting `RecordingStopped`. /// No-op when not recording. Called on stop, room leave, and room switch so a /// recording is always closed cleanly (its WAV size fields patched). @@ -1036,6 +1082,7 @@ async fn run_core_loop( name: "Anonymous".to_string(), avatar: crate::avatar::Avatar::default(), game: None, + music: None, }; // Game detection (W17/W18): a background worker polls Steam state + the process // list and publishes the debounced running game on a watch channel. Detection @@ -2665,6 +2712,54 @@ async fn run_core_loop( } } + CoreCommand::ServeMusicTrack { id, data } => { + if let Some(session) = &active_session { + session.transport.serve_attachment(id, data); + } + } + + CoreCommand::ForgetMusicTrack(id) => { + if let Some(session) = &active_session { + session.transport.forget_attachment(id); + } + } + + CoreCommand::SetMusicPresence(music) => { + presence.music = music; + if let Some(session) = &active_session { + let self_state = presence.to_state( + is_muted.load(Ordering::Relaxed), + net.endpoint.addr(), + current_sharing.clone(), + ); + let _ = session.room_state.update_self_state(self_state).await; + } + } + + CoreCommand::FetchMusic { from, id, size } => { + if let Some(session) = &active_session { + spawn_music_fetch( + session.transport.clone(), + ui_tx.clone(), + from, + id, + size, + ); + } + } + + CoreCommand::PrefetchMusic { from, id, size } => { + if let Some(session) = &active_session { + spawn_music_prefetch( + session.transport.clone(), + ui_tx.clone(), + from, + id, + size, + ); + } + } + CoreCommand::SetPixelpassPath(path) => { pixelpass_override = path.filter(|p| !p.trim().is_empty()); } diff --git a/src/lib.rs b/src/lib.rs index 95f4c9d..8ee191b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod recents; pub mod discovery; pub mod hotkeys; pub mod files; +pub mod playlist; pub mod game; pub mod widget; diff --git a/src/network/gossip.rs b/src/network/gossip.rs index 692436f..26f3be4 100644 --- a/src/network/gossip.rs +++ b/src/network/gossip.rs @@ -645,6 +645,29 @@ impl RoomState for IrohGossipState { let cleaned = crate::sanitize::sanitize_game_label(&g); (!cleaned.is_empty()).then_some(cleaned) }); + // Music presence is untrusted peer data: + // the track name is display text (sanitize + // + cap like the game label) and the size + // bounds a future fetch (reject anything + // outside the attachment cap). + state.music = state.music.and_then(|mut m| { + let name = crate::sanitize::sanitize_game_label(&m.name); + if name.is_empty() || !crate::files::size_within_cap(m.size) { + return None; + } + m.name = name; + if m.next_id.is_some() { + let ok = m + .next_size + .map(crate::files::size_within_cap) + .unwrap_or(false); + if !ok { + m.next_id = None; + m.next_size = None; + } + } + Some(m) + }); // Bound an insider's advertised address set // before we retain it / hand it to the dialer // (Tier C F-01). @@ -959,6 +982,7 @@ mod tests { sharing: None, avatar: crate::avatar::Avatar::default(), game: None, + music: None, } } diff --git a/src/network/iroh_impl.rs b/src/network/iroh_impl.rs index f0e62bc..0f51132 100644 --- a/src/network/iroh_impl.rs +++ b/src/network/iroh_impl.rs @@ -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, 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, NetError> { + self.fetch_blob(from, att.id, att.size).await + } } #[async_trait] diff --git a/src/network/mod.rs b/src/network/mod.rs index 4b2b902..c0b64b6 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -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, + /// Byte length of the next track; bounds the prefetch. Cap-checked at ingest. + #[serde(default)] + pub next_size: Option, +} + #[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, + /// 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, } /// 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, + /// Our current broadcast timeline, or `None` when not broadcasting / not playing. + pub music: Option, } 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); + } } diff --git a/src/playlist.rs b/src/playlist.rs new file mode 100644 index 0000000..9074794 --- /dev/null +++ b/src/playlist.rs @@ -0,0 +1,114 @@ +use std::path::{Path, PathBuf}; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PlaylistKind { + M3u, + Pls, +} + +/// Classify a path by extension into a playlist kind, or None if it is not a +/// recognized playlist file. Case-insensitive: m3u/m3u8 -> M3u, pls -> Pls. +pub fn playlist_kind(path: &Path) -> Option { + let ext = path.extension()?.to_string_lossy(); + match ext.to_ascii_lowercase().as_str() { + "m3u" | "m3u8" => Some(PlaylistKind::M3u), + "pls" => Some(PlaylistKind::Pls), + _ => None, + } +} + +/// Parse an m3u/m3u8 or pls playlist into local audio file paths. Remote entries +/// (http/https/ftp URLs) and non-audio entries are skipped; relative paths are +/// resolved against `base_dir` (the playlist file's parent directory). Order is +/// preserved. Does not touch the filesystem. +pub fn parse_playlist(contents: &str, base_dir: &Path, kind: PlaylistKind) -> Vec { + let entries: Vec<&str> = match kind { + PlaylistKind::M3u => contents + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .collect(), + PlaylistKind::Pls => contents + .lines() + .filter_map(|line| { + let (key, value) = line.split_once('=')?; + key.trim() + .to_ascii_lowercase() + .starts_with("file") + .then_some(value.trim()) + }) + .filter(|line| !line.is_empty()) + .collect(), + }; + + entries + .into_iter() + .filter_map(|entry| playlist_entry_path(entry, base_dir)) + .collect() +} + +fn playlist_entry_path(entry: &str, base_dir: &Path) -> Option { + let lower = entry.to_ascii_lowercase(); + if lower.starts_with("http://") + || lower.starts_with("https://") + || lower.starts_with("ftp://") + { + return None; + } + + let path = Path::new(entry); + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + base_dir.join(path) + }; + let file_name = resolved.file_name()?.to_string_lossy(); + crate::files::looks_like_audio_name(&file_name).then_some(resolved) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn m3u_skips_comments_and_remote_urls() { + let base = Path::new("/music/lists"); + let contents = "\ +#EXTM3U +#EXTINF:123,Artist - Song +tracks/song.ogg +https://example.com/stream.mp3 +"; + assert_eq!( + parse_playlist(contents, base, PlaylistKind::M3u), + vec![PathBuf::from("/music/lists/tracks/song.ogg")] + ); + } + + #[test] + fn pls_keeps_file_values_and_skips_non_audio() { + let base = Path::new("/music"); + let contents = "\ +[playlist] +File1=one.flac +Title1=One +File2=notes.txt +File3=/var/audio/two.MP3 +"; + assert_eq!( + parse_playlist(contents, base, PlaylistKind::Pls), + vec![ + PathBuf::from("/music/one.flac"), + PathBuf::from("/var/audio/two.MP3"), + ] + ); + } + + #[test] + fn playlist_kind_is_case_insensitive() { + assert_eq!(playlist_kind(Path::new("mix.M3U")), Some(PlaylistKind::M3u)); + assert_eq!(playlist_kind(Path::new("mix.m3u8")), Some(PlaylistKind::M3u)); + assert_eq!(playlist_kind(Path::new("mix.PLS")), Some(PlaylistKind::Pls)); + assert_eq!(playlist_kind(Path::new("mix.txt")), None); + } +} diff --git a/src/protocol.rs b/src/protocol.rs index 331ec6c..e6a19fc 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -32,7 +32,14 @@ pub const FRIENDS_PROTO: u32 = 1; /// strictly required for decoding — but per the versioning discipline a wire-shape /// change is isolated into its own topic + signature domain so v2 and v3 peers /// never share a swarm. Resync everyone, exactly like the W4 avatar bump. -pub const GOSSIP_PROTO: u32 = 3; +/// +/// v4 (0.6.0): `PeerState` gained an optional `music` presence field carrying a +/// current shared-listening track descriptor and playback timeline. Bytes still +/// ride the files plane by id; gossip carries only the descriptor/timeline. +/// +/// v5 (0.7.0): `MusicPresence` gained optional prefetch hints for the next +/// track so tuned-in listeners can fetch it before the DJ advances. +pub const GOSSIP_PROTO: u32 = 5; /// File-transfer plane version (chat attachment request/stream shape). Bump on /// any change. Mirrored in [`FILES_ALPN`]. pub const FILES_PROTO: u32 = 1; @@ -47,7 +54,7 @@ pub const FILES_ALPN: &[u8] = b"peerspeak/files/1"; /// ed25519 gossip signature domain: `peerspeak-gossip-v`. Carries /// the gossip protocol version into every signed payload — a version mismatch /// fails verification (cryptographic separation between gossip versions). -pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v3"; +pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v5"; /// Version-namespace a room topic so peers on different gossip protocol versions /// derive **different subscription topics from the same ticket** and therefore diff --git a/tests/gossip_rebootstrap.rs b/tests/gossip_rebootstrap.rs index 1d59287..b148a84 100644 --- a/tests/gossip_rebootstrap.rs +++ b/tests/gossip_rebootstrap.rs @@ -64,6 +64,7 @@ fn state(name: &str, addr: EndpointAddr) -> PeerState { sharing: None, avatar: Default::default(), game: None, + music: None, } }