Connected Devices: Added sync feature
This commit is contained in:
@@ -50,6 +50,7 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
|
||||
Command::Quit
|
||||
| Command::Import(_)
|
||||
| Command::Open(_)
|
||||
| Command::ConnectInvite(_)
|
||||
| Command::Volume(_)
|
||||
| Command::Seek(_)
|
||||
| Command::SeekTo(_)
|
||||
@@ -167,6 +168,7 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
|
||||
Command::Quit => state.should_quit = true,
|
||||
Command::Import(path) => super::spawn_import(state, runtime, &path),
|
||||
Command::Open(link) => open_frid_link(state, runtime, link),
|
||||
Command::ConnectInvite(invite) => super::device_connect(runtime, invite),
|
||||
Command::Volume(value) => {
|
||||
state.player.volume = value;
|
||||
super::perform_effect(state, runtime, Effect::SetVolume(value));
|
||||
|
||||
@@ -27,6 +27,8 @@ pub enum Command {
|
||||
Import(String),
|
||||
/// `:open frid://...` — open a shared federation content link.
|
||||
Open(String),
|
||||
/// `:connect frid://i/...` — pair this client with a trusted device.
|
||||
ConnectInvite(String),
|
||||
/// `:volume 40` (also `:vol`) — set the volume precisely.
|
||||
Volume(u8),
|
||||
/// `:seek +30` / `:seek -10` — relative seek in seconds.
|
||||
@@ -91,6 +93,15 @@ pub fn parse(input: &str) -> Parsed {
|
||||
_ => Parsed::Invalid("usage: :open frid://<content_id>".to_string()),
|
||||
}
|
||||
}
|
||||
"connect" => {
|
||||
let value = input.trim_start().split_once(char::is_whitespace);
|
||||
match value.map(|(_, rest)| rest.trim()) {
|
||||
Some(value) if !value.is_empty() => {
|
||||
Parsed::Command(Command::ConnectInvite(value.into()))
|
||||
}
|
||||
_ => Parsed::Invalid("usage: :connect frid://i/<invite>".to_string()),
|
||||
}
|
||||
}
|
||||
"volume" | "vol" => match arg.and_then(|a| a.parse::<u8>().ok()) {
|
||||
Some(value) if value <= 100 => Parsed::Command(Command::Volume(value)),
|
||||
_ => Parsed::Invalid("usage: :volume 0-100".to_string()),
|
||||
@@ -182,6 +193,10 @@ mod tests {
|
||||
);
|
||||
assert!(matches!(parse("import"), Parsed::Invalid(_)));
|
||||
assert!(matches!(parse("open"), Parsed::Invalid(_)));
|
||||
assert_eq!(
|
||||
parse("connect frid://i/abcd"),
|
||||
Parsed::Command(Command::ConnectInvite("frid://i/abcd".to_string()))
|
||||
);
|
||||
assert_eq!(parse("volume 40"), Parsed::Command(Command::Volume(40)));
|
||||
assert_eq!(parse("vol 0"), Parsed::Command(Command::Volume(0)));
|
||||
assert_eq!(parse("shuffle"), Parsed::Command(Command::Shuffle));
|
||||
|
||||
@@ -128,4 +128,12 @@ pub enum AppEvent {
|
||||
},
|
||||
/// This peer's connection ticket, requested from the Federation tab.
|
||||
FedTicket(Result<String, String>),
|
||||
/// Fresh personal-device sync status snapshot for Settings.
|
||||
DeviceSyncStatus(crate::devices::DeviceSyncStatus),
|
||||
/// Invite link for pairing another device.
|
||||
DeviceInvite(Result<String, String>),
|
||||
/// Result of `:connect frid://i/...`.
|
||||
DeviceConnectResult(Result<String, String>),
|
||||
/// Incoming pairing request that passed the invite-secret check.
|
||||
DevicePairingRequest(crate::devices::PendingPairing),
|
||||
}
|
||||
|
||||
+178
-6
@@ -7,9 +7,9 @@ mod popup;
|
||||
pub mod state;
|
||||
pub mod update;
|
||||
|
||||
use std::io;
|
||||
use std::io::{self, Write as _};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -36,6 +36,7 @@ const VISUALIZER_TICK_INTERVAL: Duration = Duration::from_millis(50);
|
||||
pub struct Runtime {
|
||||
pub event_tx: mpsc::UnboundedSender<AppEvent>,
|
||||
pub library: Arc<Library>,
|
||||
pub devices: Arc<crate::devices::DeviceSync>,
|
||||
pub federation: Arc<crate::federation::Federation>,
|
||||
/// When the last Federation-tab status snapshot was requested.
|
||||
pub fed_status_at: Option<std::time::Instant>,
|
||||
@@ -92,12 +93,16 @@ pub async fn run(
|
||||
state.status_message = Some(format!("visualizations disabled: {err:#}"));
|
||||
}
|
||||
|
||||
let federation = crate::federation::Federation::new(Arc::clone(&library));
|
||||
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));
|
||||
state.federation.settings = federation.settings();
|
||||
state.federation.devices = Some(devices.status());
|
||||
let player_events = event_tx.clone();
|
||||
let mut runtime = Runtime {
|
||||
event_tx,
|
||||
library,
|
||||
devices,
|
||||
federation,
|
||||
fed_status_at: None,
|
||||
fed_resolving: std::sync::Mutex::new(std::collections::HashSet::new()),
|
||||
@@ -114,11 +119,13 @@ pub async fn run(
|
||||
|
||||
{
|
||||
let fed = Arc::clone(&runtime.federation);
|
||||
let devices = Arc::clone(&runtime.devices);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
fed.start_if_enabled().await;
|
||||
let status = fed.status().await;
|
||||
let _ = tx.send(AppEvent::FederationStatus(status));
|
||||
let _ = tx.send(AppEvent::DeviceSyncStatus(devices.status()));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -430,11 +437,15 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||
return;
|
||||
}
|
||||
let library = Arc::clone(&runtime.library);
|
||||
let devices = Arc::clone(&runtime.devices);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
for track_id in track_ids {
|
||||
match library.toggle_like(track_id) {
|
||||
Ok(liked) => {
|
||||
if let Err(err) = devices.record_track_like(track_id, liked) {
|
||||
tracing::warn!(%err, track_id, "recording synced like failed");
|
||||
}
|
||||
let _ = tx.send(AppEvent::LikeToggled { track_id, liked });
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -448,6 +459,11 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||
for fed in fed_tracks {
|
||||
match library.toggle_fed_like(&fed) {
|
||||
Ok(liked) => {
|
||||
if let Err(err) =
|
||||
devices.record_fed_like(fed.content_id.as_deref(), liked)
|
||||
{
|
||||
tracing::warn!(%err, title = %fed.title, "recording synced federated like failed");
|
||||
}
|
||||
let _ = tx.send(AppEvent::FedLikeToggled {
|
||||
item_id: fed.item_id.clone(),
|
||||
liked,
|
||||
@@ -468,12 +484,20 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||
track_ids,
|
||||
} => {
|
||||
let library = Arc::clone(&runtime.library);
|
||||
let devices = Arc::clone(&runtime.devices);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let event = match library.remove_tracks_from_playlist(playlist_id, &track_ids) {
|
||||
Ok(()) => AppEvent::LibraryChanged {
|
||||
message: Some(format!("removed {} track(s)", track_ids.len())),
|
||||
},
|
||||
Ok(()) => {
|
||||
if let Err(err) =
|
||||
devices.record_playlist_tracks_removed(playlist_id, &track_ids)
|
||||
{
|
||||
tracing::warn!(%err, playlist_id, "recording synced playlist removal failed");
|
||||
}
|
||||
AppEvent::LibraryChanged {
|
||||
message: Some(format!("removed {} track(s)", track_ids.len())),
|
||||
}
|
||||
}
|
||||
Err(err) => AppEvent::StatusMessage(format!("remove failed: {err:#}")),
|
||||
};
|
||||
let _ = tx.send(event);
|
||||
@@ -500,6 +524,57 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||
let _ = tx.send(AppEvent::FedTicket(result));
|
||||
});
|
||||
}
|
||||
Effect::DeviceShowInvite => {
|
||||
let fed = Arc::clone(&runtime.federation);
|
||||
let devices = Arc::clone(&runtime.devices);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = fed.device_invite().await.map_err(|err| format!("{err:#}"));
|
||||
let _ = tx.send(AppEvent::DeviceInvite(result));
|
||||
let _ = tx.send(AppEvent::FederationStatus(fed.status().await));
|
||||
let _ = tx.send(AppEvent::DeviceSyncStatus(devices.status()));
|
||||
});
|
||||
}
|
||||
Effect::DeviceConnectInvite(invite) => device_connect(runtime, invite),
|
||||
Effect::DeviceSyncNow => {
|
||||
let fed = Arc::clone(&runtime.federation);
|
||||
let devices = Arc::clone(&runtime.devices);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let message = match fed.device_sync_now().await {
|
||||
Ok(()) => "devices: sync complete".to_string(),
|
||||
Err(err) => format!("devices: {err:#}"),
|
||||
};
|
||||
let _ = tx.send(AppEvent::DeviceSyncStatus(devices.status()));
|
||||
let _ = tx.send(AppEvent::StatusMessage(message));
|
||||
});
|
||||
}
|
||||
Effect::DeviceSetName(name) => {
|
||||
let fed = Arc::clone(&runtime.federation);
|
||||
let devices = Arc::clone(&runtime.devices);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let ticket = fed.ticket().await.ok();
|
||||
let message = match devices.set_device_name(&name, ticket.as_deref()) {
|
||||
Ok(()) => "device name saved".to_string(),
|
||||
Err(err) => format!("device name: {err:#}"),
|
||||
};
|
||||
let _ = tx.send(AppEvent::DeviceSyncStatus(devices.status()));
|
||||
let _ = tx.send(AppEvent::StatusMessage(message));
|
||||
});
|
||||
}
|
||||
Effect::DeviceRevoke(device_id) => {
|
||||
let devices = Arc::clone(&runtime.devices);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let message = match devices.revoke_device(&device_id) {
|
||||
Ok(()) => format!("device {} revoked", &device_id[..device_id.len().min(10)]),
|
||||
Err(err) => format!("revoke failed: {err:#}"),
|
||||
};
|
||||
let _ = tx.send(AppEvent::DeviceSyncStatus(devices.status()));
|
||||
let _ = tx.send(AppEvent::StatusMessage(message));
|
||||
});
|
||||
}
|
||||
Effect::FedOpenArtist(name) => {
|
||||
let fed = Arc::clone(&runtime.federation);
|
||||
let tx = runtime.event_tx.clone();
|
||||
@@ -646,6 +721,45 @@ fn shell_quote(path: &Path) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
fn copy_text_to_clipboard(text: &str) -> bool {
|
||||
let command: &[&str] = if cfg!(target_os = "macos") {
|
||||
&["pbcopy"]
|
||||
} else if cfg!(target_os = "windows") {
|
||||
&["clip"]
|
||||
} else {
|
||||
&["wl-copy"]
|
||||
};
|
||||
let Some((program, args)) = command.split_first() else {
|
||||
return false;
|
||||
};
|
||||
let mut child = match Command::new(program)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(_) if !cfg!(target_os = "macos") && !cfg!(target_os = "windows") => {
|
||||
match Command::new("xclip")
|
||||
.args(["-selection", "clipboard"])
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
Err(_) => return false,
|
||||
};
|
||||
let Some(mut stdin) = child.stdin.take() else {
|
||||
return false;
|
||||
};
|
||||
if stdin.write_all(text.as_bytes()).is_err() {
|
||||
return false;
|
||||
}
|
||||
drop(stdin);
|
||||
child.wait().is_ok_and(|status| status.success())
|
||||
}
|
||||
|
||||
/// Start playing `queue[queue_pos]`: open the local file in a background
|
||||
/// task and hand the reader to the audio thread.
|
||||
fn play_current(state: &mut AppState, runtime: &mut Runtime) {
|
||||
@@ -819,6 +933,22 @@ pub(crate) fn fed_connect(runtime: &Runtime, ticket: String) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Pair with another trusted client by an opaque `frid://i/...` invite.
|
||||
pub(crate) fn device_connect(runtime: &Runtime, invite: String) {
|
||||
let fed = Arc::clone(&runtime.federation);
|
||||
let devices = Arc::clone(&runtime.devices);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = fed
|
||||
.device_connect(&invite)
|
||||
.await
|
||||
.map_err(|err| format!("{err:#}"));
|
||||
let _ = tx.send(AppEvent::FederationStatus(fed.status().await));
|
||||
let _ = tx.send(AppEvent::DeviceSyncStatus(devices.status()));
|
||||
let _ = tx.send(AppEvent::DeviceConnectResult(result));
|
||||
});
|
||||
}
|
||||
|
||||
/// Downloads one pending federated track (into the cache, or the library
|
||||
/// when save-on-listen is enabled) and reports back with the placeholder id
|
||||
/// so the queue can swap the resolved track in.
|
||||
@@ -863,6 +993,7 @@ pub(crate) fn fed_download_spawn(
|
||||
}
|
||||
let fed = Arc::clone(&runtime.federation);
|
||||
let library = Arc::clone(&runtime.library);
|
||||
let devices = Arc::clone(&runtime.devices);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let total = tracks.len();
|
||||
@@ -890,12 +1021,19 @@ pub(crate) fn fed_download_spawn(
|
||||
&& !imported_ids.is_empty()
|
||||
{
|
||||
let library = Arc::clone(&library);
|
||||
let devices = Arc::clone(&devices);
|
||||
let tx_add = tx.clone();
|
||||
let title = playlist_title.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = library
|
||||
.add_tracks_to_playlist(playlist_id, &imported_ids)
|
||||
.map_err(|err| format!("{err:#}"));
|
||||
if result.is_ok()
|
||||
&& let Err(err) =
|
||||
devices.record_playlist_tracks_added(playlist_id, &imported_ids)
|
||||
{
|
||||
tracing::warn!(%err, playlist_id, "recording synced playlist add failed");
|
||||
}
|
||||
let _ = tx_add.send(AppEvent::PlaylistTracksAdded {
|
||||
playlist_id,
|
||||
playlist_title: title,
|
||||
@@ -912,9 +1050,11 @@ pub(crate) fn fed_download_spawn(
|
||||
/// Request a fresh status snapshot for the Federation tab.
|
||||
fn fed_spawn_status(runtime: &Runtime) {
|
||||
let fed = Arc::clone(&runtime.federation);
|
||||
let devices = Arc::clone(&runtime.devices);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = tx.send(AppEvent::FederationStatus(fed.status().await));
|
||||
let _ = tx.send(AppEvent::DeviceSyncStatus(devices.status()));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1221,6 +1361,38 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
AppEvent::FederationStatus(status) => {
|
||||
state.federation.status = Some(status);
|
||||
}
|
||||
AppEvent::DeviceSyncStatus(status) => {
|
||||
state.federation.devices = Some(status);
|
||||
clamp_settings_cursor(state);
|
||||
}
|
||||
AppEvent::DeviceInvite(result) => match result {
|
||||
Ok(invite) => {
|
||||
let copied = copy_text_to_clipboard(&invite);
|
||||
state.popup = Some(state::Popup::FedText {
|
||||
title: "Device invite".to_string(),
|
||||
text: invite,
|
||||
});
|
||||
state.status_message = Some(if copied {
|
||||
"device invite copied to clipboard".to_string()
|
||||
} else {
|
||||
"device invite generated".to_string()
|
||||
});
|
||||
}
|
||||
Err(message) => state.status_message = Some(format!("device invite: {message}")),
|
||||
},
|
||||
AppEvent::DeviceConnectResult(result) => match result {
|
||||
Ok(message) => state.status_message = Some(message),
|
||||
Err(message) => state.status_message = Some(format!("connect failed: {message}")),
|
||||
},
|
||||
AppEvent::DevicePairingRequest(request) => {
|
||||
state.popup = Some(state::Popup::DevicePairing {
|
||||
request_id: request.request_id,
|
||||
device_id: request.device_id,
|
||||
name: request.name,
|
||||
client_version: request.client_version,
|
||||
});
|
||||
state.federation.devices = Some(runtime.devices.status());
|
||||
}
|
||||
AppEvent::FedSearchLoaded { seq, result } => {
|
||||
if runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) != seq {
|
||||
return;
|
||||
|
||||
+123
-3
@@ -61,6 +61,23 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
||||
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
|
||||
_ => state.popup = Some(Popup::FedText { title, text }),
|
||||
},
|
||||
Popup::DevicePairing {
|
||||
request_id,
|
||||
device_id,
|
||||
name,
|
||||
client_version,
|
||||
} => handle_device_pairing(
|
||||
state,
|
||||
runtime,
|
||||
request_id,
|
||||
device_id,
|
||||
name,
|
||||
client_version,
|
||||
key,
|
||||
),
|
||||
Popup::ConfirmDeviceRevoke { device_id, name } => {
|
||||
handle_device_revoke(state, runtime, device_id, name, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +101,7 @@ fn handle_library_filters(state: &mut AppState, runtime: &Runtime, cursor: usize
|
||||
/// One-line text entry on the Federation tab (network id / peer ticket).
|
||||
fn handle_fed_input(
|
||||
state: &mut AppState,
|
||||
runtime: &Runtime,
|
||||
runtime: &mut Runtime,
|
||||
field: FedInputField,
|
||||
mut input: crate::app::input::LineEdit,
|
||||
key: KeyEvent,
|
||||
@@ -110,6 +127,28 @@ fn handle_fed_input(
|
||||
super::fed_connect(runtime, value);
|
||||
}
|
||||
}
|
||||
FedInputField::DeviceName => {
|
||||
if value.is_empty() {
|
||||
state.status_message = Some("device name is empty".into());
|
||||
} else {
|
||||
super::perform_effect(
|
||||
state,
|
||||
runtime,
|
||||
crate::app::update::Effect::DeviceSetName(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
FedInputField::ConnectInvite => {
|
||||
if value.is_empty() {
|
||||
state.status_message = Some("invite is empty".into());
|
||||
} else {
|
||||
super::perform_effect(
|
||||
state,
|
||||
runtime,
|
||||
crate::app::update::Effect::DeviceConnectInvite(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
@@ -119,6 +158,63 @@ fn handle_fed_input(
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_device_pairing(
|
||||
state: &mut AppState,
|
||||
runtime: &Runtime,
|
||||
request_id: String,
|
||||
device_id: String,
|
||||
name: String,
|
||||
client_version: String,
|
||||
key: KeyEvent,
|
||||
) {
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => {
|
||||
if let Err(err) = runtime.devices.answer_pairing(&request_id, false) {
|
||||
state.status_message = Some(format!("pairing: {err:#}"));
|
||||
} else {
|
||||
state.status_message = Some("device pairing denied".to_string());
|
||||
}
|
||||
state.federation.devices = Some(runtime.devices.status());
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char('y') => {
|
||||
if let Err(err) = runtime.devices.answer_pairing(&request_id, true) {
|
||||
state.status_message = Some(format!("pairing: {err:#}"));
|
||||
} else {
|
||||
state.status_message = Some(format!("device \"{name}\" accepted"));
|
||||
}
|
||||
state.federation.devices = Some(runtime.devices.status());
|
||||
}
|
||||
_ => {
|
||||
state.popup = Some(Popup::DevicePairing {
|
||||
request_id,
|
||||
device_id,
|
||||
name,
|
||||
client_version,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_device_revoke(
|
||||
state: &mut AppState,
|
||||
runtime: &mut Runtime,
|
||||
device_id: String,
|
||||
name: String,
|
||||
key: KeyEvent,
|
||||
) {
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => {}
|
||||
KeyCode::Enter | KeyCode::Char('y') => {
|
||||
super::perform_effect(
|
||||
state,
|
||||
runtime,
|
||||
crate::app::update::Effect::DeviceRevoke(device_id),
|
||||
);
|
||||
}
|
||||
_ => state.popup = Some(Popup::ConfirmDeviceRevoke { device_id, name }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pasted text goes into the focused text field when one is open.
|
||||
pub fn handle_paste(state: &mut AppState, pasted: &str) {
|
||||
let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect();
|
||||
@@ -278,7 +374,13 @@ fn save_edit(
|
||||
if title.is_empty() {
|
||||
return Err("title is empty".to_string());
|
||||
}
|
||||
library.update_playlist(id, &title, None)
|
||||
let result = library.update_playlist(id, &title, None);
|
||||
if result.is_ok()
|
||||
&& let Err(err) = runtime.devices.record_playlist_renamed(id, &title)
|
||||
{
|
||||
tracing::warn!(%err, playlist = id, "recording synced playlist rename failed");
|
||||
}
|
||||
result
|
||||
}
|
||||
};
|
||||
match result {
|
||||
@@ -311,7 +413,13 @@ fn handle_confirm_delete(
|
||||
DeleteTarget::Track(id) => library.delete_track(id),
|
||||
DeleteTarget::Release(id) => library.delete_release(id),
|
||||
DeleteTarget::Artist(id) => library.delete_artist(id),
|
||||
DeleteTarget::Playlist(id) => library.delete_playlist(id),
|
||||
DeleteTarget::Playlist(id) => match runtime.devices.record_playlist_deleted(id) {
|
||||
Ok(()) => library.delete_playlist(id),
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, playlist = id, "recording synced playlist deletion failed");
|
||||
library.delete_playlist(id)
|
||||
}
|
||||
},
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
@@ -656,12 +764,18 @@ pub(crate) fn spawn_add_target(
|
||||
match target {
|
||||
crate::app::state::PlaylistAddTarget::Local(tracks) => {
|
||||
let library = Arc::clone(&runtime.library);
|
||||
let devices = Arc::clone(&runtime.devices);
|
||||
let tx = runtime.event_tx.clone();
|
||||
let ids: Vec<i64> = tracks.iter().map(|t| t.id).filter(|id| *id >= 0).collect();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = library
|
||||
.add_tracks_to_playlist(playlist_id, &ids)
|
||||
.map_err(|err| format!("{err:#}"));
|
||||
if result.is_ok()
|
||||
&& let Err(err) = devices.record_playlist_tracks_added(playlist_id, &ids)
|
||||
{
|
||||
tracing::warn!(%err, playlist_id, "recording synced playlist add failed");
|
||||
}
|
||||
let _ = tx.send(AppEvent::PlaylistTracksAdded {
|
||||
playlist_id,
|
||||
playlist_title,
|
||||
@@ -681,11 +795,17 @@ fn spawn_create_playlist(
|
||||
add_target: Option<crate::app::state::PlaylistAddTarget>,
|
||||
) {
|
||||
let library = Arc::clone(&runtime.library);
|
||||
let devices = Arc::clone(&runtime.devices);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = library
|
||||
.create_playlist(&title)
|
||||
.map_err(|err| format!("{err:#}"));
|
||||
if let Ok(playlist) = &result
|
||||
&& let Err(err) = devices.record_playlist_created(playlist.id, &playlist.title)
|
||||
{
|
||||
tracing::warn!(%err, playlist = playlist.id, "recording synced playlist creation failed");
|
||||
}
|
||||
let _ = tx.send(AppEvent::PlaylistCreated { result, add_target });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -530,12 +530,23 @@ pub enum Popup {
|
||||
},
|
||||
/// Wrapped read-only text (this peer's connection ticket).
|
||||
FedText { title: String, text: String },
|
||||
/// Incoming trusted-device pairing request.
|
||||
DevicePairing {
|
||||
request_id: String,
|
||||
device_id: String,
|
||||
name: String,
|
||||
client_version: String,
|
||||
},
|
||||
/// Confirmation before revoking a trusted device.
|
||||
ConfirmDeviceRevoke { device_id: String, name: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FedInputField {
|
||||
NetworkId,
|
||||
ConnectTicket,
|
||||
DeviceName,
|
||||
ConnectInvite,
|
||||
}
|
||||
|
||||
impl FedInputField {
|
||||
@@ -543,6 +554,8 @@ impl FedInputField {
|
||||
match self {
|
||||
FedInputField::NetworkId => "Network ID",
|
||||
FedInputField::ConnectTicket => "Connect to peer (paste ticket)",
|
||||
FedInputField::DeviceName => "Device name",
|
||||
FedInputField::ConnectInvite => "Connect device (paste frid://i invite)",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -575,6 +588,11 @@ impl FedRow {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SettingsRow {
|
||||
Federation(FedRow),
|
||||
DeviceName,
|
||||
DeviceInvite,
|
||||
DeviceConnect,
|
||||
DeviceSyncNow,
|
||||
Device(usize),
|
||||
VisualizationClock,
|
||||
VisualizationScript(usize),
|
||||
VisualizationNew,
|
||||
@@ -584,6 +602,13 @@ pub enum SettingsRow {
|
||||
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
|
||||
let mut rows = Vec::new();
|
||||
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
|
||||
rows.push(SettingsRow::DeviceName);
|
||||
rows.push(SettingsRow::DeviceInvite);
|
||||
rows.push(SettingsRow::DeviceConnect);
|
||||
rows.push(SettingsRow::DeviceSyncNow);
|
||||
if let Some(status) = &state.federation.devices {
|
||||
rows.extend((0..status.devices.len()).map(SettingsRow::Device));
|
||||
}
|
||||
rows.push(SettingsRow::VisualizationClock);
|
||||
rows.extend(
|
||||
state
|
||||
@@ -605,6 +630,7 @@ pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
|
||||
pub struct FederationTab {
|
||||
pub settings: crate::federation::FedSettings,
|
||||
pub status: Option<crate::federation::FedStatus>,
|
||||
pub devices: Option<crate::devices::DeviceSyncStatus>,
|
||||
}
|
||||
|
||||
/// Playlists eligible as add-targets (the virtual Likes playlist is managed
|
||||
|
||||
@@ -49,6 +49,16 @@ pub enum Effect {
|
||||
FedSyncNow,
|
||||
/// Fetch this peer's ticket and show it in a popup.
|
||||
FedShowTicket,
|
||||
/// Generate a personal-device invite and show it in a popup.
|
||||
DeviceShowInvite,
|
||||
/// Connect this client to a trusted device by opaque frid invite.
|
||||
DeviceConnectInvite(String),
|
||||
/// Force an immediate personal-device sync.
|
||||
DeviceSyncNow,
|
||||
/// Persist and publish this device's display name.
|
||||
DeviceSetName(String),
|
||||
/// Revoke a trusted device.
|
||||
DeviceRevoke(String),
|
||||
/// Assemble the federated artist card (fan-out to the owning peers).
|
||||
FedOpenArtist(String),
|
||||
/// Download federated tracks into the local library, one by one.
|
||||
@@ -2333,6 +2343,48 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
|
||||
});
|
||||
None
|
||||
}
|
||||
SettingsRow::DeviceName => {
|
||||
let name = state
|
||||
.federation
|
||||
.devices
|
||||
.as_ref()
|
||||
.map(|status| status.this_device_name.clone())
|
||||
.unwrap_or_default();
|
||||
state.popup = Some(Popup::FedInput {
|
||||
field: FedInputField::DeviceName,
|
||||
input: crate::app::input::LineEdit::new(name),
|
||||
});
|
||||
None
|
||||
}
|
||||
SettingsRow::DeviceInvite => Some(Effect::DeviceShowInvite),
|
||||
SettingsRow::DeviceConnect => {
|
||||
state.popup = Some(Popup::FedInput {
|
||||
field: FedInputField::ConnectInvite,
|
||||
input: crate::app::input::LineEdit::default(),
|
||||
});
|
||||
None
|
||||
}
|
||||
SettingsRow::DeviceSyncNow => Some(Effect::DeviceSyncNow),
|
||||
SettingsRow::Device(index) => {
|
||||
let Some(device) = state
|
||||
.federation
|
||||
.devices
|
||||
.as_ref()
|
||||
.and_then(|status| status.devices.get(index))
|
||||
.cloned()
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if device.is_self || device.revoked {
|
||||
state.status_message = Some("this device cannot be revoked here".to_string());
|
||||
return None;
|
||||
}
|
||||
state.popup = Some(Popup::ConfirmDeviceRevoke {
|
||||
device_id: device.device_id,
|
||||
name: device.name,
|
||||
});
|
||||
None
|
||||
}
|
||||
SettingsRow::VisualizationClock => {
|
||||
match state.visualizer.toggle_clock() {
|
||||
Ok(()) => {
|
||||
|
||||
Reference in New Issue
Block a user