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