Added JAM feature

This commit is contained in:
Ultradesu
2026-07-28 18:35:59 +01:00
parent 29466d70e8
commit 1c721b68f5
12 changed files with 1080 additions and 22 deletions
+10
View File
@@ -166,4 +166,14 @@ pub enum AppEvent {
DevicePlayback(crate::devices::PlaybackSnapshot),
/// Playback command addressed to this device.
PlaybackCommand(crate::devices::PlaybackCommand),
/// Current lifecycle/status of the federation Jam.
JamStatus(crate::jam::JamStatus),
/// Host playback snapshot received by a Jam participant.
JamPlayback(crate::devices::PlaybackSnapshot),
/// Participant command accepted by this Jam host.
JamCommand(crate::devices::PlaybackCommand),
/// Result of creating/regenerating a Jam capability.
JamInvite(Result<String, String>),
/// Result of joining a Jam capability.
JamJoined(Result<String, String>),
}
+148 -8
View File
@@ -38,6 +38,7 @@ pub struct Runtime {
pub event_tx: mpsc::UnboundedSender<AppEvent>,
pub library: Arc<Library>,
pub devices: Arc<crate::devices::DeviceSync>,
pub jam: Arc<crate::jam::JamManager>,
pub federation: Arc<crate::federation::Federation>,
/// When the last Federation-tab status snapshot was requested.
pub fed_status_at: Option<std::time::Instant>,
@@ -249,7 +250,12 @@ pub async fn run(
let devices = crate::devices::DeviceSync::new(Arc::clone(&library))?;
devices.set_event_tx(event_tx.clone());
let federation = crate::federation::Federation::new(Arc::clone(&library), Arc::clone(&devices));
let jam = crate::jam::JamManager::new(event_tx.clone());
let federation = crate::federation::Federation::new(
Arc::clone(&library),
Arc::clone(&devices),
Arc::clone(&jam),
);
state.federation.settings = federation.settings();
state.federation.devices = Some(devices.status());
if let Ok((device_id, device_name)) = devices.identity_summary() {
@@ -263,6 +269,7 @@ pub async fn run(
event_tx,
library,
devices,
jam,
federation,
fed_status_at: None,
library_network_refresh_at: None,
@@ -360,7 +367,7 @@ fn sync_player_shared(state: &mut AppState, runtime: &Runtime) {
} else {
runtime.player.shared.audio_analysis()
};
if state.device_playback.role == state::DevicePlaybackRole::Active {
if state.device_playback.is_audio_owner() {
publish_playback_snapshot(state, runtime);
}
}
@@ -474,7 +481,7 @@ fn apply_playback_state_to_ui(
}
fn publish_playback_snapshot(state: &mut AppState, runtime: &Runtime) {
if state.device_playback.role != state::DevicePlaybackRole::Active {
if !state.device_playback.is_audio_owner() {
return;
}
publish_playback_snapshot_with_active(state, runtime, true);
@@ -503,6 +510,19 @@ fn publish_playback_snapshot_with_active(state: &mut AppState, runtime: &Runtime
state: playback_state_from_ui(state),
};
runtime.devices.publish_playback(snapshot);
if state.device_playback.role == state::DevicePlaybackRole::Jam
&& state.device_playback.jam_host
{
runtime
.jam
.publish_host_playback(crate::devices::PlaybackSnapshot {
device_id: state.device_playback.self_device_id.clone(),
device_name: state.device_playback.self_device_name.clone(),
active: true,
updated_at_ms: unix_time_ms(),
state: playback_state_from_ui(state),
});
}
}
fn update_local_idle_since(state: &mut AppState) {
@@ -532,7 +552,7 @@ fn active_idle_lease_expired(snapshot: &crate::devices::PlaybackSnapshot, now: i
}
fn local_active_lease_protected(state: &mut AppState, now: i64) -> bool {
if state.device_playback.role != state::DevicePlaybackRole::Active || !state.player.playing {
if !state.device_playback.is_audio_owner() || !state.player.playing {
return false;
}
if !state.player.paused {
@@ -576,7 +596,7 @@ pub(crate) fn become_control_device(
runtime: &Runtime,
snapshot: crate::devices::PlaybackSnapshot,
) {
if state.device_playback.role == state::DevicePlaybackRole::Active {
if state.device_playback.is_audio_owner() {
runtime.player.stop();
publish_inactive_playback_snapshot(state, runtime);
}
@@ -596,6 +616,7 @@ pub(crate) fn become_control_device(
pub(crate) fn become_active_device(state: &mut AppState, runtime: &mut Runtime, start_audio: bool) {
let was_control = state.device_playback.role == state::DevicePlaybackRole::Control;
state.device_playback.role = state::DevicePlaybackRole::Active;
state.device_playback.jam_host = false;
let Ok((device_id, device_name)) = runtime.devices.identity_summary() else {
return;
};
@@ -617,7 +638,7 @@ pub(crate) fn become_active_device(state: &mut AppState, runtime: &mut Runtime,
}
pub(crate) fn transfer_active_to_this_device(state: &mut AppState, runtime: &mut Runtime) {
if state.device_playback.role == state::DevicePlaybackRole::Active {
if state.device_playback.is_audio_owner() {
publish_playback_snapshot(state, runtime);
request_urgent_device_sync(runtime);
return;
@@ -722,6 +743,12 @@ fn record_control_playback_state(state: &mut AppState, runtime: &Runtime, seek:
state: playback_state_from_ui(state),
seek,
};
if state.device_playback.role == state::DevicePlaybackRole::Jam {
if let Err(err) = runtime.jam.submit_command(command) {
state.status_message = Some(format!("Jam command failed: {err:#}"));
}
return;
}
record_playback_command_async(runtime, target, command, "device command");
}
@@ -1479,10 +1506,41 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
| Effect::DeviceSetName(_)
| Effect::DeviceRevoke(_)
| Effect::DeviceLeaveGroup
| Effect::JamCreate
| Effect::JamJoin(_)
if !state.connected_devices_enabled() =>
{
state.status_message = Some("enable federation before using connected devices".into());
}
Effect::JamCreate => {
let federation = Arc::clone(&runtime.federation);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = federation
.create_jam()
.await
.map_err(|err| format!("{err:#}"));
let _ = tx.send(AppEvent::JamInvite(result));
});
}
Effect::JamJoin(invite) => {
let result = runtime
.federation
.join_jam(&invite)
.map(|()| "joined Jam; waiting for host state".to_string())
.map_err(|err| format!("{err:#}"));
let _ = runtime.event_tx.send(AppEvent::JamJoined(result));
let _ = runtime
.event_tx
.send(AppEvent::JamStatus(runtime.jam.status()));
}
Effect::JamLeave => {
runtime.jam.leave();
state.jam = runtime.jam.status();
state.device_playback.role = state::DevicePlaybackRole::Active;
state.device_playback.jam_host = false;
state.status_message = Some("left Jam".into());
}
Effect::DeviceShowInvite => {
let fed = Arc::clone(&runtime.federation);
let devices = Arc::clone(&runtime.devices);
@@ -1679,6 +1737,8 @@ fn is_controlled_playback_effect(effect: &Effect) -> bool {
fn perform_control_playback_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
let mut seek = false;
let local_only_volume = state.device_playback.role == state::DevicePlaybackRole::Jam
&& matches!(effect, Effect::SetVolume(_));
match effect {
Effect::PlayCurrent => {
state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
@@ -1708,6 +1768,9 @@ fn perform_control_playback_effect(state: &mut AppState, runtime: &mut Runtime,
| Effect::LoadListenHistory => {}
_ => {}
}
if local_only_volume {
return;
}
record_control_playback_state(state, runtime, seek);
}
@@ -2753,7 +2816,7 @@ fn handle_device_playback_snapshot(
let already_controls_this_device = state.device_playback.is_control()
&& state.device_playback.active_device_id.as_deref() == Some(snapshot.device_id.as_str());
if !lease_expired || already_controls_this_device {
let was_active = state.device_playback.role == state::DevicePlaybackRole::Active;
let was_active = state.device_playback.is_audio_owner();
let was_paused = state.player.playing && state.player.paused;
become_control_device(state, runtime, snapshot.clone());
if was_active && was_paused {
@@ -2829,7 +2892,7 @@ fn handle_playback_command(
if active_device_id == state.device_playback.self_device_id {
return;
}
let was_active = state.device_playback.role == state::DevicePlaybackRole::Active;
let was_active = state.device_playback.is_audio_owner();
let snapshot = crate::devices::PlaybackSnapshot {
device_id: active_device_id,
device_name: active_device_name,
@@ -2962,6 +3025,83 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
AppEvent::PlaybackCommand(command) => {
handle_playback_command(state, runtime, command);
}
AppEvent::JamStatus(status) => {
state.jam = status.clone();
match status.role {
crate::jam::JamRole::Host => {
state.device_playback.role = state::DevicePlaybackRole::Jam;
state.device_playback.jam_host = true;
publish_playback_snapshot(state, runtime);
}
crate::jam::JamRole::Participant => {
if state.device_playback.is_audio_owner() {
runtime.player.stop();
runtime.player_start_pending = false;
}
state.device_playback.role = state::DevicePlaybackRole::Jam;
state.device_playback.jam_host = false;
}
crate::jam::JamRole::None => {
if state.device_playback.role == state::DevicePlaybackRole::Jam {
state.device_playback.role = state::DevicePlaybackRole::Active;
state.device_playback.jam_host = false;
}
}
}
}
AppEvent::JamPlayback(snapshot) => {
let local_volume = state.player.volume;
become_control_device(state, runtime, snapshot);
state.device_playback.role = state::DevicePlaybackRole::Jam;
state.device_playback.jam_host = false;
state.player.volume = local_volume;
state.status_message = Some(format!(
"Jam · controlling {}",
state.device_playback.active_label()
));
}
AppEvent::JamCommand(command) => {
let command = match command {
crate::devices::PlaybackCommand::SetState {
state: mut wire,
seek,
} => {
wire.volume = state.player.volume;
crate::devices::PlaybackCommand::SetState { state: wire, seek }
}
crate::devices::PlaybackCommand::ActiveChanged { .. } => {
state.status_message =
Some("Jam cannot transfer audio away from its host".into());
return;
}
};
handle_playback_command(state, runtime, command);
state.device_playback.role = state::DevicePlaybackRole::Jam;
state.device_playback.jam_host = true;
publish_playback_snapshot(state, runtime);
}
AppEvent::JamInvite(result) => match result {
Ok(invite) => {
if !state.device_playback.is_audio_owner() {
transfer_active_to_this_device(state, runtime);
}
state.jam = runtime.jam.status();
state.device_playback.role = state::DevicePlaybackRole::Jam;
state.device_playback.jam_host = true;
publish_playback_snapshot(state, runtime);
state.popup = Some(state::Popup::FedCopyText {
title: "Jam invite".to_string(),
text: invite,
help: "Copied capability lets federation peers control this host player until restart or regeneration.".to_string(),
});
state.status_message = Some("Jam started".into());
}
Err(error) => state.status_message = Some(format!("Jam: {error}")),
},
AppEvent::JamJoined(result) => match result {
Ok(message) => state.status_message = Some(message),
Err(error) => state.status_message = Some(format!("Jam: {error}")),
},
AppEvent::FedSearchLoaded { seq, result } => {
if runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) != seq {
return;
+38 -1
View File
@@ -82,7 +82,7 @@ pub(crate) fn connected_device_rows(state: &AppState) -> Vec<ConnectedDevicePopu
is_self: true,
online: true,
section: DevicePresenceSection::Online,
active: state.device_playback.role == crate::app::state::DevicePlaybackRole::Active,
active: state.device_playback.is_audio_owner(),
playing: state.player.playing,
paused: state.player.paused,
queue_len: state.player.queue.len(),
@@ -386,6 +386,32 @@ fn handle_connected_devices(
let cursor = cursor.min(last);
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {}
KeyCode::Char('h') => {
super::perform_effect(state, runtime, crate::app::update::Effect::JamCreate);
}
KeyCode::Char('J') => {
state.popup = Some(Popup::FedInput {
field: FedInputField::JamInvite,
input: crate::app::input::LineEdit::default(),
});
}
KeyCode::Char('l') if state.jam.role != crate::jam::JamRole::None => {
super::perform_effect(state, runtime, crate::app::update::Effect::JamLeave);
}
KeyCode::Char('c') => {
if let Some(invite) = state.jam.invite.as_deref() {
match copy_to_clipboard(invite) {
Ok(()) => state.status_message = Some("Jam invite copied".into()),
Err(error) => {
state.status_message = Some(format!("clipboard: {error}"));
state.popup = Some(Popup::ConnectedDevices { cursor });
}
}
} else {
state.status_message = Some("only the Jam host has an invite".into());
state.popup = Some(Popup::ConnectedDevices { cursor });
}
}
KeyCode::Up | KeyCode::Char('k') => {
state.popup = Some(Popup::ConnectedDevices {
cursor: cursor.saturating_sub(1),
@@ -499,6 +525,17 @@ fn handle_fed_input(
);
}
}
FedInputField::JamInvite => {
if value.is_empty() {
state.status_message = Some("Jam invite is empty".into());
} else {
super::perform_effect(
state,
runtime,
crate::app::update::Effect::JamJoin(value),
);
}
}
}
}
_ => {
+15
View File
@@ -805,6 +805,7 @@ pub enum FedInputField {
ConnectTicket,
DeviceName,
ConnectInvite,
JamInvite,
}
impl FedInputField {
@@ -814,6 +815,7 @@ impl FedInputField {
FedInputField::ConnectTicket => "Connect to peer (paste ticket)",
FedInputField::DeviceName => "Device name",
FedInputField::ConnectInvite => "Connect device (paste frid://i invite)",
FedInputField::JamInvite => "Join Jam (paste frid://j invite)",
}
}
@@ -831,6 +833,9 @@ impl FedInputField {
FedInputField::ConnectInvite => {
"Paste a frid:// invite generated by another client to add this device to its sync group."
}
FedInputField::JamInvite => {
"Paste a frid://j capability to control the host player for this Jam only."
}
}
}
}
@@ -1210,6 +1215,7 @@ pub enum DevicePlaybackRole {
#[default]
Active,
Control,
Jam,
}
impl DevicePlaybackRole {
@@ -1217,6 +1223,7 @@ impl DevicePlaybackRole {
match self {
DevicePlaybackRole::Active => "active",
DevicePlaybackRole::Control => "control",
DevicePlaybackRole::Jam => "jam",
}
}
}
@@ -1232,11 +1239,18 @@ pub struct DevicePlaybackState {
pub local_idle_since_ms: Option<i64>,
pub remote: BTreeMap<String, crate::devices::PlaybackSnapshot>,
pub last_remote_snapshot: Option<crate::devices::PlaybackSnapshot>,
pub jam_host: bool,
}
impl DevicePlaybackState {
pub fn is_control(&self) -> bool {
self.role == DevicePlaybackRole::Control
|| (self.role == DevicePlaybackRole::Jam && !self.jam_host)
}
pub fn is_audio_owner(&self) -> bool {
self.role == DevicePlaybackRole::Active
|| (self.role == DevicePlaybackRole::Jam && self.jam_host)
}
pub fn active_label(&self) -> String {
@@ -1264,6 +1278,7 @@ pub struct AppState {
pub settings_cursor: usize,
pub player: PlayerBar,
pub device_playback: DevicePlaybackState,
pub jam: crate::jam::JamStatus,
pub visualizer: crate::visualizer::VisualizerState,
pub global: GlobalTab,
pub artist_views: HashMap<i64, Loadable<ArtistDetail>>,
+6
View File
@@ -68,6 +68,12 @@ pub enum Effect {
DeviceRevoke(String),
/// Leave the current personal-device group after publishing self-revoke.
DeviceLeaveGroup,
/// Create or regenerate the runtime Jam capability.
JamCreate,
/// Join a federation Jam by capability.
JamJoin(String),
/// Leave the current Jam without changing personal-device state.
JamLeave,
/// Assemble the federated artist card (fan-out to the owning peers).
FedOpenArtist(String),
/// Download federated tracks into the local library, one by one.