feat(music): W22 shared listening + release 0.6.0

Personal playlist on a dedicated music player (browse/play/prev/next/
seek/volume/reorder/remove, .pls/.m3u import), plus per-person shared
listening: broadcast your track over presence, peers tune in and stream
it point-to-point over the files plane. Playback is timeline-synced
(play/pause/skip/seek mirror with no drift) with gapless prefetch of the
next track and independent per-source volume per listener.

In the 3-column layout the playlist gets its own card stacked under the
chat, with a resizable divider and its own scrollbar; other layouts keep
it in the Controls panel.

Breaking wire change: gossip protocol v5 (presence gains music fields),
so 0.6.0 peers cannot share a swarm with 0.5.x. Version bumped 0.5.1 ->
0.6.0; CHANGELOG updated.

Untrusted-input handling: broadcast track name sanitized and size
cap-checked at gossip ingest, fetched bytes confirmed audio before
decode, only the descriptor rides gossip (bytes go point-to-point, one
fetch in flight).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 06:04:31 -04:00
co-authored by Claude Opus 4.8
parent c2e27c3367
commit bca2ccd6a4
15 changed files with 1440 additions and 28 deletions
+114
View File
@@ -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);
}
}