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::Quit
|
||||||
| Command::Import(_)
|
| Command::Import(_)
|
||||||
| Command::Open(_)
|
| Command::Open(_)
|
||||||
|
| Command::ConnectInvite(_)
|
||||||
| Command::Volume(_)
|
| Command::Volume(_)
|
||||||
| Command::Seek(_)
|
| Command::Seek(_)
|
||||||
| Command::SeekTo(_)
|
| Command::SeekTo(_)
|
||||||
@@ -167,6 +168,7 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
|
|||||||
Command::Quit => state.should_quit = true,
|
Command::Quit => state.should_quit = true,
|
||||||
Command::Import(path) => super::spawn_import(state, runtime, &path),
|
Command::Import(path) => super::spawn_import(state, runtime, &path),
|
||||||
Command::Open(link) => open_frid_link(state, runtime, link),
|
Command::Open(link) => open_frid_link(state, runtime, link),
|
||||||
|
Command::ConnectInvite(invite) => super::device_connect(runtime, invite),
|
||||||
Command::Volume(value) => {
|
Command::Volume(value) => {
|
||||||
state.player.volume = value;
|
state.player.volume = value;
|
||||||
super::perform_effect(state, runtime, Effect::SetVolume(value));
|
super::perform_effect(state, runtime, Effect::SetVolume(value));
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ pub enum Command {
|
|||||||
Import(String),
|
Import(String),
|
||||||
/// `:open frid://...` — open a shared federation content link.
|
/// `:open frid://...` — open a shared federation content link.
|
||||||
Open(String),
|
Open(String),
|
||||||
|
/// `:connect frid://i/...` — pair this client with a trusted device.
|
||||||
|
ConnectInvite(String),
|
||||||
/// `:volume 40` (also `:vol`) — set the volume precisely.
|
/// `:volume 40` (also `:vol`) — set the volume precisely.
|
||||||
Volume(u8),
|
Volume(u8),
|
||||||
/// `:seek +30` / `:seek -10` — relative seek in seconds.
|
/// `: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()),
|
_ => 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()) {
|
"volume" | "vol" => match arg.and_then(|a| a.parse::<u8>().ok()) {
|
||||||
Some(value) if value <= 100 => Parsed::Command(Command::Volume(value)),
|
Some(value) if value <= 100 => Parsed::Command(Command::Volume(value)),
|
||||||
_ => Parsed::Invalid("usage: :volume 0-100".to_string()),
|
_ => Parsed::Invalid("usage: :volume 0-100".to_string()),
|
||||||
@@ -182,6 +193,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(matches!(parse("import"), Parsed::Invalid(_)));
|
assert!(matches!(parse("import"), Parsed::Invalid(_)));
|
||||||
assert!(matches!(parse("open"), 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("volume 40"), Parsed::Command(Command::Volume(40)));
|
||||||
assert_eq!(parse("vol 0"), Parsed::Command(Command::Volume(0)));
|
assert_eq!(parse("vol 0"), Parsed::Command(Command::Volume(0)));
|
||||||
assert_eq!(parse("shuffle"), Parsed::Command(Command::Shuffle));
|
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.
|
/// This peer's connection ticket, requested from the Federation tab.
|
||||||
FedTicket(Result<String, String>),
|
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 state;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
|
||||||
use std::io;
|
use std::io::{self, Write as _};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ const VISUALIZER_TICK_INTERVAL: Duration = Duration::from_millis(50);
|
|||||||
pub struct Runtime {
|
pub struct Runtime {
|
||||||
pub event_tx: mpsc::UnboundedSender<AppEvent>,
|
pub event_tx: mpsc::UnboundedSender<AppEvent>,
|
||||||
pub library: Arc<Library>,
|
pub library: Arc<Library>,
|
||||||
|
pub devices: Arc<crate::devices::DeviceSync>,
|
||||||
pub federation: Arc<crate::federation::Federation>,
|
pub federation: Arc<crate::federation::Federation>,
|
||||||
/// When the last Federation-tab status snapshot was requested.
|
/// When the last Federation-tab status snapshot was requested.
|
||||||
pub fed_status_at: Option<std::time::Instant>,
|
pub fed_status_at: Option<std::time::Instant>,
|
||||||
@@ -92,12 +93,16 @@ pub async fn run(
|
|||||||
state.status_message = Some(format!("visualizations disabled: {err:#}"));
|
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.settings = federation.settings();
|
||||||
|
state.federation.devices = Some(devices.status());
|
||||||
let player_events = event_tx.clone();
|
let player_events = event_tx.clone();
|
||||||
let mut runtime = Runtime {
|
let mut runtime = Runtime {
|
||||||
event_tx,
|
event_tx,
|
||||||
library,
|
library,
|
||||||
|
devices,
|
||||||
federation,
|
federation,
|
||||||
fed_status_at: None,
|
fed_status_at: None,
|
||||||
fed_resolving: std::sync::Mutex::new(std::collections::HashSet::new()),
|
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 fed = Arc::clone(&runtime.federation);
|
||||||
|
let devices = Arc::clone(&runtime.devices);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
fed.start_if_enabled().await;
|
fed.start_if_enabled().await;
|
||||||
let status = fed.status().await;
|
let status = fed.status().await;
|
||||||
let _ = tx.send(AppEvent::FederationStatus(status));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let library = Arc::clone(&runtime.library);
|
let library = Arc::clone(&runtime.library);
|
||||||
|
let devices = Arc::clone(&runtime.devices);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
for track_id in track_ids {
|
for track_id in track_ids {
|
||||||
match library.toggle_like(track_id) {
|
match library.toggle_like(track_id) {
|
||||||
Ok(liked) => {
|
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 });
|
let _ = tx.send(AppEvent::LikeToggled { track_id, liked });
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -448,6 +459,11 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
|||||||
for fed in fed_tracks {
|
for fed in fed_tracks {
|
||||||
match library.toggle_fed_like(&fed) {
|
match library.toggle_fed_like(&fed) {
|
||||||
Ok(liked) => {
|
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 {
|
let _ = tx.send(AppEvent::FedLikeToggled {
|
||||||
item_id: fed.item_id.clone(),
|
item_id: fed.item_id.clone(),
|
||||||
liked,
|
liked,
|
||||||
@@ -468,12 +484,20 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
|||||||
track_ids,
|
track_ids,
|
||||||
} => {
|
} => {
|
||||||
let library = Arc::clone(&runtime.library);
|
let library = Arc::clone(&runtime.library);
|
||||||
|
let devices = Arc::clone(&runtime.devices);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let event = match library.remove_tracks_from_playlist(playlist_id, &track_ids) {
|
let event = match library.remove_tracks_from_playlist(playlist_id, &track_ids) {
|
||||||
Ok(()) => AppEvent::LibraryChanged {
|
Ok(()) => {
|
||||||
message: Some(format!("removed {} track(s)", track_ids.len())),
|
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:#}")),
|
Err(err) => AppEvent::StatusMessage(format!("remove failed: {err:#}")),
|
||||||
};
|
};
|
||||||
let _ = tx.send(event);
|
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));
|
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) => {
|
Effect::FedOpenArtist(name) => {
|
||||||
let fed = Arc::clone(&runtime.federation);
|
let fed = Arc::clone(&runtime.federation);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
@@ -646,6 +721,45 @@ fn shell_quote(path: &Path) -> String {
|
|||||||
format!("'{}'", value.replace('\'', "'\\''"))
|
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
|
/// Start playing `queue[queue_pos]`: open the local file in a background
|
||||||
/// task and hand the reader to the audio thread.
|
/// task and hand the reader to the audio thread.
|
||||||
fn play_current(state: &mut AppState, runtime: &mut Runtime) {
|
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
|
/// Downloads one pending federated track (into the cache, or the library
|
||||||
/// when save-on-listen is enabled) and reports back with the placeholder id
|
/// when save-on-listen is enabled) and reports back with the placeholder id
|
||||||
/// so the queue can swap the resolved track in.
|
/// 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 fed = Arc::clone(&runtime.federation);
|
||||||
let library = Arc::clone(&runtime.library);
|
let library = Arc::clone(&runtime.library);
|
||||||
|
let devices = Arc::clone(&runtime.devices);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let total = tracks.len();
|
let total = tracks.len();
|
||||||
@@ -890,12 +1021,19 @@ pub(crate) fn fed_download_spawn(
|
|||||||
&& !imported_ids.is_empty()
|
&& !imported_ids.is_empty()
|
||||||
{
|
{
|
||||||
let library = Arc::clone(&library);
|
let library = Arc::clone(&library);
|
||||||
|
let devices = Arc::clone(&devices);
|
||||||
let tx_add = tx.clone();
|
let tx_add = tx.clone();
|
||||||
let title = playlist_title.clone();
|
let title = playlist_title.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let result = library
|
let result = library
|
||||||
.add_tracks_to_playlist(playlist_id, &imported_ids)
|
.add_tracks_to_playlist(playlist_id, &imported_ids)
|
||||||
.map_err(|err| format!("{err:#}"));
|
.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 {
|
let _ = tx_add.send(AppEvent::PlaylistTracksAdded {
|
||||||
playlist_id,
|
playlist_id,
|
||||||
playlist_title: title,
|
playlist_title: title,
|
||||||
@@ -912,9 +1050,11 @@ pub(crate) fn fed_download_spawn(
|
|||||||
/// Request a fresh status snapshot for the Federation tab.
|
/// Request a fresh status snapshot for the Federation tab.
|
||||||
fn fed_spawn_status(runtime: &Runtime) {
|
fn fed_spawn_status(runtime: &Runtime) {
|
||||||
let fed = Arc::clone(&runtime.federation);
|
let fed = Arc::clone(&runtime.federation);
|
||||||
|
let devices = Arc::clone(&runtime.devices);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = tx.send(AppEvent::FederationStatus(fed.status().await));
|
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) => {
|
AppEvent::FederationStatus(status) => {
|
||||||
state.federation.status = Some(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 } => {
|
AppEvent::FedSearchLoaded { seq, result } => {
|
||||||
if runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) != seq {
|
if runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) != seq {
|
||||||
return;
|
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') => {}
|
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
|
||||||
_ => state.popup = Some(Popup::FedText { title, text }),
|
_ => 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).
|
/// One-line text entry on the Federation tab (network id / peer ticket).
|
||||||
fn handle_fed_input(
|
fn handle_fed_input(
|
||||||
state: &mut AppState,
|
state: &mut AppState,
|
||||||
runtime: &Runtime,
|
runtime: &mut Runtime,
|
||||||
field: FedInputField,
|
field: FedInputField,
|
||||||
mut input: crate::app::input::LineEdit,
|
mut input: crate::app::input::LineEdit,
|
||||||
key: KeyEvent,
|
key: KeyEvent,
|
||||||
@@ -110,6 +127,28 @@ fn handle_fed_input(
|
|||||||
super::fed_connect(runtime, value);
|
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.
|
/// Pasted text goes into the focused text field when one is open.
|
||||||
pub fn handle_paste(state: &mut AppState, pasted: &str) {
|
pub fn handle_paste(state: &mut AppState, pasted: &str) {
|
||||||
let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect();
|
let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect();
|
||||||
@@ -278,7 +374,13 @@ fn save_edit(
|
|||||||
if title.is_empty() {
|
if title.is_empty() {
|
||||||
return Err("title is empty".to_string());
|
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 {
|
match result {
|
||||||
@@ -311,7 +413,13 @@ fn handle_confirm_delete(
|
|||||||
DeleteTarget::Track(id) => library.delete_track(id),
|
DeleteTarget::Track(id) => library.delete_track(id),
|
||||||
DeleteTarget::Release(id) => library.delete_release(id),
|
DeleteTarget::Release(id) => library.delete_release(id),
|
||||||
DeleteTarget::Artist(id) => library.delete_artist(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 {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@@ -656,12 +764,18 @@ pub(crate) fn spawn_add_target(
|
|||||||
match target {
|
match target {
|
||||||
crate::app::state::PlaylistAddTarget::Local(tracks) => {
|
crate::app::state::PlaylistAddTarget::Local(tracks) => {
|
||||||
let library = Arc::clone(&runtime.library);
|
let library = Arc::clone(&runtime.library);
|
||||||
|
let devices = Arc::clone(&runtime.devices);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
let ids: Vec<i64> = tracks.iter().map(|t| t.id).filter(|id| *id >= 0).collect();
|
let ids: Vec<i64> = tracks.iter().map(|t| t.id).filter(|id| *id >= 0).collect();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let result = library
|
let result = library
|
||||||
.add_tracks_to_playlist(playlist_id, &ids)
|
.add_tracks_to_playlist(playlist_id, &ids)
|
||||||
.map_err(|err| format!("{err:#}"));
|
.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 {
|
let _ = tx.send(AppEvent::PlaylistTracksAdded {
|
||||||
playlist_id,
|
playlist_id,
|
||||||
playlist_title,
|
playlist_title,
|
||||||
@@ -681,11 +795,17 @@ fn spawn_create_playlist(
|
|||||||
add_target: Option<crate::app::state::PlaylistAddTarget>,
|
add_target: Option<crate::app::state::PlaylistAddTarget>,
|
||||||
) {
|
) {
|
||||||
let library = Arc::clone(&runtime.library);
|
let library = Arc::clone(&runtime.library);
|
||||||
|
let devices = Arc::clone(&runtime.devices);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let result = library
|
let result = library
|
||||||
.create_playlist(&title)
|
.create_playlist(&title)
|
||||||
.map_err(|err| format!("{err:#}"));
|
.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 });
|
let _ = tx.send(AppEvent::PlaylistCreated { result, add_target });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -530,12 +530,23 @@ pub enum Popup {
|
|||||||
},
|
},
|
||||||
/// Wrapped read-only text (this peer's connection ticket).
|
/// Wrapped read-only text (this peer's connection ticket).
|
||||||
FedText { title: String, text: String },
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum FedInputField {
|
pub enum FedInputField {
|
||||||
NetworkId,
|
NetworkId,
|
||||||
ConnectTicket,
|
ConnectTicket,
|
||||||
|
DeviceName,
|
||||||
|
ConnectInvite,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FedInputField {
|
impl FedInputField {
|
||||||
@@ -543,6 +554,8 @@ impl FedInputField {
|
|||||||
match self {
|
match self {
|
||||||
FedInputField::NetworkId => "Network ID",
|
FedInputField::NetworkId => "Network ID",
|
||||||
FedInputField::ConnectTicket => "Connect to peer (paste ticket)",
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum SettingsRow {
|
pub enum SettingsRow {
|
||||||
Federation(FedRow),
|
Federation(FedRow),
|
||||||
|
DeviceName,
|
||||||
|
DeviceInvite,
|
||||||
|
DeviceConnect,
|
||||||
|
DeviceSyncNow,
|
||||||
|
Device(usize),
|
||||||
VisualizationClock,
|
VisualizationClock,
|
||||||
VisualizationScript(usize),
|
VisualizationScript(usize),
|
||||||
VisualizationNew,
|
VisualizationNew,
|
||||||
@@ -584,6 +602,13 @@ pub enum SettingsRow {
|
|||||||
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
|
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
|
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.push(SettingsRow::VisualizationClock);
|
||||||
rows.extend(
|
rows.extend(
|
||||||
state
|
state
|
||||||
@@ -605,6 +630,7 @@ pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
|
|||||||
pub struct FederationTab {
|
pub struct FederationTab {
|
||||||
pub settings: crate::federation::FedSettings,
|
pub settings: crate::federation::FedSettings,
|
||||||
pub status: Option<crate::federation::FedStatus>,
|
pub status: Option<crate::federation::FedStatus>,
|
||||||
|
pub devices: Option<crate::devices::DeviceSyncStatus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Playlists eligible as add-targets (the virtual Likes playlist is managed
|
/// Playlists eligible as add-targets (the virtual Likes playlist is managed
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ pub enum Effect {
|
|||||||
FedSyncNow,
|
FedSyncNow,
|
||||||
/// Fetch this peer's ticket and show it in a popup.
|
/// Fetch this peer's ticket and show it in a popup.
|
||||||
FedShowTicket,
|
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).
|
/// Assemble the federated artist card (fan-out to the owning peers).
|
||||||
FedOpenArtist(String),
|
FedOpenArtist(String),
|
||||||
/// Download federated tracks into the local library, one by one.
|
/// Download federated tracks into the local library, one by one.
|
||||||
@@ -2333,6 +2343,48 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
|
|||||||
});
|
});
|
||||||
None
|
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 => {
|
SettingsRow::VisualizationClock => {
|
||||||
match state.visualizer.toggle_clock() {
|
match state.visualizer.toggle_clock() {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
|||||||
+2212
File diff suppressed because it is too large
Load Diff
+71
-4
@@ -181,11 +181,13 @@ pub struct FedPlayable {
|
|||||||
struct Running {
|
struct Running {
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
network_name: String,
|
network_name: String,
|
||||||
|
network_id: NetworkId,
|
||||||
tasks: Vec<tokio::task::JoinHandle<()>>,
|
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Federation {
|
pub struct Federation {
|
||||||
library: Arc<Library>,
|
library: Arc<Library>,
|
||||||
|
devices: Arc<crate::devices::DeviceSync>,
|
||||||
data_dir: PathBuf,
|
data_dir: PathBuf,
|
||||||
cache_dir: PathBuf,
|
cache_dir: PathBuf,
|
||||||
media_dir: PathBuf,
|
media_dir: PathBuf,
|
||||||
@@ -284,7 +286,7 @@ async fn dht_record_payload_bytes(data_dir: PathBuf, now_ms: u64) -> Result<u64>
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Federation {
|
impl Federation {
|
||||||
pub fn new(library: Arc<Library>) -> Arc<Self> {
|
pub fn new(library: Arc<Library>, devices: Arc<crate::devices::DeviceSync>) -> Arc<Self> {
|
||||||
let dirs = crate::config::project_dirs();
|
let dirs = crate::config::project_dirs();
|
||||||
let data_dir = dirs
|
let data_dir = dirs
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -307,6 +309,7 @@ impl Federation {
|
|||||||
});
|
});
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
library,
|
library,
|
||||||
|
devices,
|
||||||
data_dir,
|
data_dir,
|
||||||
cache_dir,
|
cache_dir,
|
||||||
media_dir,
|
media_dir,
|
||||||
@@ -363,9 +366,18 @@ impl Federation {
|
|||||||
|
|
||||||
/// Starts the DHT node. Idempotent per network name.
|
/// Starts the DHT node. Idempotent per network name.
|
||||||
async fn start(self: &Arc<Self>, network_name: String) -> Result<()> {
|
async fn start(self: &Arc<Self>, network_name: String) -> Result<()> {
|
||||||
|
self.start_with_network_id(NetworkId::from_name(&network_name), network_name)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_with_network_id(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
network_id: NetworkId,
|
||||||
|
network_name: String,
|
||||||
|
) -> Result<()> {
|
||||||
let mut guard = self.running.lock().await;
|
let mut guard = self.running.lock().await;
|
||||||
if let Some(running) = guard.as_ref() {
|
if let Some(running) = guard.as_ref() {
|
||||||
if running.network_name == network_name {
|
if running.network_id == network_id {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
stop_running(guard.take()).await;
|
stop_running(guard.take()).await;
|
||||||
@@ -375,13 +387,15 @@ impl Federation {
|
|||||||
|
|
||||||
let config = MusicDhtConfig::builder()
|
let config = MusicDhtConfig::builder()
|
||||||
.data_dir(&self.data_dir)
|
.data_dir(&self.data_dir)
|
||||||
.network_id(NetworkId::from_name(&network_name))
|
.network_id(network_id)
|
||||||
// Peers of the network find each other knowing only its name.
|
// Peers of the network find each other knowing only its name.
|
||||||
.rendezvous(RendezvousConfig::default())
|
.rendezvous(RendezvousConfig::default())
|
||||||
// Peers stream each other's audio over this protocol.
|
// Peers stream each other's audio over this protocol.
|
||||||
.stream_protocol(AUDIO_ALPN)
|
.stream_protocol(AUDIO_ALPN)
|
||||||
// ...and browse each other's per-artist catalogs over this one.
|
// ...and browse each other's per-artist catalogs over this one.
|
||||||
.stream_protocol(CATALOG_ALPN)
|
.stream_protocol(CATALOG_ALPN)
|
||||||
|
// Personal-device sync (likes, playlists, trusted devices).
|
||||||
|
.stream_protocol(crate::devices::SYNC_ALPN)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
||||||
let (service, mut events) = MusicDhtService::start(config)
|
let (service, mut events) = MusicDhtService::start(config)
|
||||||
@@ -428,11 +442,32 @@ impl Federation {
|
|||||||
Arc::clone(&self.library),
|
Arc::clone(&self.library),
|
||||||
service.endpoint_id(),
|
service.endpoint_id(),
|
||||||
));
|
));
|
||||||
|
let sync_acceptor = service
|
||||||
|
.stream_acceptor(crate::devices::SYNC_ALPN)
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to take the device-sync acceptor: {err}"))?;
|
||||||
|
let device_sync_task = tokio::spawn(crate::devices::serve_peers(
|
||||||
|
sync_acceptor,
|
||||||
|
Arc::clone(&self.devices),
|
||||||
|
Arc::clone(&service),
|
||||||
|
));
|
||||||
|
let device_sync = Arc::clone(&self.devices);
|
||||||
|
let device_service = Arc::clone(&service);
|
||||||
|
let device_tick_task = tokio::spawn(async move {
|
||||||
|
crate::devices::sync_loop(device_sync, device_service).await;
|
||||||
|
});
|
||||||
|
|
||||||
*guard = Some(Running {
|
*guard = Some(Running {
|
||||||
service,
|
service,
|
||||||
network_name,
|
network_name,
|
||||||
tasks: vec![event_task, sync_task, audio_task, catalog_task],
|
network_id,
|
||||||
|
tasks: vec![
|
||||||
|
event_task,
|
||||||
|
sync_task,
|
||||||
|
audio_task,
|
||||||
|
catalog_task,
|
||||||
|
device_sync_task,
|
||||||
|
device_tick_task,
|
||||||
|
],
|
||||||
});
|
});
|
||||||
self.set_error(None);
|
self.set_error(None);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -870,6 +905,38 @@ impl Federation {
|
|||||||
Ok(ticket.to_string())
|
Ok(ticket.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn device_invite(self: &Arc<Self>) -> Result<String> {
|
||||||
|
if self.running.lock().await.is_none() {
|
||||||
|
let status = self.devices.status();
|
||||||
|
let network_name = format!("furumi-device-sync:{}", status.group_id);
|
||||||
|
self.start_with_network_id(NetworkId::from_name(&network_name), "device-sync".into())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
let service = self.service().await?;
|
||||||
|
self.devices.create_invite(service).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_connect(self: &Arc<Self>, invite: &str) -> Result<String> {
|
||||||
|
let network_id = crate::devices::invite_network_id(invite)?;
|
||||||
|
let needs_start = self
|
||||||
|
.running
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|running| running.network_id != network_id);
|
||||||
|
if needs_start {
|
||||||
|
self.start_with_network_id(network_id, "device-invite".to_string())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
let service = self.service().await?;
|
||||||
|
self.devices.connect_invite(service, invite).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn device_sync_now(self: &Arc<Self>) -> Result<()> {
|
||||||
|
let service = self.service().await?;
|
||||||
|
self.devices.sync_once(service).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn connect(&self, ticket: &str) -> Result<String> {
|
pub async fn connect(&self, ticket: &str) -> Result<String> {
|
||||||
let service = self.service().await?;
|
let service = self.service().await?;
|
||||||
let ticket: PeerTicket = ticket
|
let ticket: PeerTicket = ticket
|
||||||
|
|||||||
+219
-1
@@ -68,6 +68,7 @@ CREATE TABLE IF NOT EXISTS track_artists (
|
|||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS playlists (
|
CREATE TABLE IF NOT EXISTS playlists (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
|
sync_id TEXT UNIQUE,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
@@ -669,8 +670,14 @@ impl Library {
|
|||||||
pub fn create_playlist(&self, title: &str) -> Result<PlaylistCard> {
|
pub fn create_playlist(&self, title: &str) -> Result<PlaylistCard> {
|
||||||
let conn = self.lock();
|
let conn = self.lock();
|
||||||
conn.execute("INSERT INTO playlists (title) VALUES (?1)", [title])?;
|
conn.execute("INSERT INTO playlists (title) VALUES (?1)", [title])?;
|
||||||
|
let id = conn.last_insert_rowid();
|
||||||
|
let sync_id = make_playlist_sync_id(id, title);
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE playlists SET sync_id = ?2 WHERE id = ?1",
|
||||||
|
params![id, sync_id],
|
||||||
|
)?;
|
||||||
Ok(PlaylistCard {
|
Ok(PlaylistCard {
|
||||||
id: conn.last_insert_rowid(),
|
id,
|
||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
track_count: 0,
|
track_count: 0,
|
||||||
kind: "normal".to_string(),
|
kind: "normal".to_string(),
|
||||||
@@ -692,6 +699,67 @@ impl Library {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn delete_playlist_by_sync_id(&self, sync_id: &str) -> Result<()> {
|
||||||
|
let conn = self.lock();
|
||||||
|
conn.execute("DELETE FROM playlists WHERE sync_id = ?1", [sync_id])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn playlist_sync_id(&self, id: i64) -> Result<Option<String>> {
|
||||||
|
let conn = self.lock();
|
||||||
|
Ok(conn
|
||||||
|
.query_row("SELECT sync_id FROM playlists WHERE id = ?1", [id], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})
|
||||||
|
.optional()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_playlist_sync_id(&self, id: i64) -> Result<String> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let existing: Option<String> = conn
|
||||||
|
.query_row("SELECT sync_id FROM playlists WHERE id = ?1", [id], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})
|
||||||
|
.optional()?
|
||||||
|
.flatten();
|
||||||
|
if let Some(sync_id) = existing {
|
||||||
|
return Ok(sync_id);
|
||||||
|
}
|
||||||
|
let title: String =
|
||||||
|
conn.query_row("SELECT title FROM playlists WHERE id = ?1", [id], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})?;
|
||||||
|
let sync_id = make_playlist_sync_id(id, &title);
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE playlists SET sync_id = ?2 WHERE id = ?1",
|
||||||
|
params![id, sync_id],
|
||||||
|
)?;
|
||||||
|
Ok(sync_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert_synced_playlist(&self, sync_id: &str, title: &str) -> Result<i64> {
|
||||||
|
let conn = self.lock();
|
||||||
|
if let Some(id) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM playlists WHERE sync_id = ?1",
|
||||||
|
[sync_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
{
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE playlists SET title = ?2 WHERE id = ?1",
|
||||||
|
params![id, title],
|
||||||
|
)?;
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO playlists (sync_id, title) VALUES (?1, ?2)",
|
||||||
|
params![sync_id, title],
|
||||||
|
)?;
|
||||||
|
Ok(conn.last_insert_rowid())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn add_tracks_to_playlist(&self, playlist_id: i64, track_ids: &[i64]) -> Result<()> {
|
pub fn add_tracks_to_playlist(&self, playlist_id: i64, track_ids: &[i64]) -> Result<()> {
|
||||||
let mut conn = self.lock();
|
let mut conn = self.lock();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
@@ -725,6 +793,125 @@ impl Library {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn track_content_id_by_id(&self, track_id: i64) -> Result<Option<String>> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let content_id: Option<String> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT content_id FROM tracks WHERE id = ?1",
|
||||||
|
[track_id],
|
||||||
|
|row| row.get::<_, Option<String>>(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.flatten()
|
||||||
|
.and_then(|value| music_dht::normalize_content_id(&value));
|
||||||
|
Ok(content_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn track_content_ids(&self, track_ids: &[i64]) -> Result<Vec<String>> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for &track_id in track_ids {
|
||||||
|
let content_id: Option<String> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT content_id FROM tracks WHERE id = ?1",
|
||||||
|
[track_id],
|
||||||
|
|row| row.get::<_, Option<String>>(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.flatten()
|
||||||
|
.and_then(|value| music_dht::normalize_content_id(&value));
|
||||||
|
if let Some(content_id) = content_id {
|
||||||
|
out.push(content_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn track_id_by_content_id(&self, content_id: &str) -> Result<Option<i64>> {
|
||||||
|
let Some(content_id) = music_dht::normalize_content_id(content_id) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let conn = self.lock();
|
||||||
|
Ok(conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM tracks WHERE content_id = ?1 LIMIT 1",
|
||||||
|
[content_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_like(&self, track_id: i64, liked: bool) -> Result<()> {
|
||||||
|
let conn = self.lock();
|
||||||
|
if liked {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO likes (track_id) VALUES (?1)",
|
||||||
|
[track_id],
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
conn.execute("DELETE FROM likes WHERE track_id = ?1", [track_id])?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_content_id_to_synced_playlist(
|
||||||
|
&self,
|
||||||
|
playlist_sync_id: &str,
|
||||||
|
content_id: &str,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let Some(track_id) = self.track_id_by_content_id(content_id)? else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let conn = self.lock();
|
||||||
|
let playlist_id: Option<i64> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM playlists WHERE sync_id = ?1",
|
||||||
|
[playlist_sync_id],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
let Some(playlist_id) = playlist_id else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let next: i64 = conn.query_row(
|
||||||
|
"SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_tracks WHERE playlist_id = ?1",
|
||||||
|
[playlist_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position)
|
||||||
|
VALUES (?1, ?2, ?3)",
|
||||||
|
params![playlist_id, track_id, next],
|
||||||
|
)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_content_id_from_synced_playlist(
|
||||||
|
&self,
|
||||||
|
playlist_sync_id: &str,
|
||||||
|
content_id: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let Some(track_id) = self.track_id_by_content_id(content_id)? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let conn = self.lock();
|
||||||
|
let playlist_id: Option<i64> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM playlists WHERE sync_id = ?1",
|
||||||
|
[playlist_sync_id],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
let Some(playlist_id) = playlist_id else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM playlist_tracks WHERE playlist_id = ?1 AND track_id = ?2",
|
||||||
|
params![playlist_id, track_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Liked federated tracks, newest first, as playable references.
|
/// Liked federated tracks, newest first, as playable references.
|
||||||
pub fn fed_likes(&self) -> Result<Vec<crate::federation::FedTrack>> {
|
pub fn fed_likes(&self) -> Result<Vec<crate::federation::FedTrack>> {
|
||||||
let conn = self.lock();
|
let conn = self.lock();
|
||||||
@@ -1222,6 +1409,28 @@ fn ensure_schema_migrations(conn: &Connection) -> Result<()> {
|
|||||||
if !track_columns.iter().any(|column| column == "content_id") {
|
if !track_columns.iter().any(|column| column == "content_id") {
|
||||||
conn.execute("ALTER TABLE tracks ADD COLUMN content_id TEXT", [])?;
|
conn.execute("ALTER TABLE tracks ADD COLUMN content_id TEXT", [])?;
|
||||||
}
|
}
|
||||||
|
let playlist_columns = table_columns(conn, "playlists")?;
|
||||||
|
if !playlist_columns.iter().any(|column| column == "sync_id") {
|
||||||
|
conn.execute("ALTER TABLE playlists ADD COLUMN sync_id TEXT", [])?;
|
||||||
|
conn.execute(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_playlists_sync_id
|
||||||
|
ON playlists(sync_id)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
let mut rows = conn.prepare("SELECT id, title FROM playlists WHERE sync_id IS NULL")?;
|
||||||
|
let missing = rows
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
drop(rows);
|
||||||
|
for (id, title) in missing {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE playlists SET sync_id = ?2 WHERE id = ?1",
|
||||||
|
params![id, make_playlist_sync_id(id, &title)],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1232,6 +1441,15 @@ fn table_columns(conn: &Connection, table: &str) -> Result<Vec<String>> {
|
|||||||
.collect::<rusqlite::Result<Vec<_>>>()?)
|
.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn make_playlist_sync_id(id: i64, title: &str) -> String {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let seed = format!("playlist:{id}:{title}:{now}:{}", std::process::id());
|
||||||
|
format!("pl_{}", &blake3::hash(seed.as_bytes()).to_hex()[..24])
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod app;
|
mod app;
|
||||||
mod art;
|
mod art;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod devices;
|
||||||
mod federation;
|
mod federation;
|
||||||
mod library;
|
mod library;
|
||||||
mod media;
|
mod media;
|
||||||
|
|||||||
+127
-2
@@ -6,7 +6,7 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::{Block, Paragraph};
|
use ratatui::widgets::{Block, Paragraph};
|
||||||
|
|
||||||
use super::theme;
|
use super::theme;
|
||||||
use crate::app::state::{AppState, FedRow};
|
use crate::app::state::{AppState, FedRow, settings_rows};
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
@@ -16,7 +16,7 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let rows_height = (FedRow::ALL.len() + state.visualizer.scripts.len() + 6) as u16;
|
let rows_height = (settings_rows(state).len() + 5) as u16;
|
||||||
let [rows_area, _, status_area] = Layout::vertical([
|
let [rows_area, _, status_area] = Layout::vertical([
|
||||||
Constraint::Length(rows_height.min(inner.height)),
|
Constraint::Length(rows_height.min(inner.height)),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
@@ -66,6 +66,83 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
cursor += 1;
|
cursor += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
y = y.saturating_add(1);
|
||||||
|
draw_section(frame, area, &mut y, "Connected Devices");
|
||||||
|
let devices = state.federation.devices.as_ref();
|
||||||
|
draw_row(
|
||||||
|
frame,
|
||||||
|
area,
|
||||||
|
&mut y,
|
||||||
|
cursor,
|
||||||
|
state.settings_cursor,
|
||||||
|
"This device name",
|
||||||
|
devices
|
||||||
|
.map(|status| status.this_device_name.clone())
|
||||||
|
.unwrap_or_else(|| "loading…".to_string()),
|
||||||
|
);
|
||||||
|
cursor += 1;
|
||||||
|
draw_row(
|
||||||
|
frame,
|
||||||
|
area,
|
||||||
|
&mut y,
|
||||||
|
cursor,
|
||||||
|
state.settings_cursor,
|
||||||
|
"Generate device invite",
|
||||||
|
"↵".to_string(),
|
||||||
|
);
|
||||||
|
cursor += 1;
|
||||||
|
draw_row(
|
||||||
|
frame,
|
||||||
|
area,
|
||||||
|
&mut y,
|
||||||
|
cursor,
|
||||||
|
state.settings_cursor,
|
||||||
|
"Connect device by invite…",
|
||||||
|
"↵".to_string(),
|
||||||
|
);
|
||||||
|
cursor += 1;
|
||||||
|
draw_row(
|
||||||
|
frame,
|
||||||
|
area,
|
||||||
|
&mut y,
|
||||||
|
cursor,
|
||||||
|
state.settings_cursor,
|
||||||
|
"Sync devices now",
|
||||||
|
"↵".to_string(),
|
||||||
|
);
|
||||||
|
cursor += 1;
|
||||||
|
if let Some(status) = devices {
|
||||||
|
for device in &status.devices {
|
||||||
|
let label = if device.is_self {
|
||||||
|
format!("* {}", device.name)
|
||||||
|
} else if device.revoked {
|
||||||
|
format!(" {} (revoked)", device.name)
|
||||||
|
} else {
|
||||||
|
format!(" {}", device.name)
|
||||||
|
};
|
||||||
|
let version = if device.client_version.is_empty() {
|
||||||
|
"unknown".to_string()
|
||||||
|
} else {
|
||||||
|
format!("v{}", device.client_version)
|
||||||
|
};
|
||||||
|
let value = if device.is_self || device.revoked {
|
||||||
|
version
|
||||||
|
} else {
|
||||||
|
format!("{version} · revoke ↵")
|
||||||
|
};
|
||||||
|
draw_row(
|
||||||
|
frame,
|
||||||
|
area,
|
||||||
|
&mut y,
|
||||||
|
cursor,
|
||||||
|
state.settings_cursor,
|
||||||
|
&label,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
y = y.saturating_add(1);
|
y = y.saturating_add(1);
|
||||||
draw_section(frame, area, &mut y, "Visualizations");
|
draw_section(frame, area, &mut y, "Visualizations");
|
||||||
draw_row(
|
draw_row(
|
||||||
@@ -273,5 +350,53 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
lines.push(Line::default());
|
||||||
|
lines.push(Line::styled("Connected Devices", theme::header()));
|
||||||
|
match &state.federation.devices {
|
||||||
|
None => lines.push(Line::styled("loading…", theme::dim())),
|
||||||
|
Some(status) => {
|
||||||
|
lines.push(status_line("This device", status.this_device_id.clone()));
|
||||||
|
lines.push(status_line("Sync group", status.group_id.clone()));
|
||||||
|
lines.push(status_line(
|
||||||
|
"Active devices",
|
||||||
|
status.active_devices.to_string(),
|
||||||
|
));
|
||||||
|
lines.push(status_line(
|
||||||
|
"Revoked devices",
|
||||||
|
status.revoked_devices.to_string(),
|
||||||
|
));
|
||||||
|
lines.push(status_line(
|
||||||
|
"Pending requests",
|
||||||
|
status.pending_requests.to_string(),
|
||||||
|
));
|
||||||
|
lines.push(status_line("Ops in log", status.ops_total.to_string()));
|
||||||
|
lines.push(status_line(
|
||||||
|
"Tombstones",
|
||||||
|
format!(
|
||||||
|
"{} ({} compactable)",
|
||||||
|
status.tombstone_ops, status.compactable_tombstones
|
||||||
|
),
|
||||||
|
));
|
||||||
|
lines.push(status_line("Outbox ops", status.outbox_ops.to_string()));
|
||||||
|
lines.push(status_line(
|
||||||
|
"Snapshot",
|
||||||
|
format!(
|
||||||
|
"{} likes, {} playlists, {} items",
|
||||||
|
status.snapshot_likes, status.snapshot_playlists, status.snapshot_items
|
||||||
|
),
|
||||||
|
));
|
||||||
|
lines.push(status_line(
|
||||||
|
"Unresolved items",
|
||||||
|
status.unresolved_playlist_items.to_string(),
|
||||||
|
));
|
||||||
|
lines.push(status_line("Peer ack floor", status.peer_ack_floor.clone()));
|
||||||
|
if let Some(last_sync) = &status.last_sync {
|
||||||
|
lines.push(status_line("Last device sync", last_sync.clone()));
|
||||||
|
}
|
||||||
|
if let Some(last_error) = &status.last_error {
|
||||||
|
lines.push(status_line("Device error", last_error.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
frame.render_widget(Paragraph::new(lines), area);
|
frame.render_widget(Paragraph::new(lines), area);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
|||||||
Some(Popup::LogDetail(entry)) => draw_log_detail(frame, entry),
|
Some(Popup::LogDetail(entry)) => draw_log_detail(frame, entry),
|
||||||
Some(Popup::FedInput { field, input }) => draw_fed_input(frame, field.title(), input),
|
Some(Popup::FedInput { field, input }) => draw_fed_input(frame, field.title(), input),
|
||||||
Some(Popup::FedText { title, text }) => draw_fed_text(frame, title, text),
|
Some(Popup::FedText { title, text }) => draw_fed_text(frame, title, text),
|
||||||
|
Some(Popup::DevicePairing {
|
||||||
|
device_id,
|
||||||
|
name,
|
||||||
|
client_version,
|
||||||
|
..
|
||||||
|
}) => draw_device_pairing(frame, device_id, name, client_version),
|
||||||
|
Some(Popup::ConfirmDeviceRevoke { device_id, name }) => {
|
||||||
|
draw_device_revoke(frame, device_id, name)
|
||||||
|
}
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +136,58 @@ fn draw_fed_text(frame: &mut Frame, title: &str, text: &str) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_device_pairing(frame: &mut Frame, device_id: &str, name: &str, client_version: &str) {
|
||||||
|
let area = centered(frame.area(), 64, 8);
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Pair device ")
|
||||||
|
.title_style(theme::header())
|
||||||
|
.border_style(theme::accent());
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Name ", theme::dim()),
|
||||||
|
Span::raw(name.to_string()),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Version ", theme::dim()),
|
||||||
|
Span::raw(client_version.to_string()),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Device ", theme::dim()),
|
||||||
|
Span::raw(device_id.chars().take(24).collect::<String>()),
|
||||||
|
]),
|
||||||
|
Line::default(),
|
||||||
|
Line::styled("enter/y accept · n/esc deny", theme::dim()),
|
||||||
|
];
|
||||||
|
frame.render_widget(Paragraph::new(lines), inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_device_revoke(frame: &mut Frame, device_id: &str, name: &str) {
|
||||||
|
let area = centered(frame.area(), 64, 7);
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Revoke device ")
|
||||||
|
.title_style(theme::header())
|
||||||
|
.border_style(theme::accent());
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Device ", theme::dim()),
|
||||||
|
Span::raw(name.to_string()),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("ID ", theme::dim()),
|
||||||
|
Span::raw(device_id.chars().take(24).collect::<String>()),
|
||||||
|
]),
|
||||||
|
Line::default(),
|
||||||
|
Line::styled("enter/y revoke · n/esc cancel", theme::dim()),
|
||||||
|
];
|
||||||
|
frame.render_widget(Paragraph::new(lines), inner);
|
||||||
|
}
|
||||||
|
|
||||||
/// Metadata edit form: one bordered input per field, the focused field gets
|
/// Metadata edit form: one bordered input per field, the focused field gets
|
||||||
/// the accent border and a cursor block.
|
/// the accent border and a cursor block.
|
||||||
fn draw_edit(
|
fn draw_edit(
|
||||||
|
|||||||
Reference in New Issue
Block a user