Added fed artist page
This commit is contained in:
+9
-17
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
|
||||
use crate::app::Runtime;
|
||||
use crate::app::command::{self, Command, Parsed};
|
||||
@@ -17,32 +17,22 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Esc => cancel(state),
|
||||
KeyCode::Enter => commit(state, runtime),
|
||||
KeyCode::Backspace => {
|
||||
if state.cmdline.input.pop().is_none() {
|
||||
// Backspace on an empty line closes it, like vim.
|
||||
cancel(state);
|
||||
return;
|
||||
// Backspace on an empty line closes it, like vim.
|
||||
KeyCode::Backspace if state.cmdline.input.is_empty() => cancel(state),
|
||||
_ => {
|
||||
if state.cmdline.input.handle_key(key) {
|
||||
after_change(state, runtime);
|
||||
}
|
||||
after_change(state, runtime);
|
||||
}
|
||||
KeyCode::Char(c) if is_typing(key) => {
|
||||
state.cmdline.input.push(c);
|
||||
after_change(state, runtime);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_paste(state: &mut AppState, runtime: &Runtime, pasted: &str) {
|
||||
let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect();
|
||||
state.cmdline.input.push_str(&cleaned);
|
||||
state.cmdline.input.insert_str(&cleaned);
|
||||
after_change(state, runtime);
|
||||
}
|
||||
|
||||
fn is_typing(key: KeyEvent) -> bool {
|
||||
key.modifiers.difference(KeyModifiers::SHIFT).is_empty()
|
||||
}
|
||||
|
||||
/// Re-evaluate the input after every edit; live commands (search) take
|
||||
/// effect immediately, while typing.
|
||||
fn after_change(state: &mut AppState, runtime: &Runtime) {
|
||||
@@ -101,6 +91,7 @@ pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
||||
state.search.loading = false;
|
||||
state.search.results = None;
|
||||
state.search.fed_tracks.clear();
|
||||
state.search.fed_artists.clear();
|
||||
state.search.fed_loading = false;
|
||||
return;
|
||||
}
|
||||
@@ -123,6 +114,7 @@ pub(super) fn schedule_search(state: &mut AppState, runtime: &Runtime) {
|
||||
// The same query also runs against the federated network (when the
|
||||
// node is up); its results render as a separate, marked section.
|
||||
state.search.fed_tracks.clear();
|
||||
state.search.fed_artists.clear();
|
||||
state.search.fed_loading = false;
|
||||
if runtime.federation.settings().enabled {
|
||||
state.search.fed_loading = true;
|
||||
|
||||
+8
-2
@@ -89,10 +89,16 @@ pub enum AppEvent {
|
||||
},
|
||||
/// A status snapshot for the Federation tab.
|
||||
FederationStatus(crate::federation::FedStatus),
|
||||
/// Tracks found on the federated network for the live search.
|
||||
/// Federated live-search results (artists a card can be opened for,
|
||||
/// plus matching tracks).
|
||||
FedSearchLoaded {
|
||||
seq: u64,
|
||||
result: Result<Vec<crate::federation::FedTrack>, String>,
|
||||
result: Result<crate::federation::FedSearchResults, String>,
|
||||
},
|
||||
/// A federated artist card finished assembling.
|
||||
FedArtistLoaded {
|
||||
name: String,
|
||||
result: Result<crate::federation::FedArtistCard, String>,
|
||||
},
|
||||
/// A federated track finished downloading and is ready to play.
|
||||
FedPlayReady {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! One-line text editing with a movable cursor, shared by every text input
|
||||
//! (command line, edit forms, popup fields).
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
/// A single-line value plus a cursor position (in characters). Dereferences
|
||||
/// to `&str`, so read paths treat it like the plain string it wraps.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LineEdit {
|
||||
value: String,
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
impl LineEdit {
|
||||
/// Starts with `value` and the cursor at its end.
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
let value = value.into();
|
||||
let cursor = value.chars().count();
|
||||
Self { value, cursor }
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
|
||||
/// Cursor position in characters (0..=len).
|
||||
pub fn cursor(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
fn byte_index(&self, chars: usize) -> usize {
|
||||
self.value
|
||||
.char_indices()
|
||||
.nth(chars)
|
||||
.map(|(index, _)| index)
|
||||
.unwrap_or(self.value.len())
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.value.clear();
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, c: char) {
|
||||
let at = self.byte_index(self.cursor);
|
||||
self.value.insert(at, c);
|
||||
self.cursor += 1;
|
||||
}
|
||||
|
||||
pub fn insert_str(&mut self, s: &str) {
|
||||
let at = self.byte_index(self.cursor);
|
||||
self.value.insert_str(at, s);
|
||||
self.cursor += s.chars().count();
|
||||
}
|
||||
|
||||
/// Removes the character before the cursor; `false` when at the start.
|
||||
pub fn backspace(&mut self) -> bool {
|
||||
if self.cursor == 0 {
|
||||
return false;
|
||||
}
|
||||
let at = self.byte_index(self.cursor - 1);
|
||||
self.value.remove(at);
|
||||
self.cursor -= 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Removes the character under the cursor.
|
||||
pub fn delete(&mut self) {
|
||||
if self.cursor < self.value.chars().count() {
|
||||
let at = self.byte_index(self.cursor);
|
||||
self.value.remove(at);
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies one editing key (characters, backspace/delete, cursor
|
||||
/// movement). Returns `false` for keys this editor does not handle
|
||||
/// (Enter, Esc, Tab, ...), which the caller interprets itself.
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
|
||||
match key.code {
|
||||
KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => {
|
||||
self.insert(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.backspace();
|
||||
}
|
||||
KeyCode::Delete => self.delete(),
|
||||
KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
|
||||
KeyCode::Right => self.cursor = (self.cursor + 1).min(self.value.chars().count()),
|
||||
KeyCode::Home => self.cursor = 0,
|
||||
KeyCode::End => self.cursor = self.value.chars().count(),
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for LineEdit {
|
||||
type Target = str;
|
||||
|
||||
fn deref(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LineEdit {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
fn key(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_at_cursor() {
|
||||
let mut edit = LineEdit::new("hllo");
|
||||
assert_eq!(edit.cursor(), 4);
|
||||
edit.handle_key(key(KeyCode::Home));
|
||||
edit.handle_key(key(KeyCode::Right));
|
||||
edit.handle_key(key(KeyCode::Char('e')));
|
||||
assert_eq!(edit.as_str(), "hello");
|
||||
assert_eq!(edit.cursor(), 2);
|
||||
edit.handle_key(key(KeyCode::End));
|
||||
edit.handle_key(key(KeyCode::Backspace));
|
||||
assert_eq!(edit.as_str(), "hell");
|
||||
edit.handle_key(key(KeyCode::Home));
|
||||
edit.handle_key(key(KeyCode::Delete));
|
||||
assert_eq!(edit.as_str(), "ell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_safe() {
|
||||
let mut edit = LineEdit::new("метл");
|
||||
edit.handle_key(key(KeyCode::Left));
|
||||
edit.insert('а');
|
||||
assert_eq!(edit.as_str(), "метал");
|
||||
edit.handle_key(key(KeyCode::End));
|
||||
edit.insert('л');
|
||||
assert_eq!(edit.as_str(), "металл");
|
||||
}
|
||||
}
|
||||
+27
-2
@@ -1,5 +1,6 @@
|
||||
pub mod action;
|
||||
mod cmdline;
|
||||
pub mod input;
|
||||
pub mod command;
|
||||
pub mod event;
|
||||
mod popup;
|
||||
@@ -244,7 +245,7 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
|
||||
});
|
||||
}
|
||||
}
|
||||
state::GlobalView::Search { .. } => {}
|
||||
state::GlobalView::Search { .. } | state::GlobalView::FedArtist { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +426,17 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||
let _ = tx.send(AppEvent::FedTicket(result));
|
||||
});
|
||||
}
|
||||
Effect::FedOpenArtist(name) => {
|
||||
let fed = Arc::clone(&runtime.federation);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = fed
|
||||
.artist_card(&name)
|
||||
.await
|
||||
.map_err(|err| format!("{err:#}"));
|
||||
let _ = tx.send(AppEvent::FedArtistLoaded { name, result });
|
||||
});
|
||||
}
|
||||
Effect::FedPlay(fed_track) => {
|
||||
let fed = Arc::clone(&runtime.federation);
|
||||
let tx = runtime.event_tx.clone();
|
||||
@@ -900,7 +912,10 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
}
|
||||
state.search.fed_loading = false;
|
||||
match result {
|
||||
Ok(tracks) => state.search.fed_tracks = tracks,
|
||||
Ok(results) => {
|
||||
state.search.fed_artists = results.artists;
|
||||
state.search.fed_tracks = results.tracks;
|
||||
}
|
||||
Err(message) => tracing::warn!(%message, "federated search failed"),
|
||||
}
|
||||
}
|
||||
@@ -920,6 +935,16 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
}
|
||||
Err(message) => state.status_message = Some(format!("federation: {message}")),
|
||||
},
|
||||
AppEvent::FedArtistLoaded { name, result } => {
|
||||
if let Some((current, data)) = &mut state.fed_artist_view
|
||||
&& *current == name
|
||||
{
|
||||
*data = match result {
|
||||
Ok(card) => state::Loadable::Ready(card),
|
||||
Err(message) => state::Loadable::Failed(message),
|
||||
};
|
||||
}
|
||||
}
|
||||
AppEvent::FedTicket(result) => match result {
|
||||
Ok(ticket) => {
|
||||
state.popup = Some(state::Popup::FedText {
|
||||
|
||||
+12
-38
@@ -5,7 +5,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
|
||||
use crate::app::Runtime;
|
||||
use crate::app::event::AppEvent;
|
||||
@@ -59,7 +59,7 @@ fn handle_fed_input(
|
||||
state: &mut AppState,
|
||||
runtime: &Runtime,
|
||||
field: FedInputField,
|
||||
mut input: String,
|
||||
mut input: crate::app::input::LineEdit,
|
||||
key: KeyEvent,
|
||||
) {
|
||||
match key.code {
|
||||
@@ -85,15 +85,10 @@ fn handle_fed_input(
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
input.pop();
|
||||
_ => {
|
||||
input.handle_key(key);
|
||||
state.popup = Some(Popup::FedInput { field, input });
|
||||
}
|
||||
KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => {
|
||||
input.push(c);
|
||||
state.popup = Some(Popup::FedInput { field, input });
|
||||
}
|
||||
_ => state.popup = Some(Popup::FedInput { field, input }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,11 +96,11 @@ fn handle_fed_input(
|
||||
pub fn handle_paste(state: &mut AppState, pasted: &str) {
|
||||
let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect();
|
||||
match &mut state.popup {
|
||||
Some(Popup::NewPlaylist { input, busy, .. }) if !*busy => input.push_str(&cleaned),
|
||||
Some(Popup::FedInput { input, .. }) => input.push_str(&cleaned),
|
||||
Some(Popup::NewPlaylist { input, busy, .. }) if !*busy => input.insert_str(&cleaned),
|
||||
Some(Popup::FedInput { input, .. }) => input.insert_str(&cleaned),
|
||||
Some(Popup::Edit { fields, focus, .. }) => {
|
||||
if let Some(field) = fields.get_mut(*focus) {
|
||||
field.value.push_str(&cleaned);
|
||||
field.value.insert_str(&cleaned);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -152,17 +147,11 @@ fn handle_edit(
|
||||
let len = fields.len().max(1);
|
||||
focus = (focus + len - 1) % len;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
_ => {
|
||||
if let Some(field) = fields.get_mut(focus) {
|
||||
field.value.pop();
|
||||
field.value.handle_key(key);
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => {
|
||||
if let Some(field) = fields.get_mut(focus) {
|
||||
field.value.push(c);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
state.popup = Some(Popup::Edit {
|
||||
target,
|
||||
@@ -393,7 +382,7 @@ fn handle_picker(
|
||||
if cursor == 0 {
|
||||
state.popup = Some(Popup::NewPlaylist {
|
||||
for_track: Some(track),
|
||||
input: String::new(),
|
||||
input: crate::app::input::LineEdit::default(),
|
||||
busy: false,
|
||||
});
|
||||
} else if let Some((id, title)) = options.get(cursor - 1).cloned() {
|
||||
@@ -408,7 +397,7 @@ fn handle_name_entry(
|
||||
state: &mut AppState,
|
||||
runtime: &Runtime,
|
||||
for_track: Option<TrackItem>,
|
||||
mut input: String,
|
||||
mut input: crate::app::input::LineEdit,
|
||||
busy: bool,
|
||||
key: KeyEvent,
|
||||
) {
|
||||
@@ -445,23 +434,8 @@ fn handle_name_entry(
|
||||
busy: true,
|
||||
});
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
input.pop();
|
||||
state.popup = Some(Popup::NewPlaylist {
|
||||
for_track,
|
||||
input,
|
||||
busy: false,
|
||||
});
|
||||
}
|
||||
KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => {
|
||||
input.push(c);
|
||||
state.popup = Some(Popup::NewPlaylist {
|
||||
for_track,
|
||||
input,
|
||||
busy: false,
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
input.handle_key(key);
|
||||
state.popup = Some(Popup::NewPlaylist {
|
||||
for_track,
|
||||
input,
|
||||
|
||||
+16
-5
@@ -1,6 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::app::input::LineEdit;
|
||||
use crate::art::ArtImage;
|
||||
use crate::config::keymap::KeyContext;
|
||||
use crate::library::models::{
|
||||
@@ -69,6 +70,10 @@ pub enum GlobalView {
|
||||
Search {
|
||||
cursor: usize,
|
||||
},
|
||||
/// A federated artist card (data lives in `AppState::fed_artist_view`).
|
||||
FedArtist {
|
||||
cursor: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// The Global tab: the whole server library of artists.
|
||||
@@ -308,14 +313,14 @@ pub enum DeleteTarget {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditField {
|
||||
pub label: &'static str,
|
||||
pub value: String,
|
||||
pub value: LineEdit,
|
||||
}
|
||||
|
||||
impl EditField {
|
||||
pub fn new(label: &'static str, value: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label,
|
||||
value: value.into(),
|
||||
value: LineEdit::new(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,7 +335,7 @@ pub enum Popup {
|
||||
/// added to it right after creation.
|
||||
NewPlaylist {
|
||||
for_track: Option<TrackItem>,
|
||||
input: String,
|
||||
input: LineEdit,
|
||||
busy: bool,
|
||||
},
|
||||
/// Metadata edit form for a track, release, artist or playlist.
|
||||
@@ -354,7 +359,7 @@ pub enum Popup {
|
||||
/// One-line text entry on the Federation tab (network id, peer ticket).
|
||||
FedInput {
|
||||
field: FedInputField,
|
||||
input: String,
|
||||
input: LineEdit,
|
||||
},
|
||||
/// Wrapped read-only text (this peer's connection ticket).
|
||||
FedText { title: String, text: String },
|
||||
@@ -422,7 +427,7 @@ pub fn addable_playlists(state: &AppState) -> Vec<(i64, String)> {
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Cmdline {
|
||||
pub active: bool,
|
||||
pub input: String,
|
||||
pub input: LineEdit,
|
||||
/// A live command (search) applied effects during this session; Esc
|
||||
/// undoes them, Enter keeps them.
|
||||
pub live: bool,
|
||||
@@ -437,6 +442,9 @@ pub struct SearchState {
|
||||
/// Tracks found on the federated network (empty while federation is
|
||||
/// off); rendered as a separate, marked section.
|
||||
pub fed_tracks: Vec<crate::federation::FedTrack>,
|
||||
/// Artists a federated card can be opened for — from artist records and
|
||||
/// from the artist names of matching tracks.
|
||||
pub fed_artists: Vec<crate::federation::FedArtistHit>,
|
||||
pub fed_loading: bool,
|
||||
}
|
||||
|
||||
@@ -588,6 +596,9 @@ pub struct AppState {
|
||||
pub logs: LogsTab,
|
||||
pub queue_tab: QueueTab,
|
||||
pub federation: FederationTab,
|
||||
/// The one federated artist card being viewed (name + loading state);
|
||||
/// opening another card replaces it.
|
||||
pub fed_artist_view: Option<(String, Loadable<crate::federation::FedArtistCard>)>,
|
||||
pub track_selection: TrackSelection,
|
||||
/// Shift-J jump in flight: focus this (release, track) once the release
|
||||
/// view finishes loading.
|
||||
|
||||
+120
-29
@@ -49,6 +49,8 @@ pub enum Effect {
|
||||
FedShowTicket,
|
||||
/// Download (or resolve) a federated track and play it.
|
||||
FedPlay(crate::federation::FedTrack),
|
||||
/// Assemble the federated artist card (fan-out to the owning peers).
|
||||
FedOpenArtist(String),
|
||||
}
|
||||
|
||||
pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
@@ -199,7 +201,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
// The command line opens pre-filled with "/": typing continues
|
||||
// the live search, exactly as if `:` then `/` were pressed.
|
||||
state.cmdline.active = true;
|
||||
state.cmdline.input = "/".to_string();
|
||||
state.cmdline.input = crate::app::input::LineEdit::new("/");
|
||||
state.cmdline.live = true;
|
||||
state.search = SearchState::default();
|
||||
state.active_tab = Tab::Global;
|
||||
@@ -288,7 +290,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
Action::NewPlaylist => {
|
||||
state.popup = Some(super::state::Popup::NewPlaylist {
|
||||
for_track: None,
|
||||
input: String::new(),
|
||||
input: crate::app::input::LineEdit::default(),
|
||||
busy: false,
|
||||
});
|
||||
None
|
||||
@@ -548,7 +550,7 @@ fn selected_release_card(state: &AppState) -> Option<crate::library::models::Rel
|
||||
let offset = cursor.checked_sub(results.artists.len())?;
|
||||
results.releases.get(offset).cloned()
|
||||
}
|
||||
GlobalView::Release { .. } => None,
|
||||
GlobalView::Release { .. } | GlobalView::FedArtist { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -880,6 +882,7 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
||||
let offset = cursor.checked_sub(results.artists.len() + results.releases.len())?;
|
||||
results.tracks.get(offset).cloned()
|
||||
}
|
||||
GlobalView::FedArtist { .. } => None,
|
||||
},
|
||||
Tab::Playlists => {
|
||||
let opened = state.playlists.opened.as_ref()?;
|
||||
@@ -919,7 +922,7 @@ fn selected_release_id(state: &AppState) -> Option<i64> {
|
||||
let offset = cursor.checked_sub(results.artists.len())?;
|
||||
results.releases.get(offset).map(|r| r.id)
|
||||
}
|
||||
GlobalView::Release { .. } => None,
|
||||
GlobalView::Release { .. } | GlobalView::FedArtist { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1183,7 +1186,9 @@ fn page_step(state: &AppState) -> isize {
|
||||
lines
|
||||
}
|
||||
}
|
||||
Some(GlobalView::Release { .. }) | Some(GlobalView::Search { .. }) => lines,
|
||||
Some(GlobalView::Release { .. })
|
||||
| Some(GlobalView::Search { .. })
|
||||
| Some(GlobalView::FedArtist { .. }) => lines,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1343,6 +1348,7 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
||||
Some(GlobalView::Search { cursor }) => {
|
||||
// Local results plus the federated section below them.
|
||||
let total = (state.search.results.as_ref().map_or(0, |r| r.len())
|
||||
+ state.search.fed_artists.len()
|
||||
+ state.search.fed_tracks.len()) as isize;
|
||||
if total == 0 {
|
||||
return;
|
||||
@@ -1351,6 +1357,25 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
||||
set_view_cursor(state, next as usize);
|
||||
state.track_selection.clear();
|
||||
}
|
||||
Some(GlobalView::FedArtist { cursor }) => {
|
||||
let total = fed_card_len(state) as isize;
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
let next = (cursor as isize + dy).clamp(0, total - 1);
|
||||
set_view_cursor(state, next as usize);
|
||||
state.track_selection.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Selectable rows of the open federated artist card (its tracks).
|
||||
pub(crate) fn fed_card_len(state: &AppState) -> usize {
|
||||
match &state.fed_artist_view {
|
||||
Some((_, Loadable::Ready(card))) => {
|
||||
card.releases.iter().map(|r| r.tracks.len()).sum()
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1359,7 +1384,8 @@ fn set_view_cursor(state: &mut AppState, value: usize) {
|
||||
match view {
|
||||
GlobalView::Artist { cursor, .. }
|
||||
| GlobalView::Release { cursor, .. }
|
||||
| GlobalView::Search { cursor } => *cursor = value,
|
||||
| GlobalView::Search { cursor }
|
||||
| GlobalView::FedArtist { cursor } => *cursor = value,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1398,8 +1424,11 @@ fn current_view_len(state: &AppState) -> usize {
|
||||
_ => 0,
|
||||
},
|
||||
Some(GlobalView::Search { .. }) => {
|
||||
state.search.results.as_ref().map_or(0, |r| r.len()) + state.search.fed_tracks.len()
|
||||
state.search.results.as_ref().map_or(0, |r| r.len())
|
||||
+ state.search.fed_artists.len()
|
||||
+ state.search.fed_tracks.len()
|
||||
}
|
||||
Some(GlobalView::FedArtist { .. }) => fed_card_len(state),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1527,15 +1556,6 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
||||
not_yet(state, "Navigation in this view");
|
||||
return None;
|
||||
}
|
||||
enum Outcome {
|
||||
Push(GlobalView),
|
||||
Play {
|
||||
tracks: Vec<crate::library::models::TrackItem>,
|
||||
start: usize,
|
||||
},
|
||||
PlayFed(crate::federation::FedTrack),
|
||||
Nothing,
|
||||
}
|
||||
let outcome = match state.global.stack.last().copied() {
|
||||
None => match state.global.artists.get(state.global.selected) {
|
||||
Some(artist) => Outcome::Push(GlobalView::Artist {
|
||||
@@ -1604,18 +1624,32 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
||||
start: cursor - artists - releases,
|
||||
}
|
||||
} else {
|
||||
let fed_index = cursor - artists - releases - results.tracks.len();
|
||||
match state.search.fed_tracks.get(fed_index) {
|
||||
Some(fed) => Outcome::PlayFed(fed.clone()),
|
||||
fed_outcome(state, cursor - artists - releases - results.tracks.len())
|
||||
}
|
||||
}
|
||||
None => fed_outcome(state, cursor),
|
||||
},
|
||||
Some(GlobalView::FedArtist { cursor }) => {
|
||||
match &state.fed_artist_view {
|
||||
Some((name, Loadable::Ready(card))) => {
|
||||
match card
|
||||
.releases
|
||||
.iter()
|
||||
.flat_map(|release| release.tracks.iter().map(move |t| (release, t)))
|
||||
.nth(cursor)
|
||||
{
|
||||
Some((release, track)) => {
|
||||
match fed_track_from_card(name, release, track) {
|
||||
Some(fed) => Outcome::PlayFed(fed),
|
||||
None => Outcome::Nothing,
|
||||
}
|
||||
}
|
||||
None => Outcome::Nothing,
|
||||
}
|
||||
}
|
||||
_ => Outcome::Nothing,
|
||||
}
|
||||
None => match state.search.fed_tracks.get(cursor) {
|
||||
Some(fed) => Outcome::PlayFed(fed.clone()),
|
||||
None => Outcome::Nothing,
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
match outcome {
|
||||
Outcome::Push(view) => {
|
||||
@@ -1632,10 +1666,60 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
||||
state.status_message = Some(format!("federation: fetching \"{}\"…", fed.title));
|
||||
Some(Effect::FedPlay(fed))
|
||||
}
|
||||
Outcome::OpenFedArtist(name) => {
|
||||
state.fed_artist_view = Some((name.clone(), Loadable::Loading));
|
||||
state.global.stack.push(GlobalView::FedArtist { cursor: 0 });
|
||||
state.active_tab = Tab::Global;
|
||||
Some(Effect::FedOpenArtist(name))
|
||||
}
|
||||
Outcome::Nothing => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What Enter resolved to in the current view.
|
||||
enum Outcome {
|
||||
Push(GlobalView),
|
||||
Play {
|
||||
tracks: Vec<crate::library::models::TrackItem>,
|
||||
start: usize,
|
||||
},
|
||||
PlayFed(crate::federation::FedTrack),
|
||||
OpenFedArtist(String),
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// Enter inside the federated section of the search results: artists open
|
||||
/// their card, tracks play.
|
||||
fn fed_outcome(state: &AppState, fed_index: usize) -> Outcome {
|
||||
let artists = &state.search.fed_artists;
|
||||
if fed_index < artists.len() {
|
||||
return Outcome::OpenFedArtist(artists[fed_index].name.clone());
|
||||
}
|
||||
match state.search.fed_tracks.get(fed_index - artists.len()) {
|
||||
Some(fed) => Outcome::PlayFed(fed.clone()),
|
||||
None => Outcome::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
/// A playable FedTrack out of a card row (first source; the rest are
|
||||
/// fallbacks for a later improvement).
|
||||
fn fed_track_from_card(
|
||||
artist: &str,
|
||||
release: &crate::federation::FedRelease,
|
||||
track: &crate::federation::FedCardTrack,
|
||||
) -> Option<crate::federation::FedTrack> {
|
||||
let (owner, item_id) = track.sources.first()?.clone();
|
||||
Some(crate::federation::FedTrack {
|
||||
item_id,
|
||||
owner,
|
||||
own: false,
|
||||
title: track.title.clone(),
|
||||
artist_names: vec![artist.to_string()],
|
||||
year: release.year,
|
||||
duration_seconds: track.duration_seconds.map(|d| d.round() as i64),
|
||||
})
|
||||
}
|
||||
|
||||
/// Enter on the Federation tab: toggle switches, open text inputs, run
|
||||
/// one-shot operations. The heavy lifting happens in perform_effect().
|
||||
fn federation_select(state: &mut AppState) -> Option<Effect> {
|
||||
@@ -1646,7 +1730,7 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
|
||||
if !settings.enabled && settings.network_id.trim().is_empty() {
|
||||
state.popup = Some(Popup::FedInput {
|
||||
field: FedInputField::NetworkId,
|
||||
input: String::new(),
|
||||
input: crate::app::input::LineEdit::default(),
|
||||
});
|
||||
return None;
|
||||
}
|
||||
@@ -1656,7 +1740,9 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
|
||||
FedRow::NetworkId => {
|
||||
state.popup = Some(Popup::FedInput {
|
||||
field: FedInputField::NetworkId,
|
||||
input: state.federation.settings.network_id.clone(),
|
||||
input: crate::app::input::LineEdit::new(
|
||||
state.federation.settings.network_id.clone(),
|
||||
),
|
||||
});
|
||||
None
|
||||
}
|
||||
@@ -1669,7 +1755,7 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
|
||||
FedRow::Connect => {
|
||||
state.popup = Some(Popup::FedInput {
|
||||
field: FedInputField::ConnectTicket,
|
||||
input: String::new(),
|
||||
input: crate::app::input::LineEdit::default(),
|
||||
});
|
||||
None
|
||||
}
|
||||
@@ -1724,10 +1810,14 @@ fn go_back(state: &mut AppState) {
|
||||
state.active_tab = origin;
|
||||
return;
|
||||
}
|
||||
if let Some(popped) = state.global.stack.pop()
|
||||
&& matches!(popped, GlobalView::Search { .. }) {
|
||||
if let Some(popped) = state.global.stack.pop() {
|
||||
if matches!(popped, GlobalView::Search { .. }) {
|
||||
state.search = SearchState::default();
|
||||
}
|
||||
if matches!(popped, GlobalView::FedArtist { .. }) {
|
||||
state.fed_artist_view = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1754,6 +1844,7 @@ fn reset_tab(state: &mut AppState, tab: Tab) {
|
||||
state.search = SearchState::default();
|
||||
}
|
||||
state.global.stack.clear();
|
||||
state.fed_artist_view = None;
|
||||
}
|
||||
Tab::Playlists => state.playlists.opened = None,
|
||||
Tab::Federation => state.federation.cursor = 0,
|
||||
|
||||
@@ -168,7 +168,7 @@ fn extension_for_mime(mime: &str) -> &'static str {
|
||||
}
|
||||
|
||||
/// Reads one `\n`-terminated line, bounded by [`MAX_PROTOCOL_LINE`].
|
||||
async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>> {
|
||||
pub(super) async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>> {
|
||||
let mut line = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
loop {
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
//! The peer catalog protocol: one peer asks another for its library slice
|
||||
//! of a single artist (releases with full tracklists), used to assemble a
|
||||
//! federated artist card.
|
||||
//!
|
||||
//! Wire shape on the `furumi-fd/catalog/1` ALPN: the requester sends one
|
||||
//! JSON line ([`CatalogRequest`]) and finishes; the owner answers with one
|
||||
//! JSON document ([`CatalogResponse`]) and finishes. All fields default, so
|
||||
//! the shape is extensible like the audio protocol.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use music_dht::{ByteStream, EndpointId, ItemKind, MusicDhtService, StreamAcceptor};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
use crate::library::Library;
|
||||
|
||||
/// ALPN of the catalog protocol.
|
||||
pub const CATALOG_ALPN: &[u8] = b"furumi-fd/catalog/1";
|
||||
|
||||
/// Upper bound for one catalog response (thousands of tracks fit easily).
|
||||
const MAX_CATALOG_BYTES: u64 = 4 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CatalogRequest {
|
||||
/// Artist display name; matched case-insensitively by the owner.
|
||||
artist: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct CatalogResponse {
|
||||
ok: bool,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
#[serde(default)]
|
||||
artist: Option<CatalogArtist>,
|
||||
}
|
||||
|
||||
/// One peer's library slice for an artist.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CatalogArtist {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub releases: Vec<CatalogRelease>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CatalogRelease {
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub release_type: String,
|
||||
#[serde(default)]
|
||||
pub year: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub tracks: Vec<CatalogTrack>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CatalogTrack {
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub track_number: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub disc_number: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub duration_seconds: Option<f64>,
|
||||
/// Hex DHT item id — the key the audio is requested by (FedPlay).
|
||||
#[serde(default)]
|
||||
pub item_id: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serving side
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Runs the catalog accept loop until the acceptor closes.
|
||||
pub async fn serve_peers(mut acceptor: StreamAcceptor, library: Arc<Library>, own: EndpointId) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
let library = Arc::clone(&library);
|
||||
tokio::spawn(async move {
|
||||
let peer = stream.peer_id;
|
||||
if let Err(err) = serve_one(stream, library, own).await {
|
||||
tracing::warn!(peer = %peer, "catalog request failed: {err:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_one(mut stream: ByteStream, library: Arc<Library>, own: EndpointId) -> Result<()> {
|
||||
let request: CatalogRequest =
|
||||
serde_json::from_slice(&super::audio::read_line(&mut stream.recv).await?)?;
|
||||
tracing::info!(peer = %stream.peer_id, artist = %request.artist, "peer requested a catalog");
|
||||
|
||||
let response = tokio::task::spawn_blocking(move || build_catalog(&library, own, &request.artist))
|
||||
.await?
|
||||
.unwrap_or_else(|err| CatalogResponse {
|
||||
ok: false,
|
||||
error: Some(format!("catalog lookup failed: {err:#}")),
|
||||
artist: None,
|
||||
});
|
||||
let payload = serde_json::to_vec(&response)?;
|
||||
stream.send.write_all(&payload).await?;
|
||||
stream.send.finish()?;
|
||||
let _ = stream.send.stopped().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds this instance's library slice for `artist`.
|
||||
fn build_catalog(library: &Library, own: EndpointId, artist: &str) -> Result<CatalogResponse> {
|
||||
let Some(artist_id) = library.artist_id_by_name(artist)? else {
|
||||
return Ok(CatalogResponse {
|
||||
ok: false,
|
||||
error: Some("artist not found in the library".to_string()),
|
||||
artist: None,
|
||||
});
|
||||
};
|
||||
let detail = library.artist(artist_id)?;
|
||||
let item_id_of = |track_id: i64| -> String {
|
||||
super::audio::hex_encode(
|
||||
music_dht::ItemId::derive(&own, ItemKind::Track, &format!("track:{track_id}"))
|
||||
.as_bytes(),
|
||||
)
|
||||
};
|
||||
let mut releases = Vec::new();
|
||||
for card in &detail.releases {
|
||||
let release = library.release(card.id)?;
|
||||
releases.push(CatalogRelease {
|
||||
title: release.title,
|
||||
release_type: release.release_type,
|
||||
year: release.year,
|
||||
tracks: release
|
||||
.tracks
|
||||
.iter()
|
||||
.map(|track| CatalogTrack {
|
||||
title: track.title.clone(),
|
||||
track_number: track.track_number,
|
||||
disc_number: track.disc_number,
|
||||
duration_seconds: (track.duration_seconds > 0.0)
|
||||
.then_some(track.duration_seconds),
|
||||
item_id: item_id_of(track.id),
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
Ok(CatalogResponse {
|
||||
ok: true,
|
||||
error: None,
|
||||
artist: Some(CatalogArtist {
|
||||
name: detail.name,
|
||||
releases,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Requesting side
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fetches one peer's catalog slice for `artist`.
|
||||
pub async fn fetch_catalog(
|
||||
service: &MusicDhtService,
|
||||
owner: EndpointId,
|
||||
artist: &str,
|
||||
) -> Result<CatalogArtist> {
|
||||
let mut stream = service
|
||||
.open_stream(owner, CATALOG_ALPN)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("cannot reach the peer: {err}"))?;
|
||||
let mut line = serde_json::to_vec(&CatalogRequest {
|
||||
artist: artist.to_string(),
|
||||
})?;
|
||||
line.push(b'\n');
|
||||
stream.send.write_all(&line).await?;
|
||||
stream.send.finish()?;
|
||||
|
||||
let mut payload = Vec::new();
|
||||
// The whole response is one JSON document, bounded by the byte cap.
|
||||
tokio::io::AsyncReadExt::take(StreamReader(&mut stream), MAX_CATALOG_BYTES + 1)
|
||||
.read_to_end(&mut payload)
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
payload.len() as u64 <= MAX_CATALOG_BYTES,
|
||||
"catalog response exceeds {MAX_CATALOG_BYTES} bytes"
|
||||
);
|
||||
let response: CatalogResponse =
|
||||
serde_json::from_slice(&payload).context("malformed catalog response")?;
|
||||
if !response.ok {
|
||||
anyhow::bail!(
|
||||
"peer refused the catalog: {}",
|
||||
response
|
||||
.error
|
||||
.unwrap_or_else(|| "unknown error".to_string())
|
||||
);
|
||||
}
|
||||
response.artist.context("empty catalog response")
|
||||
}
|
||||
|
||||
/// AsyncRead adapter over the receive half of a byte stream.
|
||||
struct StreamReader<'a>(&'a mut ByteStream);
|
||||
|
||||
impl tokio::io::AsyncRead for StreamReader<'_> {
|
||||
fn poll_read(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::pin::Pin::new(&mut self.0.recv).poll_read(cx, buf)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aggregation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The assembled, deduplicated federated artist card.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FedArtistCard {
|
||||
#[allow(dead_code, reason = "the open card is keyed by name in AppState")]
|
||||
pub name: String,
|
||||
/// Peers whose catalogs contributed to the card.
|
||||
pub peers: usize,
|
||||
pub releases: Vec<FedRelease>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FedRelease {
|
||||
pub title: String,
|
||||
pub release_type: String,
|
||||
pub year: Option<i32>,
|
||||
pub tracks: Vec<FedCardTrack>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FedCardTrack {
|
||||
pub title: String,
|
||||
pub track_number: Option<i32>,
|
||||
pub disc_number: Option<i32>,
|
||||
pub duration_seconds: Option<f64>,
|
||||
/// Every peer that can serve this track: (owner hex, item id hex).
|
||||
/// Duplicates collapse into one row; all sources stay playable.
|
||||
pub sources: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Merges per-peer catalogs into one card: releases are keyed by normalized
|
||||
/// title, tracks within a release by normalized title + track number; a
|
||||
/// track present on several peers keeps every source.
|
||||
pub fn merge_catalogs(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> FedArtistCard {
|
||||
let peers = catalogs.len();
|
||||
let mut releases: Vec<FedRelease> = Vec::new();
|
||||
let mut release_index: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
for (owner_hex, catalog) in catalogs {
|
||||
for release in catalog.releases {
|
||||
let release_key = music_dht::normalize_name(&release.title);
|
||||
let slot = *release_index.entry(release_key).or_insert_with(|| {
|
||||
releases.push(FedRelease {
|
||||
title: release.title.clone(),
|
||||
release_type: release.release_type.clone(),
|
||||
year: None,
|
||||
tracks: Vec::new(),
|
||||
});
|
||||
releases.len() - 1
|
||||
});
|
||||
let merged = &mut releases[slot];
|
||||
if merged.year.is_none() {
|
||||
merged.year = release.year;
|
||||
}
|
||||
if merged.release_type.is_empty() {
|
||||
merged.release_type = release.release_type.clone();
|
||||
}
|
||||
for track in release.tracks {
|
||||
if track.item_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let existing = merged.tracks.iter_mut().find(|t| {
|
||||
music_dht::normalize_name(&t.title) == music_dht::normalize_name(&track.title)
|
||||
&& (t.track_number == track.track_number
|
||||
|| t.track_number.is_none()
|
||||
|| track.track_number.is_none())
|
||||
});
|
||||
match existing {
|
||||
Some(t) => {
|
||||
if t.track_number.is_none() {
|
||||
t.track_number = track.track_number;
|
||||
}
|
||||
if t.duration_seconds.is_none() {
|
||||
t.duration_seconds = track.duration_seconds;
|
||||
}
|
||||
t.sources.push((owner_hex.clone(), track.item_id));
|
||||
}
|
||||
None => merged.tracks.push(FedCardTrack {
|
||||
title: track.title,
|
||||
track_number: track.track_number,
|
||||
disc_number: track.disc_number,
|
||||
duration_seconds: track.duration_seconds,
|
||||
sources: vec![(owner_hex.clone(), track.item_id)],
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for release in &mut releases {
|
||||
release
|
||||
.tracks
|
||||
.sort_by_key(|t| (t.disc_number.unwrap_or(1), t.track_number.unwrap_or(i32::MAX)));
|
||||
}
|
||||
releases.sort_by(|a, b| {
|
||||
a.year
|
||||
.unwrap_or(i32::MAX)
|
||||
.cmp(&b.year.unwrap_or(i32::MAX))
|
||||
.then_with(|| a.title.cmp(&b.title))
|
||||
});
|
||||
|
||||
FedArtistCard {
|
||||
name: name.to_string(),
|
||||
peers,
|
||||
releases,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn track(title: &str, number: i32, item: &str) -> CatalogTrack {
|
||||
CatalogTrack {
|
||||
title: title.into(),
|
||||
track_number: Some(number),
|
||||
disc_number: None,
|
||||
duration_seconds: Some(100.0),
|
||||
item_id: item.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_and_dedupes_across_peers() {
|
||||
let catalog = |item_prefix: &str| CatalogArtist {
|
||||
name: "Metallica".into(),
|
||||
releases: vec![CatalogRelease {
|
||||
title: "Black Album".into(),
|
||||
release_type: "album".into(),
|
||||
year: Some(1991),
|
||||
tracks: vec![
|
||||
track("Enter Sandman", 1, &format!("{item_prefix}1")),
|
||||
track("Sad But True", 2, &format!("{item_prefix}2")),
|
||||
],
|
||||
}],
|
||||
};
|
||||
let card = merge_catalogs(
|
||||
"Metallica",
|
||||
vec![
|
||||
("peer-a".to_string(), catalog("a")),
|
||||
("peer-b".to_string(), catalog("b")),
|
||||
],
|
||||
);
|
||||
assert_eq!(card.peers, 2);
|
||||
assert_eq!(card.releases.len(), 1);
|
||||
let release = &card.releases[0];
|
||||
assert_eq!(release.year, Some(1991));
|
||||
assert_eq!(release.tracks.len(), 2);
|
||||
// Both peers stay as sources of the deduplicated track.
|
||||
assert_eq!(release.tracks[0].sources.len(), 2);
|
||||
}
|
||||
}
|
||||
+136
-5
@@ -13,6 +13,7 @@
|
||||
//! the network too).
|
||||
|
||||
mod audio;
|
||||
pub mod catalog;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
@@ -31,6 +32,7 @@ use crate::library::Library;
|
||||
use crate::library::models::{ArtistRef, TrackItem};
|
||||
|
||||
pub use audio::{AUDIO_ALPN, TrackMetadata};
|
||||
pub use catalog::{CATALOG_ALPN, FedArtistCard, FedCardTrack, FedRelease};
|
||||
|
||||
/// How often the published library is re-synchronized with the local index.
|
||||
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
||||
@@ -85,6 +87,23 @@ fn save_settings(settings: &FedSettings) -> Result<()> {
|
||||
// Data shapes for the UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// An artist surfaced by federated search — either an artist record, or
|
||||
/// derived from the artist names of matching tracks/releases (so searching
|
||||
/// a track title still leads to the artist's card).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FedArtistHit {
|
||||
pub name: String,
|
||||
/// Distinct peers (other than this instance) holding the artist.
|
||||
pub peers: usize,
|
||||
}
|
||||
|
||||
/// Federated search results for the UI.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct FedSearchResults {
|
||||
pub artists: Vec<FedArtistHit>,
|
||||
pub tracks: Vec<FedTrack>,
|
||||
}
|
||||
|
||||
/// A track found through federated search.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FedTrack {
|
||||
@@ -261,6 +280,8 @@ impl Federation {
|
||||
.rendezvous(RendezvousConfig::default())
|
||||
// Peers stream each other's audio over this protocol.
|
||||
.stream_protocol(AUDIO_ALPN)
|
||||
// ...and browse each other's per-artist catalogs over this one.
|
||||
.stream_protocol(CATALOG_ALPN)
|
||||
.build()
|
||||
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
||||
let (service, mut events) = MusicDhtService::start(config)
|
||||
@@ -298,11 +319,20 @@ impl Federation {
|
||||
Arc::clone(&self.library),
|
||||
service.endpoint_id(),
|
||||
));
|
||||
// Serve per-artist catalog requests (the federated artist card).
|
||||
let catalog_acceptor = service
|
||||
.stream_acceptor(CATALOG_ALPN)
|
||||
.map_err(|err| anyhow::anyhow!("failed to take the catalog acceptor: {err}"))?;
|
||||
let catalog_task = tokio::spawn(catalog::serve_peers(
|
||||
catalog_acceptor,
|
||||
Arc::clone(&self.library),
|
||||
service.endpoint_id(),
|
||||
));
|
||||
|
||||
*guard = Some(Running {
|
||||
service,
|
||||
network_name,
|
||||
tasks: vec![event_task, sync_task, audio_task],
|
||||
tasks: vec![event_task, sync_task, audio_task, catalog_task],
|
||||
});
|
||||
self.set_error(None);
|
||||
Ok(())
|
||||
@@ -403,15 +433,17 @@ impl Federation {
|
||||
status
|
||||
}
|
||||
|
||||
/// Searches the federated network for tracks matching `query`.
|
||||
pub async fn search(&self, query: &str) -> Result<Vec<FedTrack>> {
|
||||
/// Searches the federated network: matching tracks plus the artists a
|
||||
/// card can be assembled for (from artist records and from the artist
|
||||
/// names of matching tracks/releases).
|
||||
pub async fn search(&self, query: &str) -> Result<FedSearchResults> {
|
||||
let service = self.service().await?;
|
||||
let outcome = service
|
||||
.search_network(query)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?;
|
||||
let own = service.endpoint_id();
|
||||
Ok(outcome
|
||||
let tracks: Vec<FedTrack> = outcome
|
||||
.network_results
|
||||
.iter()
|
||||
.filter(|item| item.kind == ItemKind::Track)
|
||||
@@ -424,7 +456,106 @@ impl Federation {
|
||||
year: item.year,
|
||||
duration_seconds: item.duration_seconds.map(|d| d.round() as i64),
|
||||
})
|
||||
.collect())
|
||||
.collect();
|
||||
|
||||
// Artists: normalized name -> (display name, distinct non-own peers).
|
||||
let mut artists: std::collections::HashMap<
|
||||
String,
|
||||
(String, std::collections::HashSet<EndpointId>),
|
||||
> = Default::default();
|
||||
for item in &outcome.network_results {
|
||||
if item.owner == own {
|
||||
continue;
|
||||
}
|
||||
let mut note = |name: &str| {
|
||||
let key = music_dht::normalize_name(name);
|
||||
if key.is_empty() {
|
||||
return;
|
||||
}
|
||||
let entry = artists
|
||||
.entry(key)
|
||||
.or_insert_with(|| (name.to_string(), Default::default()));
|
||||
entry.1.insert(item.owner);
|
||||
};
|
||||
if item.kind == ItemKind::Artist {
|
||||
note(&item.name);
|
||||
}
|
||||
for artist in &item.artist_names {
|
||||
note(artist);
|
||||
}
|
||||
}
|
||||
let mut artists: Vec<FedArtistHit> = artists
|
||||
.into_values()
|
||||
.map(|(name, owners)| FedArtistHit {
|
||||
name,
|
||||
peers: owners.len(),
|
||||
})
|
||||
.collect();
|
||||
artists.sort_by(|a, b| b.peers.cmp(&a.peers).then_with(|| a.name.cmp(&b.name)));
|
||||
|
||||
Ok(FedSearchResults { artists, tracks })
|
||||
}
|
||||
|
||||
/// Assembles the federated artist card: finds the peers holding the
|
||||
/// artist through the DHT, asks each for its catalog slice directly and
|
||||
/// merges the answers (missing/slow peers are skipped).
|
||||
pub async fn artist_card(&self, name: &str) -> Result<FedArtistCard> {
|
||||
let service = self.service().await?;
|
||||
let own = service.endpoint_id();
|
||||
let normalized = music_dht::normalize_name(name);
|
||||
let outcome = service
|
||||
.search_network(name)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?;
|
||||
let owners: std::collections::HashSet<EndpointId> = outcome
|
||||
.network_results
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
(item.kind == ItemKind::Artist && item.normalized_name == normalized)
|
||||
|| item
|
||||
.artist_names
|
||||
.iter()
|
||||
.any(|artist| music_dht::normalize_name(artist) == normalized)
|
||||
})
|
||||
.map(|item| item.owner)
|
||||
.filter(|owner| *owner != own)
|
||||
.collect();
|
||||
anyhow::ensure!(!owners.is_empty(), "no peers hold artist \"{name}\"");
|
||||
|
||||
let mut requests = Vec::new();
|
||||
for owner in owners {
|
||||
let service = Arc::clone(&service);
|
||||
let name = name.to_string();
|
||||
requests.push(tokio::spawn(async move {
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
catalog::fetch_catalog(&service, owner, &name),
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(catalog)) => Some((owner.to_string(), catalog)),
|
||||
Ok(Err(err)) => {
|
||||
tracing::warn!(peer = %owner, "catalog fetch failed: {err:#}");
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(peer = %owner, "catalog fetch timed out");
|
||||
None
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
let mut catalogs = Vec::new();
|
||||
for request in requests {
|
||||
if let Ok(Some(catalog)) = request.await {
|
||||
catalogs.push(catalog);
|
||||
}
|
||||
}
|
||||
anyhow::ensure!(
|
||||
!catalogs.is_empty(),
|
||||
"none of the peers answered the catalog request"
|
||||
);
|
||||
Ok(catalog::merge_catalogs(name, catalogs))
|
||||
}
|
||||
|
||||
pub async fn ticket(&self) -> Result<String> {
|
||||
|
||||
@@ -719,6 +719,18 @@ impl Library {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Artist id by display name (case-insensitive), for catalog requests.
|
||||
pub fn artist_id_by_name(&self, name: &str) -> Result<Option<i64>> {
|
||||
let conn = self.lock();
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT id FROM artists WHERE name = ?1 COLLATE NOCASE",
|
||||
[name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
/// Image of one artist, for the federation metadata exchange.
|
||||
pub fn artist_image(&self, artist_id: i64) -> Result<Option<String>> {
|
||||
let conn = self.lock();
|
||||
|
||||
+137
-4
@@ -23,6 +23,7 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
Some(GlobalView::Artist { id, cursor }) => draw_artist(frame, area, state, *id, *cursor),
|
||||
Some(GlobalView::Release { id, cursor }) => draw_release(frame, area, state, *id, *cursor),
|
||||
Some(GlobalView::Search { cursor }) => draw_search(frame, area, state, *cursor),
|
||||
Some(GlobalView::FedArtist { cursor }) => draw_fed_artist(frame, area, state, *cursor),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,7 +716,12 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
let empty_results = SearchResults::default();
|
||||
let results = match &search.results {
|
||||
Some(results) => results,
|
||||
None if !state.search.fed_tracks.is_empty() || state.search.fed_loading => &empty_results,
|
||||
None if !state.search.fed_tracks.is_empty()
|
||||
|| !state.search.fed_artists.is_empty()
|
||||
|| state.search.fed_loading =>
|
||||
{
|
||||
&empty_results
|
||||
}
|
||||
None => {
|
||||
let hint = if search.query.is_empty() {
|
||||
"type to search artists, releases and tracks"
|
||||
@@ -725,7 +731,11 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
return centered_line(frame, inner, Line::styled(hint, theme::dim()));
|
||||
}
|
||||
};
|
||||
if results.len() == 0 && state.search.fed_tracks.is_empty() && !state.search.fed_loading {
|
||||
if results.len() == 0
|
||||
&& state.search.fed_tracks.is_empty()
|
||||
&& state.search.fed_artists.is_empty()
|
||||
&& !state.search.fed_loading
|
||||
{
|
||||
return centered_line(frame, inner, Line::styled("nothing found", theme::dim()));
|
||||
}
|
||||
|
||||
@@ -782,8 +792,12 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
// Tracks found on the federated network, marked with the owning peer.
|
||||
if !state.search.fed_tracks.is_empty() || state.search.fed_loading {
|
||||
// Federated section: artists whose card can be assembled, then tracks
|
||||
// (both marked with the owning peers).
|
||||
if !state.search.fed_tracks.is_empty()
|
||||
|| !state.search.fed_artists.is_empty()
|
||||
|| state.search.fed_loading
|
||||
{
|
||||
if !rows.is_empty() {
|
||||
rows.push((Line::default(), None, None));
|
||||
}
|
||||
@@ -793,6 +807,22 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
"Federation"
|
||||
};
|
||||
rows.push((Line::styled(header, theme::header()), None, None));
|
||||
for hit in &state.search.fed_artists {
|
||||
rows.push((
|
||||
Line::from(vec![
|
||||
Span::styled("⇅ ", theme::accent()),
|
||||
Span::raw(hit.name.clone()),
|
||||
Span::styled(" артист · открыть карточку", theme::dim()),
|
||||
]),
|
||||
Some(format!(
|
||||
"{} peer{}",
|
||||
hit.peers,
|
||||
if hit.peers == 1 { "" } else { "s" }
|
||||
)),
|
||||
Some(index),
|
||||
));
|
||||
index += 1;
|
||||
}
|
||||
for fed in &state.search.fed_tracks {
|
||||
let origin = if fed.own {
|
||||
"your library".to_string()
|
||||
@@ -842,3 +872,106 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
draw_row(frame, rect, line, right, row_cursor == Some(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Federated artist card (assembled from peer catalogs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
let Some((name, data)) = &state.fed_artist_view else {
|
||||
return centered_line(frame, area, Line::styled("no card is open", theme::dim()));
|
||||
};
|
||||
let inner = bordered(frame, area, format!(" {name} — federation "));
|
||||
let card = match data {
|
||||
Loadable::Loading => {
|
||||
return centered_line(
|
||||
frame,
|
||||
inner,
|
||||
Line::styled("собираем карточку с пиров…", theme::dim()),
|
||||
);
|
||||
}
|
||||
Loadable::Failed(message) => {
|
||||
return centered_line(frame, inner, Line::styled(message.clone(), error_style()));
|
||||
}
|
||||
Loadable::Ready(card) => card,
|
||||
};
|
||||
|
||||
// All rows are one line tall: (line, right column, cursor index).
|
||||
let mut rows: Vec<(Line, Option<String>, Option<usize>)> = Vec::new();
|
||||
rows.push((
|
||||
Line::styled(
|
||||
format!(
|
||||
"{} релизов · {} треков · с {} пиров",
|
||||
card.releases.len(),
|
||||
card.releases.iter().map(|r| r.tracks.len()).sum::<usize>(),
|
||||
card.peers
|
||||
),
|
||||
theme::dim(),
|
||||
),
|
||||
None,
|
||||
None,
|
||||
));
|
||||
let mut index = 0;
|
||||
for release in &card.releases {
|
||||
rows.push((Line::default(), None, None));
|
||||
let mut header = release.title.clone();
|
||||
if let Some(year) = release.year {
|
||||
header.push_str(&format!(" ({year})"));
|
||||
}
|
||||
rows.push((
|
||||
Line::from(vec![
|
||||
Span::styled(header, theme::header()),
|
||||
Span::styled(format!(" {}", release.release_type), theme::dim()),
|
||||
]),
|
||||
None,
|
||||
None,
|
||||
));
|
||||
for track in &release.tracks {
|
||||
let number = track
|
||||
.track_number
|
||||
.map(|n| format!("{n:>2}. "))
|
||||
.unwrap_or_else(|| " ".to_string());
|
||||
let duration = track
|
||||
.duration_seconds
|
||||
.map(|d| {
|
||||
let total = d.round() as i64;
|
||||
format!("{}:{:02}", total / 60, total % 60)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let sources = if track.sources.len() > 1 {
|
||||
format!("{} · {} пиров", duration, track.sources.len())
|
||||
} else {
|
||||
duration
|
||||
};
|
||||
rows.push((
|
||||
Line::from(vec![
|
||||
Span::styled("⇅ ", theme::accent()),
|
||||
Span::raw(format!("{number}{}", track.title)),
|
||||
]),
|
||||
Some(sources),
|
||||
Some(index),
|
||||
));
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let cursor_row = rows
|
||||
.iter()
|
||||
.position(|(_, _, c)| *c == Some(cursor))
|
||||
.unwrap_or(0);
|
||||
let visible = usize::from(inner.height.max(1));
|
||||
let first = cursor_row
|
||||
.saturating_sub(visible / 2)
|
||||
.min(rows.len().saturating_sub(visible));
|
||||
for (offset, (line, right, row_cursor)) in
|
||||
rows.into_iter().enumerate().skip(first).take(visible)
|
||||
{
|
||||
let rect = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y + (offset - first) as u16,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
};
|
||||
draw_row(frame, rect, line, right, row_cursor == Some(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
+38
-6
@@ -12,6 +12,7 @@ use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph, Tabs};
|
||||
|
||||
use crate::app::input::LineEdit;
|
||||
use crate::app::state::{AppState, Tab, TrackSelectionScope};
|
||||
use crate::config::keymap::Keymap;
|
||||
|
||||
@@ -298,12 +299,12 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
|
||||
if state.cmdline.active {
|
||||
// Vim-style command line takes over the message row.
|
||||
let line = Line::from(vec![
|
||||
Span::styled(":", theme::header()),
|
||||
Span::raw(state.cmdline.input.clone()),
|
||||
Span::styled("█", theme::accent()),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(line), message_row);
|
||||
let mut spans = vec![Span::styled(":", theme::header())];
|
||||
spans.extend(line_edit_spans(
|
||||
&state.cmdline.input,
|
||||
usize::from(message_row.width.saturating_sub(2)),
|
||||
));
|
||||
frame.render_widget(Paragraph::new(Line::from(spans)), message_row);
|
||||
draw_version(frame, message_row);
|
||||
return;
|
||||
}
|
||||
@@ -448,3 +449,34 @@ fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
|
||||
.areas(rect);
|
||||
rect
|
||||
}
|
||||
|
||||
/// Renders a [`LineEdit`] as spans with a visible cursor, windowed so the
|
||||
/// cursor always stays on screen when the value is wider than `width`.
|
||||
pub(crate) fn line_edit_spans(edit: &LineEdit, width: usize) -> Vec<Span<'static>> {
|
||||
let width = width.max(2);
|
||||
let chars: Vec<char> = edit.as_str().chars().collect();
|
||||
let cursor = edit.cursor().min(chars.len());
|
||||
// Window start: keep the cursor within the visible slice (one cell is
|
||||
// reserved for the cursor block itself when it sits at the end).
|
||||
let start = (cursor + 1).saturating_sub(width);
|
||||
let end = (start + width.saturating_sub(1)).min(chars.len());
|
||||
let before: String = chars[start..cursor].iter().collect();
|
||||
let (under, after): (String, String) = if cursor < chars.len() {
|
||||
(
|
||||
chars[cursor].to_string(),
|
||||
chars[cursor + 1..end.max(cursor + 1)].iter().collect(),
|
||||
)
|
||||
} else {
|
||||
("█".to_string(), String::new())
|
||||
};
|
||||
let cursor_style = if cursor < chars.len() {
|
||||
ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
|
||||
} else {
|
||||
theme::accent()
|
||||
};
|
||||
vec![
|
||||
Span::raw(before),
|
||||
Span::styled(under, cursor_style),
|
||||
Span::raw(after),
|
||||
]
|
||||
}
|
||||
|
||||
+16
-31
@@ -34,7 +34,7 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
||||
}
|
||||
|
||||
/// One-line text entry on the Federation tab (network id / peer ticket).
|
||||
fn draw_fed_input(frame: &mut Frame, title: &str, input: &str) {
|
||||
fn draw_fed_input(frame: &mut Frame, title: &str, input: &crate::app::input::LineEdit) {
|
||||
let area = centered(frame.area(), 64, 5);
|
||||
let block = Block::bordered()
|
||||
.title(format!(" {title} "))
|
||||
@@ -45,20 +45,8 @@ fn draw_fed_input(frame: &mut Frame, title: &str, input: &str) {
|
||||
frame.render_widget(block, area);
|
||||
let [entry_area, hint_area] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
|
||||
// Keep the tail visible when the value (a ticket) exceeds the width.
|
||||
let visible: String = {
|
||||
let width = usize::from(entry_area.width.saturating_sub(2));
|
||||
let chars: Vec<char> = input.chars().collect();
|
||||
let skip = chars.len().saturating_sub(width);
|
||||
chars[skip..].iter().collect()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw(visible),
|
||||
Span::styled("█", theme::accent()),
|
||||
])),
|
||||
entry_area,
|
||||
);
|
||||
let spans = super::line_edit_spans(input, usize::from(entry_area.width.saturating_sub(1)));
|
||||
frame.render_widget(Paragraph::new(Line::from(spans)), entry_area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled("enter: apply · esc: cancel", theme::dim()))
|
||||
.alignment(Alignment::Center),
|
||||
@@ -118,16 +106,18 @@ fn draw_edit(
|
||||
});
|
||||
let field_inner = field_block.inner(areas[index]);
|
||||
frame.render_widget(field_block, areas[index]);
|
||||
let width = usize::from(field_inner.width.saturating_sub(1));
|
||||
let mut shown: String = field
|
||||
.value
|
||||
.chars()
|
||||
.skip(field.value.chars().count().saturating_sub(width))
|
||||
.collect();
|
||||
let width = usize::from(field_inner.width);
|
||||
if focused {
|
||||
shown.push('█');
|
||||
let spans = super::line_edit_spans(&field.value, width);
|
||||
frame.render_widget(Paragraph::new(Line::from(spans)), field_inner);
|
||||
} else {
|
||||
let shown: String = field
|
||||
.value
|
||||
.chars()
|
||||
.skip(field.value.chars().count().saturating_sub(width))
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(shown), field_inner);
|
||||
}
|
||||
frame.render_widget(Paragraph::new(shown), field_inner);
|
||||
}
|
||||
|
||||
let footer = areas[areas.len() - 1];
|
||||
@@ -394,7 +384,7 @@ fn draw_picker(frame: &mut Frame, state: &AppState, track_title: &str, cursor: u
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_name_entry(frame: &mut Frame, input: &str, busy: bool) {
|
||||
fn draw_name_entry(frame: &mut Frame, input: &crate::app::input::LineEdit, busy: bool) {
|
||||
let area = centered(frame.area(), 44, 7);
|
||||
let block = Block::bordered()
|
||||
.title(" New playlist ")
|
||||
@@ -416,13 +406,8 @@ fn draw_name_entry(frame: &mut Frame, input: &str, busy: bool) {
|
||||
.border_style(theme::accent());
|
||||
let name_inner = name_block.inner(field);
|
||||
frame.render_widget(name_block, field);
|
||||
let width = usize::from(name_inner.width.saturating_sub(1));
|
||||
let mut shown: String = input
|
||||
.chars()
|
||||
.skip(input.chars().count().saturating_sub(width))
|
||||
.collect();
|
||||
shown.push('█');
|
||||
frame.render_widget(Paragraph::new(shown), name_inner);
|
||||
let spans = super::line_edit_spans(input, usize::from(name_inner.width));
|
||||
frame.render_widget(Paragraph::new(Line::from(spans)), name_inner);
|
||||
|
||||
let hint = if busy {
|
||||
Line::styled("creating…", theme::accent())
|
||||
|
||||
Reference in New Issue
Block a user