Compare commits
10
Commits
v0.5.0
...
9ff7c7b99c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ff7c7b99c | ||
|
|
3ff0945866 | ||
|
|
618a53027d | ||
|
|
f293181626 | ||
|
|
bca2ccd6a4 | ||
|
|
6054f0ecf9 | ||
|
|
c2e27c3367 | ||
|
|
3c678afaf7 | ||
|
|
8cfb5beff9 | ||
|
|
f21a027e78 |
@@ -2,6 +2,26 @@
|
|||||||
|
|
||||||
All notable changes to PeerSpeak are documented here.
|
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
|
||||||
|
- **Volume control for inline chat audio clips.** A master volume slider plus a **Universal volume** toggle now sit in the Chat panel header, and every audio attachment gets its own 🔊 slider on its play row. With Universal volume on (the default), one level applies to all clips and persists across sessions; turn it off to give each clip its own independent level.
|
||||||
|
|
||||||
## [0.5.0] — 2026-06-27
|
## [0.5.0] — 2026-06-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -22,6 +42,7 @@ All notable changes to PeerSpeak are documented here.
|
|||||||
- Added Debian/Ubuntu `.deb` packaging (cargo-deb metadata); the official `.deb` is now built on **Debian 12 (bookworm)** for wide compatibility.
|
- Added Debian/Ubuntu `.deb` packaging (cargo-deb metadata); the official `.deb` is now built on **Debian 12 (bookworm)** for wide compatibility.
|
||||||
- Arch `PKGBUILD` clones over anonymous HTTPS.
|
- Arch `PKGBUILD` clones over anonymous HTTPS.
|
||||||
|
|
||||||
|
[0.5.1]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.1
|
||||||
[0.5.0]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.0
|
[0.5.0]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.0
|
||||||
|
|
||||||
Earlier releases: see git tags `v0.4.0`, `v0.3.0`, `v0.2.0`.
|
Earlier releases: see git tags `v0.4.0`, `v0.3.0`, `v0.2.0`.
|
||||||
|
|||||||
Generated
+1
-1
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||||
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ build() {
|
|||||||
export CARGO_HOME="$srcdir/cargo-home"
|
export CARGO_HOME="$srcdir/cargo-home"
|
||||||
export RUSTUP_TOOLCHAIN=stable
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
export CARGO_TARGET_DIR=target
|
export CARGO_TARGET_DIR=target
|
||||||
|
# Strip the build directory out of paths embedded in the binary (Rust bakes
|
||||||
|
# source paths into panic/backtrace metadata that survives stripping), so the
|
||||||
|
# package doesn't reference $srcdir. One remap covers our sources and the
|
||||||
|
# vendored deps, since CARGO_HOME lives under $srcdir too.
|
||||||
|
export RUSTFLAGS="${RUSTFLAGS:-} --remap-path-prefix=$srcdir=/"
|
||||||
cargo build --frozen --release --bin "$_pkgname"
|
cargo build --frozen --release --bin "$_pkgname"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
||||||
|
|
||||||
#define MyAppName "PeerSpeak"
|
#define MyAppName "PeerSpeak"
|
||||||
#define MyAppVersion "0.5.0"
|
#define MyAppVersion "0.6.0"
|
||||||
#define MyAppPublisher "mollusk"
|
#define MyAppPublisher "mollusk"
|
||||||
#define MyAppExeName "peerspeak.exe"
|
#define MyAppExeName "peerspeak.exe"
|
||||||
|
|
||||||
|
|||||||
+1253
-41
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ enum ClipCommand {
|
|||||||
Resume,
|
Resume,
|
||||||
Seek(Duration),
|
Seek(Duration),
|
||||||
Stop,
|
Stop,
|
||||||
|
SetVolume(f32),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cheap, `Send` command handle for the dedicated playback thread.
|
/// Cheap, `Send` command handle for the dedicated playback thread.
|
||||||
@@ -51,13 +52,16 @@ pub struct ClipPlayer {
|
|||||||
impl ClipPlayer {
|
impl ClipPlayer {
|
||||||
/// Start the playback worker. The system output device is opened lazily on
|
/// Start the playback worker. The system output device is opened lazily on
|
||||||
/// first Play, so merely launching PeerSpeak never claims another stream.
|
/// first Play, so merely launching PeerSpeak never claims another stream.
|
||||||
pub fn new() -> (Self, SharedClipStatus) {
|
///
|
||||||
|
/// `initial_volume` is the universal gain (`1.0` = unity) applied to every
|
||||||
|
/// clip, restored from config so the level persists across sessions.
|
||||||
|
pub fn new(initial_volume: f32) -> (Self, SharedClipStatus) {
|
||||||
let (command_tx, command_rx) = mpsc::channel();
|
let (command_tx, command_rx) = mpsc::channel();
|
||||||
let status = Arc::new(Mutex::new(ClipStatus::default()));
|
let status = Arc::new(Mutex::new(ClipStatus::default()));
|
||||||
let worker_status = Arc::clone(&status);
|
let worker_status = Arc::clone(&status);
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("peerspeak-clip-player".to_string())
|
.name("peerspeak-clip-player".to_string())
|
||||||
.spawn(move || playback_worker(command_rx, worker_status))
|
.spawn(move || playback_worker(command_rx, worker_status, initial_volume))
|
||||||
.expect("failed to spawn clip playback thread");
|
.expect("failed to spawn clip playback thread");
|
||||||
(
|
(
|
||||||
Self {
|
Self {
|
||||||
@@ -94,11 +98,24 @@ impl ClipPlayer {
|
|||||||
pub fn stop(&self) {
|
pub fn stop(&self) {
|
||||||
let _ = self.command_tx.send(ClipCommand::Stop);
|
let _ = self.command_tx.send(ClipCommand::Stop);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the universal playback gain (`1.0` = unity). Applies to the current
|
||||||
|
/// clip immediately and to every clip played afterwards.
|
||||||
|
pub fn set_volume(&self, volume: f32) {
|
||||||
|
let _ = self.command_tx.send(ClipCommand::SetVolume(volume));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipStatus) {
|
fn playback_worker(
|
||||||
|
command_rx: mpsc::Receiver<ClipCommand>,
|
||||||
|
status: SharedClipStatus,
|
||||||
|
initial_volume: f32,
|
||||||
|
) {
|
||||||
let mut output: Option<MixerDeviceSink> = None;
|
let mut output: Option<MixerDeviceSink> = None;
|
||||||
let mut player: Option<Player> = None;
|
let mut player: Option<Player> = None;
|
||||||
|
// Universal gain remembered across clips so a level set on one upload
|
||||||
|
// carries to the next; reapplied to each freshly connected player.
|
||||||
|
let mut volume = initial_volume.max(0.0);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match command_rx.recv_timeout(Duration::from_millis(100)) {
|
match command_rx.recv_timeout(Duration::from_millis(100)) {
|
||||||
@@ -124,7 +141,9 @@ fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipSt
|
|||||||
if output.is_none() {
|
if output.is_none() {
|
||||||
match DeviceSinkBuilder::open_default_sink() {
|
match DeviceSinkBuilder::open_default_sink() {
|
||||||
Ok(sink) => {
|
Ok(sink) => {
|
||||||
player = Some(Player::connect_new(sink.mixer()));
|
let new_player = Player::connect_new(sink.mixer());
|
||||||
|
new_player.set_volume(volume);
|
||||||
|
player = Some(new_player);
|
||||||
output = Some(sink);
|
output = Some(sink);
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -177,6 +196,12 @@ fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipSt
|
|||||||
}
|
}
|
||||||
reset(&status);
|
reset(&status);
|
||||||
}
|
}
|
||||||
|
Ok(ClipCommand::SetVolume(level)) => {
|
||||||
|
volume = level.max(0.0);
|
||||||
|
if let Some(player) = player.as_ref() {
|
||||||
|
player.set_volume(volume);
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: Default::default(),
|
avatar: Default::default(),
|
||||||
game: None,
|
game: None,
|
||||||
|
music: None,
|
||||||
};
|
};
|
||||||
room_a.join(&ticket_str, state_a, vec![]).await?;
|
room_a.join(&ticket_str, state_a, vec![]).await?;
|
||||||
println!("Node A joined topic.");
|
println!("Node A joined topic.");
|
||||||
@@ -85,6 +86,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: Default::default(),
|
avatar: Default::default(),
|
||||||
game: None,
|
game: None,
|
||||||
|
music: None,
|
||||||
};
|
};
|
||||||
room_b.join(&ticket_str, state_b, vec![]).await?;
|
room_b.join(&ticket_str, state_b, vec![]).await?;
|
||||||
println!("Node B joined topic.");
|
println!("Node B joined topic.");
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ fn default_chat_height() -> f32 {
|
|||||||
180.0
|
180.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_threecol_playlist_height() -> f32 {
|
||||||
|
220.0
|
||||||
|
}
|
||||||
|
|
||||||
fn default_controls_width() -> f32 {
|
fn default_controls_width() -> f32 {
|
||||||
280.0
|
280.0
|
||||||
}
|
}
|
||||||
@@ -156,6 +160,24 @@ pub struct AppConfig {
|
|||||||
/// App-internal playback gain applied to the mixed output (1.0 = unity).
|
/// App-internal playback gain applied to the mixed output (1.0 = unity).
|
||||||
#[serde(default = "default_volume")]
|
#[serde(default = "default_volume")]
|
||||||
pub output_volume: f32,
|
pub output_volume: f32,
|
||||||
|
/// Universal playback gain for inline chat audio clips (1.0 = unity). One
|
||||||
|
/// 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")]
|
||||||
|
pub clip_volume_universal: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub network_mode: NetworkMode,
|
pub network_mode: NetworkMode,
|
||||||
/// Presence posture for the friends idle listener (W7): invisible / normal /
|
/// Presence posture for the friends idle listener (W7): invisible / normal /
|
||||||
@@ -174,6 +196,11 @@ pub struct AppConfig {
|
|||||||
pub participants_width: f32,
|
pub participants_width: f32,
|
||||||
#[serde(default = "default_chat_height")]
|
#[serde(default = "default_chat_height")]
|
||||||
pub chat_height: f32,
|
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).
|
/// Controls panel width for the 3-column layout (px).
|
||||||
#[serde(default = "default_controls_width")]
|
#[serde(default = "default_controls_width")]
|
||||||
pub controls_width: f32,
|
pub controls_width: f32,
|
||||||
@@ -279,6 +306,11 @@ pub struct AppConfig {
|
|||||||
/// string. Local preference only; never sent to peers. Absent entry = unity.
|
/// string. Local preference only; never sent to peers. Absent entry = unity.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub peer_volume: HashMap<String, f32>,
|
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),
|
/// 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.
|
/// keyed by peer node id string. Local preference only; never sent to peers.
|
||||||
/// Absent entry = gate disabled (pass-through).
|
/// Absent entry = gate disabled (pass-through).
|
||||||
@@ -312,12 +344,18 @@ impl Default for AppConfig {
|
|||||||
noise_gate_threshold: 0.01,
|
noise_gate_threshold: 0.01,
|
||||||
input_volume: 1.0,
|
input_volume: 1.0,
|
||||||
output_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(),
|
network_mode: NetworkMode::default(),
|
||||||
presence_mode: crate::presence::PresenceMode::default(),
|
presence_mode: crate::presence::PresenceMode::default(),
|
||||||
echo_cancellation_enabled: false,
|
echo_cancellation_enabled: false,
|
||||||
notifications_enabled: true,
|
notifications_enabled: true,
|
||||||
participants_width: default_participants_width(),
|
participants_width: default_participants_width(),
|
||||||
chat_height: default_chat_height(),
|
chat_height: default_chat_height(),
|
||||||
|
threecol_playlist_height: default_threecol_playlist_height(),
|
||||||
controls_width: default_controls_width(),
|
controls_width: default_controls_width(),
|
||||||
chat_drawer_width: default_chat_drawer_width(),
|
chat_drawer_width: default_chat_drawer_width(),
|
||||||
room_layout: RoomLayout::default(),
|
room_layout: RoomLayout::default(),
|
||||||
@@ -350,6 +388,7 @@ impl Default for AppConfig {
|
|||||||
peer_eq: HashMap::new(),
|
peer_eq: HashMap::new(),
|
||||||
peer_pan: HashMap::new(),
|
peer_pan: HashMap::new(),
|
||||||
peer_volume: HashMap::new(),
|
peer_volume: HashMap::new(),
|
||||||
|
music_source_volume: HashMap::new(),
|
||||||
peer_gate: HashMap::new(),
|
peer_gate: HashMap::new(),
|
||||||
hotkeys: crate::hotkeys::HotkeyMap::default(),
|
hotkeys: crate::hotkeys::HotkeyMap::default(),
|
||||||
window_width: default_window_width(),
|
window_width: default_window_width(),
|
||||||
@@ -506,6 +545,7 @@ mod tests {
|
|||||||
assert!(deserialized.peer_eq.is_empty());
|
assert!(deserialized.peer_eq.is_empty());
|
||||||
assert!(deserialized.peer_pan.is_empty());
|
assert!(deserialized.peer_pan.is_empty());
|
||||||
assert!(deserialized.peer_volume.is_empty());
|
assert!(deserialized.peer_volume.is_empty());
|
||||||
|
assert!(deserialized.music_source_volume.is_empty());
|
||||||
assert!(deserialized.peer_gate.is_empty());
|
assert!(deserialized.peer_gate.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
crate::hotkeys::format_binding(
|
crate::hotkeys::format_binding(
|
||||||
@@ -657,23 +697,44 @@ mod tests {
|
|||||||
let def = AppConfig::default();
|
let def = AppConfig::default();
|
||||||
assert_eq!(def.input_volume, 1.0);
|
assert_eq!(def.input_volume, 1.0);
|
||||||
assert_eq!(def.output_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).
|
// Missing in JSON → unity (serde default).
|
||||||
let missing = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
let missing = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
||||||
let cfg_missing: AppConfig = serde_json::from_str(missing).unwrap();
|
let cfg_missing: AppConfig = serde_json::from_str(missing).unwrap();
|
||||||
assert_eq!(cfg_missing.input_volume, 1.0);
|
assert_eq!(cfg_missing.input_volume, 1.0);
|
||||||
assert_eq!(cfg_missing.output_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);
|
||||||
|
|
||||||
// Explicit non-unity values are preserved across a round-trip.
|
// Explicit non-unity values are preserved across a round-trip.
|
||||||
let cfg = AppConfig {
|
let cfg = AppConfig {
|
||||||
input_volume: 1.5,
|
input_volume: 1.5,
|
||||||
output_volume: 0.25,
|
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()
|
..AppConfig::default()
|
||||||
};
|
};
|
||||||
let round_tripped: AppConfig =
|
let round_tripped: AppConfig =
|
||||||
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
||||||
assert_eq!(round_tripped.input_volume, 1.5);
|
assert_eq!(round_tripped.input_volume, 1.5);
|
||||||
assert_eq!(round_tripped.output_volume, 0.25);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ pub enum CoreCommand {
|
|||||||
/// (used for on-demand file/chip downloads; images are auto-fetched on
|
/// (used for on-demand file/chip downloads; images are auto-fetched on
|
||||||
/// receipt). Replies with `AttachmentReady`/`AttachmentFailed`.
|
/// receipt). Replies with `AttachmentReady`/`AttachmentFailed`.
|
||||||
FetchAttachment { from: EndpointId, attachment: crate::files::ChatAttachment },
|
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<Vec<u8>> },
|
||||||
|
/// 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<crate::network::MusicPresence>),
|
||||||
|
/// 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`).
|
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
|
||||||
/// Sent at startup so screen-share can resolve the binary.
|
/// Sent at startup so screen-share can resolve the binary.
|
||||||
SetPixelpassPath(Option<String>),
|
SetPixelpassPath(Option<String>),
|
||||||
@@ -163,6 +174,22 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
|||||||
from: _,
|
from: _,
|
||||||
attachment: _,
|
attachment: _,
|
||||||
}
|
}
|
||||||
|
| CoreCommand::ServeMusicTrack {
|
||||||
|
id: _,
|
||||||
|
data: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::ForgetMusicTrack(_)
|
||||||
|
| CoreCommand::SetMusicPresence(_)
|
||||||
|
| CoreCommand::FetchMusic {
|
||||||
|
from: _,
|
||||||
|
id: _,
|
||||||
|
size: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::PrefetchMusic {
|
||||||
|
from: _,
|
||||||
|
id: _,
|
||||||
|
size: _,
|
||||||
|
}
|
||||||
| CoreCommand::SetPixelpassPath(_)
|
| CoreCommand::SetPixelpassPath(_)
|
||||||
| CoreCommand::ListAudioApps
|
| CoreCommand::ListAudioApps
|
||||||
| CoreCommand::StartScreenShare { audio_app: _ }
|
| CoreCommand::StartScreenShare { audio_app: _ }
|
||||||
@@ -221,6 +248,12 @@ pub enum UiEvent {
|
|||||||
AttachmentReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
|
AttachmentReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
|
||||||
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
|
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
|
||||||
AttachmentFailed { from: EndpointId, id: crate::files::AttachmentId, error: String },
|
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<u8> },
|
||||||
|
/// A tuned-in source's next-track bytes arrived; cache them for a gapless swap.
|
||||||
|
MusicPrefetched { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
|
||||||
|
/// 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
|
/// The apps currently producing audio, for the screen-share audio picker
|
||||||
/// (A23). Sorted, deduplicated `application.name`s; empty when nothing is
|
/// (A23). Sorted, deduplicated `application.name`s; empty when nothing is
|
||||||
/// playing or enumeration isn't available. `app_audio_supported` reports
|
/// playing or enumeration isn't available. `app_audio_supported` reports
|
||||||
|
|||||||
@@ -865,6 +865,52 @@ fn spawn_attachment_fetch(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn spawn_music_fetch(
|
||||||
|
transport: Arc<IrohTransport>,
|
||||||
|
ui_tx: mpsc::Sender<UiEvent>,
|
||||||
|
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<IrohTransport>,
|
||||||
|
ui_tx: mpsc::Sender<UiEvent>,
|
||||||
|
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`.
|
/// 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
|
/// 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).
|
/// recording is always closed cleanly (its WAV size fields patched).
|
||||||
@@ -1036,6 +1082,7 @@ async fn run_core_loop(
|
|||||||
name: "Anonymous".to_string(),
|
name: "Anonymous".to_string(),
|
||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
game: None,
|
game: None,
|
||||||
|
music: None,
|
||||||
};
|
};
|
||||||
// Game detection (W17/W18): a background worker polls Steam state + the process
|
// Game detection (W17/W18): a background worker polls Steam state + the process
|
||||||
// list and publishes the debounced running game on a watch channel. Detection
|
// 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) => {
|
CoreCommand::SetPixelpassPath(path) => {
|
||||||
pixelpass_override = path.filter(|p| !p.trim().is_empty());
|
pixelpass_override = path.filter(|p| !p.trim().is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub mod recents;
|
|||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
pub mod hotkeys;
|
pub mod hotkeys;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
|
pub mod playlist;
|
||||||
pub mod game;
|
pub mod game;
|
||||||
pub mod widget;
|
pub mod widget;
|
||||||
|
|
||||||
|
|||||||
@@ -645,6 +645,29 @@ impl RoomState for IrohGossipState {
|
|||||||
let cleaned = crate::sanitize::sanitize_game_label(&g);
|
let cleaned = crate::sanitize::sanitize_game_label(&g);
|
||||||
(!cleaned.is_empty()).then_some(cleaned)
|
(!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
|
// Bound an insider's advertised address set
|
||||||
// before we retain it / hand it to the dialer
|
// before we retain it / hand it to the dialer
|
||||||
// (Tier C F-01).
|
// (Tier C F-01).
|
||||||
@@ -959,6 +982,7 @@ mod tests {
|
|||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
game: None,
|
game: None,
|
||||||
|
music: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-10
@@ -594,17 +594,21 @@ impl IrohTransport {
|
|||||||
self.shared.served_files.lock().unwrap().insert(id, bytes);
|
self.shared.served_files.lock().unwrap().insert(id, bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch a chat attachment's bytes from its sender over the file plane. Dials
|
/// Drop a previously-served blob (e.g. a music track no longer current-or-next).
|
||||||
/// the sender on `FILES_ALPN` (preferring a known full address), writes the
|
pub fn forget_attachment(&self, id: AttachmentId) {
|
||||||
/// 32-byte id, and reads the response bounded by the descriptor's declared
|
self.shared.served_files.lock().unwrap().remove(&id);
|
||||||
/// 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(
|
/// 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,
|
&self,
|
||||||
from: EndpointId,
|
from: EndpointId,
|
||||||
att: &ChatAttachment,
|
id: AttachmentId,
|
||||||
|
size: u64,
|
||||||
) -> Result<Vec<u8>, NetError> {
|
) -> 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()));
|
return Err(NetError::Other("attachment size out of range".to_string()));
|
||||||
}
|
}
|
||||||
let addr = self.shared.addrs.lock().unwrap().get(&from).cloned();
|
let addr = self.shared.addrs.lock().unwrap().get(&from).cloned();
|
||||||
@@ -623,13 +627,13 @@ impl IrohTransport {
|
|||||||
.open_bi()
|
.open_bi()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| NetError::Other(format!("file fetch: open stream failed: {e}")))?;
|
.map_err(|e| NetError::Other(format!("file fetch: open stream failed: {e}")))?;
|
||||||
send.write_all(&att.id)
|
send.write_all(&id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| NetError::Other(format!("file fetch: request write failed: {e}")))?;
|
.map_err(|e| NetError::Other(format!("file fetch: request write failed: {e}")))?;
|
||||||
send.finish()
|
send.finish()
|
||||||
.map_err(|e| NetError::Other(format!("file fetch: request finish failed: {e}")))?;
|
.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)
|
let bytes = tokio::time::timeout(FILE_FETCH_TIMEOUT, read)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| NetError::Other("file fetch: read timed out".to_string()))?
|
.map_err(|_| NetError::Other("file fetch: read timed out".to_string()))?
|
||||||
@@ -639,6 +643,15 @@ impl IrohTransport {
|
|||||||
}
|
}
|
||||||
Ok(bytes)
|
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]
|
#[async_trait]
|
||||||
|
|||||||
@@ -22,6 +22,38 @@ pub enum NetError {
|
|||||||
Other(String),
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct PeerState {
|
pub struct PeerState {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -48,6 +80,10 @@ pub struct PeerState {
|
|||||||
/// Defaulted so peers/configs predating the field still deserialize.
|
/// Defaulted so peers/configs predating the field still deserialize.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub game: Option<String>,
|
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
|
/// 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
|
/// (see `crate::sanitize::sanitize_game_label`) before being stored here, so
|
||||||
/// the outgoing announce carries a safe value.
|
/// the outgoing announce carries a safe value.
|
||||||
pub game: Option<String>,
|
pub game: Option<String>,
|
||||||
|
/// Our current broadcast timeline, or `None` when not broadcasting / not playing.
|
||||||
|
pub music: Option<MusicPresence>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SelfPresence {
|
impl SelfPresence {
|
||||||
@@ -89,6 +127,7 @@ impl SelfPresence {
|
|||||||
sharing,
|
sharing,
|
||||||
avatar: self.avatar.clone(),
|
avatar: self.avatar.clone(),
|
||||||
game: self.game.clone(),
|
game: self.game.clone(),
|
||||||
|
music: self.music.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,6 +346,7 @@ mod tests {
|
|||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
game: None,
|
game: None,
|
||||||
|
music: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +462,7 @@ mod tests {
|
|||||||
name: "Alice".to_string(),
|
name: "Alice".to_string(),
|
||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
game: Some("Half-Life 2".to_string()),
|
game: Some("Half-Life 2".to_string()),
|
||||||
|
music: None,
|
||||||
};
|
};
|
||||||
// Volatile fields come from the call; sticky fields from the struct.
|
// Volatile fields come from the call; sticky fields from the struct.
|
||||||
let muted = presence.to_state(true, addr.clone(), Some("ticket".to_string()));
|
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();
|
let deserialized: PeerState = serde_json::from_str(&serialized).unwrap();
|
||||||
assert_eq!(original, deserialized);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+114
@@ -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<PlaylistKind> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-2
@@ -32,7 +32,14 @@ pub const FRIENDS_PROTO: u32 = 1;
|
|||||||
/// strictly required for decoding — but per the versioning discipline a wire-shape
|
/// 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
|
/// 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.
|
/// 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
|
/// File-transfer plane version (chat attachment request/stream shape). Bump on
|
||||||
/// any change. Mirrored in [`FILES_ALPN`].
|
/// any change. Mirrored in [`FILES_ALPN`].
|
||||||
pub const FILES_PROTO: u32 = 1;
|
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<GOSSIP_PROTO>`. Carries
|
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
|
||||||
/// the gossip protocol version into every signed payload — a version mismatch
|
/// the gossip protocol version into every signed payload — a version mismatch
|
||||||
/// fails verification (cryptographic separation between gossip versions).
|
/// 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
|
/// Version-namespace a room topic so peers on different gossip protocol versions
|
||||||
/// derive **different subscription topics from the same ticket** and therefore
|
/// derive **different subscription topics from the same ticket** and therefore
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ fn state(name: &str, addr: EndpointAddr) -> PeerState {
|
|||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: Default::default(),
|
avatar: Default::default(),
|
||||||
game: None,
|
game: None,
|
||||||
|
music: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user