733 lines
24 KiB
Rust
733 lines
24 KiB
Rust
//! Capability-based Jam playback control for independent federation peers.
|
|
|
|
use std::collections::{HashMap, HashSet, VecDeque};
|
|
use std::sync::{Arc, Mutex, MutexGuard};
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{Context, Result};
|
|
use music_dht::{ByteStream, MusicDhtService, PeerTicket, StreamAcceptor};
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::sync::mpsc;
|
|
|
|
use crate::app::event::AppEvent;
|
|
use crate::devices::{PlaybackCommand, PlaybackSnapshot};
|
|
|
|
pub const JAM_ALPN: &[u8] = b"furumi/jam/1";
|
|
const PROTOCOL_VERSION: u16 = 1;
|
|
const MAX_LINE: usize = 8 * 1024 * 1024;
|
|
const MAX_COMMANDS: usize = 128;
|
|
const PARTICIPANT_TTL_MS: i64 = 30 * 60 * 1_000;
|
|
const POLL_INTERVAL: Duration = Duration::from_millis(500);
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum JamRole {
|
|
None,
|
|
Host,
|
|
Participant,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct JamStatus {
|
|
pub role: JamRole,
|
|
pub jam_id: Option<String>,
|
|
pub host_name: Option<String>,
|
|
pub invite: Option<String>,
|
|
pub participants: Vec<JamParticipant>,
|
|
pub connected: bool,
|
|
pub last_error: Option<String>,
|
|
}
|
|
|
|
impl Default for JamStatus {
|
|
fn default() -> Self {
|
|
Self {
|
|
role: JamRole::None,
|
|
jam_id: None,
|
|
host_name: None,
|
|
invite: None,
|
|
participants: Vec::new(),
|
|
connected: false,
|
|
last_error: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct JamParticipant {
|
|
pub participant_id: String,
|
|
pub name: String,
|
|
pub last_seen_ms: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct JamInvite {
|
|
v: u16,
|
|
#[serde(rename = "t")]
|
|
ticket: String,
|
|
#[serde(rename = "j")]
|
|
jam_id: String,
|
|
#[serde(rename = "s")]
|
|
secret: String,
|
|
#[serde(rename = "d")]
|
|
host_device_id: String,
|
|
#[serde(rename = "n")]
|
|
host_name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct JamCommand {
|
|
command_id: String,
|
|
participant_id: String,
|
|
command: PlaybackCommand,
|
|
sent_at_ms: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
enum WireMessage {
|
|
Poll {
|
|
version: u16,
|
|
jam_id: String,
|
|
secret: String,
|
|
participant: JamParticipant,
|
|
#[serde(default)]
|
|
commands: Vec<JamCommand>,
|
|
},
|
|
Snapshot {
|
|
accepted: bool,
|
|
#[serde(default)]
|
|
error: Option<String>,
|
|
#[serde(default)]
|
|
acknowledged_command_ids: Vec<String>,
|
|
#[serde(default)]
|
|
playback: Option<PlaybackSnapshot>,
|
|
#[serde(default)]
|
|
participants: Vec<JamParticipant>,
|
|
host_time_ms: i64,
|
|
},
|
|
Leave {
|
|
version: u16,
|
|
jam_id: String,
|
|
secret: String,
|
|
participant_id: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct HostState {
|
|
invite: Option<JamInvite>,
|
|
invite_uri: Option<String>,
|
|
playback: Option<PlaybackSnapshot>,
|
|
participants: HashMap<String, JamParticipant>,
|
|
seen_commands: HashSet<String>,
|
|
seen_order: VecDeque<String>,
|
|
}
|
|
|
|
struct JoinedState {
|
|
invite: JamInvite,
|
|
participant: JamParticipant,
|
|
pending: VecDeque<JamCommand>,
|
|
participants: Vec<JamParticipant>,
|
|
connected: bool,
|
|
last_error: Option<String>,
|
|
last_activity_ms: i64,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct State {
|
|
host: HostState,
|
|
joined: Option<JoinedState>,
|
|
}
|
|
|
|
pub struct JamManager {
|
|
state: Mutex<State>,
|
|
event_tx: mpsc::UnboundedSender<AppEvent>,
|
|
}
|
|
|
|
impl JamManager {
|
|
pub fn new(event_tx: mpsc::UnboundedSender<AppEvent>) -> Arc<Self> {
|
|
Arc::new(Self {
|
|
state: Mutex::new(State::default()),
|
|
event_tx,
|
|
})
|
|
}
|
|
|
|
pub async fn create_or_regenerate(
|
|
&self,
|
|
service: Arc<MusicDhtService>,
|
|
host_device_id: String,
|
|
host_name: String,
|
|
) -> Result<String> {
|
|
let invite = JamInvite {
|
|
v: PROTOCOL_VERSION,
|
|
ticket: service.ticket().await?.to_string(),
|
|
jam_id: format!("jam_{}", random_hex(12)),
|
|
secret: random_hex(32),
|
|
host_device_id,
|
|
host_name,
|
|
};
|
|
let uri = encode_invite(&invite)?;
|
|
let mut state = lock(&self.state);
|
|
state.joined = None;
|
|
state.host = HostState {
|
|
invite: Some(invite),
|
|
invite_uri: Some(uri.clone()),
|
|
..HostState::default()
|
|
};
|
|
Ok(uri)
|
|
}
|
|
|
|
pub fn join(&self, uri: &str, participant_id: String, name: String) -> Result<()> {
|
|
let invite = parse_invite(uri)?;
|
|
let mut state = lock(&self.state);
|
|
state.host = HostState::default();
|
|
state.joined = Some(JoinedState {
|
|
invite,
|
|
participant: JamParticipant {
|
|
participant_id,
|
|
name,
|
|
last_seen_ms: now_ms(),
|
|
},
|
|
pending: VecDeque::new(),
|
|
participants: Vec::new(),
|
|
connected: false,
|
|
last_error: None,
|
|
last_activity_ms: now_ms(),
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
pub fn leave(&self) {
|
|
let mut state = lock(&self.state);
|
|
state.joined = None;
|
|
state.host = HostState::default();
|
|
}
|
|
|
|
pub fn status(&self) -> JamStatus {
|
|
let state = lock(&self.state);
|
|
if let Some(joined) = &state.joined {
|
|
return JamStatus {
|
|
role: JamRole::Participant,
|
|
jam_id: Some(joined.invite.jam_id.clone()),
|
|
host_name: Some(joined.invite.host_name.clone()),
|
|
invite: None,
|
|
participants: joined.participants.clone(),
|
|
connected: joined.connected,
|
|
last_error: joined.last_error.clone(),
|
|
};
|
|
}
|
|
if let Some(invite) = &state.host.invite {
|
|
return JamStatus {
|
|
role: JamRole::Host,
|
|
jam_id: Some(invite.jam_id.clone()),
|
|
host_name: Some(invite.host_name.clone()),
|
|
invite: state.host.invite_uri.clone(),
|
|
participants: state.host.participants.values().cloned().collect(),
|
|
connected: true,
|
|
last_error: None,
|
|
};
|
|
}
|
|
JamStatus::default()
|
|
}
|
|
|
|
pub fn publish_host_playback(&self, snapshot: PlaybackSnapshot) {
|
|
let mut state = lock(&self.state);
|
|
if state.host.invite.is_some() {
|
|
state.host.playback = Some(snapshot);
|
|
}
|
|
}
|
|
|
|
pub fn submit_command(&self, command: PlaybackCommand) -> Result<()> {
|
|
let mut state = lock(&self.state);
|
|
let joined = state
|
|
.joined
|
|
.as_mut()
|
|
.context("this player is not controlling a Jam")?;
|
|
if joined.pending.len() >= MAX_COMMANDS {
|
|
joined.pending.pop_front();
|
|
}
|
|
joined.pending.push_back(JamCommand {
|
|
command_id: format!("cmd_{}", random_hex(16)),
|
|
participant_id: joined.participant.participant_id.clone(),
|
|
command,
|
|
sent_at_ms: now_ms(),
|
|
});
|
|
joined.last_activity_ms = now_ms();
|
|
Ok(())
|
|
}
|
|
|
|
async fn poll_once(&self, service: Arc<MusicDhtService>) -> Result<()> {
|
|
let (invite, participant, commands) = {
|
|
let state = lock(&self.state);
|
|
let joined = state.joined.as_ref().context("not joined")?;
|
|
(
|
|
joined.invite.clone(),
|
|
joined.participant.clone(),
|
|
joined.pending.iter().cloned().collect::<Vec<_>>(),
|
|
)
|
|
};
|
|
let ticket: PeerTicket = invite.ticket.parse().context("invalid Jam host ticket")?;
|
|
let peer = service.connect(ticket).await?;
|
|
let mut stream = service.open_stream(peer, JAM_ALPN).await?;
|
|
write_message(
|
|
&mut stream,
|
|
&WireMessage::Poll {
|
|
version: PROTOCOL_VERSION,
|
|
jam_id: invite.jam_id.clone(),
|
|
secret: invite.secret.clone(),
|
|
participant,
|
|
commands,
|
|
},
|
|
)
|
|
.await?;
|
|
stream.send.finish()?;
|
|
let response = read_message(&mut stream).await?;
|
|
let WireMessage::Snapshot {
|
|
accepted,
|
|
error,
|
|
acknowledged_command_ids,
|
|
playback,
|
|
participants,
|
|
..
|
|
} = response
|
|
else {
|
|
anyhow::bail!("unexpected Jam response");
|
|
};
|
|
anyhow::ensure!(
|
|
accepted,
|
|
"{}",
|
|
error.unwrap_or_else(|| "Jam refused".into())
|
|
);
|
|
{
|
|
let mut state = lock(&self.state);
|
|
let Some(joined) = state.joined.as_mut() else {
|
|
return Ok(());
|
|
};
|
|
if joined.invite.jam_id != invite.jam_id {
|
|
return Ok(());
|
|
}
|
|
let acknowledged = acknowledged_command_ids.into_iter().collect::<HashSet<_>>();
|
|
joined
|
|
.pending
|
|
.retain(|command| !acknowledged.contains(&command.command_id));
|
|
joined.participants = participants;
|
|
joined.connected = true;
|
|
joined.last_error = None;
|
|
joined.participant.last_seen_ms = now_ms();
|
|
if playback
|
|
.as_ref()
|
|
.is_some_and(|snapshot| snapshot.state.playing && !snapshot.state.paused)
|
|
{
|
|
joined.last_activity_ms = now_ms();
|
|
}
|
|
}
|
|
if let Some(playback) = playback {
|
|
let _ = self.event_tx.send(AppEvent::JamPlayback(playback));
|
|
}
|
|
let _ = self.event_tx.send(AppEvent::JamStatus(self.status()));
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub async fn serve_peers(mut acceptor: StreamAcceptor, manager: Arc<JamManager>) {
|
|
while let Some(stream) = acceptor.accept().await {
|
|
let manager = Arc::clone(&manager);
|
|
tokio::spawn(async move {
|
|
if let Err(err) = serve_one(stream, manager).await {
|
|
tracing::debug!("Jam stream failed: {err:#}");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async fn serve_one(mut stream: ByteStream, manager: Arc<JamManager>) -> Result<()> {
|
|
match read_message(&mut stream).await? {
|
|
WireMessage::Poll {
|
|
version,
|
|
jam_id,
|
|
secret,
|
|
mut participant,
|
|
commands,
|
|
} => {
|
|
let (accepted, error, acknowledged, playback, participants) = {
|
|
let mut state = lock(&manager.state);
|
|
let valid =
|
|
version == PROTOCOL_VERSION
|
|
&& state.host.invite.as_ref().is_some_and(|invite| {
|
|
invite.jam_id == jam_id && invite.secret == secret
|
|
});
|
|
if !valid {
|
|
(
|
|
false,
|
|
Some("invalid Jam capability".to_string()),
|
|
vec![],
|
|
None,
|
|
vec![],
|
|
)
|
|
} else {
|
|
let now = now_ms();
|
|
state.host.participants.retain(|_, row| {
|
|
now.saturating_sub(row.last_seen_ms) <= PARTICIPANT_TTL_MS
|
|
});
|
|
participant.last_seen_ms = now;
|
|
state
|
|
.host
|
|
.participants
|
|
.insert(participant.participant_id.clone(), participant);
|
|
let mut acknowledged = Vec::new();
|
|
for command in commands.into_iter().take(MAX_COMMANDS) {
|
|
acknowledged.push(command.command_id.clone());
|
|
if state.host.seen_commands.insert(command.command_id.clone()) {
|
|
state.host.seen_order.push_back(command.command_id.clone());
|
|
let _ = manager.event_tx.send(AppEvent::JamCommand(command.command));
|
|
}
|
|
}
|
|
while state.host.seen_order.len() > 4096 {
|
|
if let Some(id) = state.host.seen_order.pop_front() {
|
|
state.host.seen_commands.remove(&id);
|
|
}
|
|
}
|
|
(
|
|
true,
|
|
None,
|
|
acknowledged,
|
|
state.host.playback.clone(),
|
|
state.host.participants.values().cloned().collect(),
|
|
)
|
|
}
|
|
};
|
|
write_message(
|
|
&mut stream,
|
|
&WireMessage::Snapshot {
|
|
accepted,
|
|
error,
|
|
acknowledged_command_ids: acknowledged,
|
|
playback,
|
|
participants,
|
|
host_time_ms: now_ms(),
|
|
},
|
|
)
|
|
.await?;
|
|
stream.send.finish()?;
|
|
let _ = tokio::time::timeout(Duration::from_secs(2), stream.send.stopped()).await;
|
|
let _ = manager.event_tx.send(AppEvent::JamStatus(manager.status()));
|
|
}
|
|
WireMessage::Leave {
|
|
version,
|
|
jam_id,
|
|
secret,
|
|
participant_id,
|
|
} => {
|
|
let mut state = lock(&manager.state);
|
|
if version == PROTOCOL_VERSION
|
|
&& state
|
|
.host
|
|
.invite
|
|
.as_ref()
|
|
.is_some_and(|invite| invite.jam_id == jam_id && invite.secret == secret)
|
|
{
|
|
state.host.participants.remove(&participant_id);
|
|
}
|
|
drop(state);
|
|
let _ = manager.event_tx.send(AppEvent::JamStatus(manager.status()));
|
|
}
|
|
WireMessage::Snapshot { .. } => anyhow::bail!("unexpected Jam snapshot"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn poll_loop(manager: Arc<JamManager>, service: Arc<MusicDhtService>) {
|
|
let mut interval = tokio::time::interval(POLL_INTERVAL);
|
|
loop {
|
|
interval.tick().await;
|
|
if manager.status().role != JamRole::Participant {
|
|
continue;
|
|
}
|
|
let expired = {
|
|
let state = lock(&manager.state);
|
|
state.joined.as_ref().is_some_and(|joined| {
|
|
now_ms().saturating_sub(joined.last_activity_ms) > PARTICIPANT_TTL_MS
|
|
})
|
|
};
|
|
if expired {
|
|
manager.leave();
|
|
let _ = manager.event_tx.send(AppEvent::JamStatus(manager.status()));
|
|
let _ = manager.event_tx.send(AppEvent::StatusMessage(
|
|
"Jam ended after 30 minutes without playback or commands".into(),
|
|
));
|
|
continue;
|
|
}
|
|
if let Err(err) = manager.poll_once(Arc::clone(&service)).await {
|
|
{
|
|
let mut state = lock(&manager.state);
|
|
if let Some(joined) = state.joined.as_mut() {
|
|
joined.connected = false;
|
|
joined.last_error = Some(format!("{err:#}"));
|
|
}
|
|
}
|
|
let _ = manager.event_tx.send(AppEvent::JamStatus(manager.status()));
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn write_message(stream: &mut ByteStream, message: &WireMessage) -> Result<()> {
|
|
let mut bytes = serde_json::to_vec(message)?;
|
|
anyhow::ensure!(bytes.len() <= MAX_LINE, "Jam message is too large");
|
|
bytes.push(b'\n');
|
|
stream.send.write_all(&bytes).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn read_message(stream: &mut ByteStream) -> Result<WireMessage> {
|
|
let mut bytes = Vec::new();
|
|
let mut byte = [0_u8; 1];
|
|
loop {
|
|
let read = stream.recv.read(&mut byte).await?;
|
|
if read.unwrap_or(0) == 0 || byte[0] == b'\n' {
|
|
break;
|
|
}
|
|
bytes.push(byte[0]);
|
|
anyhow::ensure!(bytes.len() <= MAX_LINE, "Jam message is too large");
|
|
}
|
|
anyhow::ensure!(!bytes.is_empty(), "empty Jam message");
|
|
Ok(serde_json::from_slice(&bytes)?)
|
|
}
|
|
|
|
fn encode_invite(invite: &JamInvite) -> Result<String> {
|
|
Ok(format!(
|
|
"frid://j/{}",
|
|
base64url_encode(&serde_json::to_vec(invite)?)
|
|
))
|
|
}
|
|
|
|
fn parse_invite(uri: &str) -> Result<JamInvite> {
|
|
let encoded = uri
|
|
.trim()
|
|
.strip_prefix("frid://j/")
|
|
.context("expected frid://j invite")?;
|
|
let invite: JamInvite = serde_json::from_slice(&base64url_decode(encoded)?)?;
|
|
anyhow::ensure!(
|
|
invite.v == PROTOCOL_VERSION,
|
|
"unsupported Jam invite version"
|
|
);
|
|
anyhow::ensure!(
|
|
!invite.ticket.is_empty()
|
|
&& !invite.jam_id.is_empty()
|
|
&& invite.secret.len() >= 16
|
|
&& !invite.host_device_id.is_empty(),
|
|
"incomplete Jam invite"
|
|
);
|
|
Ok(invite)
|
|
}
|
|
|
|
fn now_ms() -> i64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|duration| duration.as_millis() as i64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn random_hex(bytes: usize) -> String {
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static COUNTER: AtomicU64 = AtomicU64::new(1);
|
|
let seed = format!(
|
|
"{}:{}:{}",
|
|
now_ms(),
|
|
std::process::id(),
|
|
COUNTER.fetch_add(1, Ordering::Relaxed)
|
|
);
|
|
let hash = blake3::hash(seed.as_bytes()).to_hex().to_string();
|
|
hash[..(bytes * 2).min(hash.len())].to_string()
|
|
}
|
|
|
|
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
|
mutex
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
}
|
|
|
|
fn base64url_encode(bytes: &[u8]) -> String {
|
|
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
let mut out = String::new();
|
|
for chunk in bytes.chunks(3) {
|
|
let b0 = chunk[0];
|
|
let b1 = chunk.get(1).copied().unwrap_or(0);
|
|
let b2 = chunk.get(2).copied().unwrap_or(0);
|
|
out.push(TABLE[(b0 >> 2) as usize] as char);
|
|
out.push(TABLE[(((b0 & 3) << 4) | (b1 >> 4)) as usize] as char);
|
|
if chunk.len() > 1 {
|
|
out.push(TABLE[(((b1 & 15) << 2) | (b2 >> 6)) as usize] as char);
|
|
}
|
|
if chunk.len() > 2 {
|
|
out.push(TABLE[(b2 & 63) as usize] as char);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn base64url_decode(value: &str) -> Result<Vec<u8>> {
|
|
fn decode(byte: u8) -> Option<u8> {
|
|
match byte {
|
|
b'A'..=b'Z' => Some(byte - b'A'),
|
|
b'a'..=b'z' => Some(byte - b'a' + 26),
|
|
b'0'..=b'9' => Some(byte - b'0' + 52),
|
|
b'-' => Some(62),
|
|
b'_' => Some(63),
|
|
_ => None,
|
|
}
|
|
}
|
|
let bytes = value.as_bytes();
|
|
anyhow::ensure!(bytes.len() % 4 != 1, "invalid base64url Jam invite");
|
|
let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
|
|
let mut i = 0;
|
|
while i < bytes.len() {
|
|
let a = decode(bytes[i]).context("invalid base64url Jam invite")?;
|
|
let b = decode(*bytes.get(i + 1).context("truncated Jam invite")?)
|
|
.context("invalid base64url Jam invite")?;
|
|
let c = bytes.get(i + 2).and_then(|byte| decode(*byte));
|
|
let d = bytes.get(i + 3).and_then(|byte| decode(*byte));
|
|
out.push((a << 2) | (b >> 4));
|
|
if let Some(c) = c {
|
|
out.push((b << 4) | (c >> 2));
|
|
if let Some(d) = d {
|
|
out.push((c << 6) | d);
|
|
}
|
|
}
|
|
i += 4;
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn invite_round_trips_and_is_separate_from_pairing() {
|
|
let invite = JamInvite {
|
|
v: PROTOCOL_VERSION,
|
|
ticket: "ticket".into(),
|
|
jam_id: "jam_test".into(),
|
|
secret: "0123456789abcdef".into(),
|
|
host_device_id: "dev_host".into(),
|
|
host_name: "Host".into(),
|
|
};
|
|
let uri = encode_invite(&invite).unwrap();
|
|
assert!(uri.starts_with("frid://j/"));
|
|
assert_eq!(parse_invite(&uri).unwrap().jam_id, "jam_test");
|
|
assert!(parse_invite("frid://i/abcd").is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn participant_receives_host_state_and_host_receives_command() {
|
|
let unique = random_hex(8);
|
|
let host_dir = std::env::temp_dir().join(format!("furumi-jam-host-{unique}"));
|
|
let guest_dir = std::env::temp_dir().join(format!("furumi-jam-guest-{unique}"));
|
|
std::fs::create_dir_all(&host_dir).unwrap();
|
|
std::fs::create_dir_all(&guest_dir).unwrap();
|
|
let network = music_dht::NetworkId::from_name(&format!("jam-test-{unique}"));
|
|
let host_config = music_dht::MusicDhtConfig::builder()
|
|
.data_dir(&host_dir)
|
|
.network_id(network)
|
|
.stream_protocol(JAM_ALPN)
|
|
.build()
|
|
.unwrap();
|
|
let guest_config = music_dht::MusicDhtConfig::builder()
|
|
.data_dir(&guest_dir)
|
|
.network_id(network)
|
|
.stream_protocol(JAM_ALPN)
|
|
.build()
|
|
.unwrap();
|
|
let (host_service, mut host_events) = music_dht::MusicDhtService::start(host_config)
|
|
.await
|
|
.unwrap();
|
|
let (guest_service, mut guest_events) = music_dht::MusicDhtService::start(guest_config)
|
|
.await
|
|
.unwrap();
|
|
let host_service = Arc::new(host_service);
|
|
let guest_service = Arc::new(guest_service);
|
|
let host_drain = tokio::spawn(async move { while host_events.recv().await.is_some() {} });
|
|
let guest_drain = tokio::spawn(async move { while guest_events.recv().await.is_some() {} });
|
|
|
|
let (host_tx, mut host_rx) = mpsc::unbounded_channel();
|
|
let (guest_tx, mut guest_rx) = mpsc::unbounded_channel();
|
|
let host = JamManager::new(host_tx);
|
|
let guest = JamManager::new(guest_tx);
|
|
let invite = host
|
|
.create_or_regenerate(Arc::clone(&host_service), "dev_host".into(), "Host".into())
|
|
.await
|
|
.unwrap();
|
|
guest
|
|
.join(&invite, "dev_guest".into(), "Guest".into())
|
|
.unwrap();
|
|
|
|
let playback_state = crate::devices::PlaybackStateWire {
|
|
queue: Vec::new(),
|
|
queue_pos: 0,
|
|
playing: false,
|
|
paused: false,
|
|
idle_since_ms: Some(now_ms()),
|
|
position_secs: 0.0,
|
|
volume: 73,
|
|
shuffle: false,
|
|
repeat: crate::devices::PlaybackRepeat::Off,
|
|
};
|
|
host.publish_host_playback(PlaybackSnapshot {
|
|
device_id: "dev_host".into(),
|
|
device_name: "Host".into(),
|
|
active: true,
|
|
updated_at_ms: now_ms(),
|
|
state: playback_state.clone(),
|
|
});
|
|
let acceptor = host_service.stream_acceptor(JAM_ALPN).unwrap();
|
|
let server = tokio::spawn(serve_peers(acceptor, Arc::clone(&host)));
|
|
|
|
guest.poll_once(Arc::clone(&guest_service)).await.unwrap();
|
|
let playback = tokio::time::timeout(Duration::from_secs(3), async {
|
|
loop {
|
|
if let Some(AppEvent::JamPlayback(snapshot)) = guest_rx.recv().await {
|
|
break snapshot;
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(playback.device_id, "dev_host");
|
|
assert_eq!(playback.state.volume, 73);
|
|
|
|
guest
|
|
.submit_command(PlaybackCommand::SetState {
|
|
state: crate::devices::PlaybackStateWire {
|
|
paused: true,
|
|
..playback_state
|
|
},
|
|
seek: false,
|
|
})
|
|
.unwrap();
|
|
guest.poll_once(Arc::clone(&guest_service)).await.unwrap();
|
|
let command = tokio::time::timeout(Duration::from_secs(3), async {
|
|
loop {
|
|
if let Some(AppEvent::JamCommand(command)) = host_rx.recv().await {
|
|
break command;
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(matches!(
|
|
command,
|
|
PlaybackCommand::SetState {
|
|
state: crate::devices::PlaybackStateWire { paused: true, .. },
|
|
seek: false
|
|
}
|
|
));
|
|
|
|
server.abort();
|
|
host_drain.abort();
|
|
guest_drain.abort();
|
|
host_service.shutdown().await.unwrap();
|
|
guest_service.shutdown().await.unwrap();
|
|
let _ = std::fs::remove_dir_all(host_dir);
|
|
let _ = std::fs::remove_dir_all(guest_dir);
|
|
}
|
|
}
|