Added Visualisation engine on rhai

This commit is contained in:
Ultradesu
2026-07-23 18:48:58 +03:00
parent af2542b668
commit 32a038dba6
19 changed files with 2685 additions and 65 deletions
+4 -1
View File
@@ -31,6 +31,7 @@ pub enum Action {
VolumeDown,
ToggleShuffle,
CycleRepeat,
ToggleVisualizer,
ToggleLike,
ToggleTrackSelection,
OpenTrackInfo,
@@ -96,7 +97,8 @@ impl Action {
| Action::VolumeUp
| Action::VolumeDown
| Action::ToggleShuffle
| Action::CycleRepeat => Category::Playback,
| Action::CycleRepeat
| Action::ToggleVisualizer => Category::Playback,
Action::QueueAddNext
| Action::QueueAddLast
| Action::DownloadSelected
@@ -173,6 +175,7 @@ impl Action {
Action::VolumeDown => "Volume down".into(),
Action::ToggleShuffle => "Toggle shuffle".into(),
Action::CycleRepeat => "Cycle repeat mode".into(),
Action::ToggleVisualizer => "Toggle fullscreen visualizer".into(),
Action::ToggleLike => "Like / unlike".into(),
Action::ToggleTrackSelection => "Track line selection".into(),
Action::OpenTrackInfo => "Track info".into(),
+96 -5
View File
@@ -7,7 +7,9 @@ mod popup;
pub mod state;
pub mod update;
use std::path::PathBuf;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
@@ -28,6 +30,7 @@ use state::AppState;
use update::{Effect, update};
const TICK_INTERVAL: Duration = Duration::from_millis(250);
const VISUALIZER_TICK_INTERVAL: Duration = Duration::from_millis(50);
/// Handles shared by background tasks; AppState stays pure UI data.
pub struct Runtime {
@@ -40,6 +43,8 @@ pub struct Runtime {
pub fed_resolving: std::sync::Mutex<std::collections::HashSet<i64>>,
/// Caps concurrent artwork loads so they never starve the disk.
pub art_semaphore: Arc<tokio::sync::Semaphore>,
/// The terminal screen was externally disturbed and needs a full repaint.
pub force_redraw: bool,
/// Monotonic sequence for live search; stale responses are dropped.
pub search_seq: Arc<std::sync::atomic::AtomicU64>,
pub player: player::Controller,
@@ -75,6 +80,9 @@ pub async fn run(
status_message: startup_warning,
..AppState::default()
};
if let Err(err) = state.visualizer.load_library() {
state.status_message = Some(format!("visualizations disabled: {err:#}"));
}
let federation = crate::federation::Federation::new(Arc::clone(&library));
state.federation.settings = federation.settings();
@@ -86,6 +94,7 @@ pub async fn run(
fed_status_at: None,
fed_resolving: std::sync::Mutex::new(std::collections::HashSet::new()),
art_semaphore: Arc::new(tokio::sync::Semaphore::new(4)),
force_redraw: false,
search_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
player: player::spawn(move |event| {
let _ = player_events.send(AppEvent::Player(event));
@@ -108,8 +117,14 @@ pub async fn run(
let mut input = EventStream::new();
let mut tick = tokio::time::interval(TICK_INTERVAL);
tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut visual_tick = tokio::time::interval(VISUALIZER_TICK_INTERVAL);
visual_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
if runtime.force_redraw {
terminal.clear()?;
runtime.force_redraw = false;
}
terminal.draw(|frame| ui::draw(frame, &state, &keymap))?;
tokio::select! {
@@ -121,13 +136,13 @@ pub async fn run(
Some(app_event) = event_rx.recv() => handle_app_event(&mut state, &mut runtime, app_event),
_ = tick.tick() => {
expire_quit_confirmation(&mut state);
if state.player.current.is_some() && !runtime.player_start_pending {
state.player.position_secs = runtime.player.shared.position().as_secs_f64();
state.player.paused = runtime.player.shared.paused();
}
sync_player_shared(&mut state, &runtime);
maybe_prefetch_next(&mut state, &runtime);
push_media_update(&state, &mut runtime, false);
}
_ = visual_tick.tick(), if state.visualizer.active => {
sync_player_shared(&mut state, &runtime);
}
}
if state.should_quit {
@@ -138,6 +153,14 @@ pub async fn run(
}
}
fn sync_player_shared(state: &mut AppState, runtime: &Runtime) {
if state.player.current.is_some() && !runtime.player_start_pending {
state.player.position_secs = runtime.player.shared.position().as_secs_f64();
state.player.paused = runtime.player.shared.paused();
}
state.player.audio_analysis = runtime.player.shared.audio_analysis();
}
const ARTISTS_PREFETCH_MARGIN: usize = 24;
/// How many artist tiles one screen holds right now (grid geometry from the
@@ -522,6 +545,27 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
});
}
}
Effect::OpenVisualizerEditor { path } => match open_visualizer_editor(&path) {
Ok(()) => {
runtime.force_redraw = true;
match state.visualizer.load_library() {
Ok(()) => {
clamp_settings_cursor(state);
state.status_message =
Some(format!("visualization script saved: {}", path.display()));
}
Err(err) => {
state.status_message = Some(format!("visualizations: {err:#}"));
}
}
}
Err(err) => {
runtime.force_redraw = true;
state.status_message = Some(format!("editor failed: {err:#}"));
let _ = state.visualizer.load_library();
clamp_settings_cursor(state);
}
},
Effect::RemoveQueueIndices {
restart_paused,
stop,
@@ -542,6 +586,52 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
}
}
fn clamp_settings_cursor(state: &mut AppState) {
let last = state::settings_rows(state).len().saturating_sub(1);
state.settings_cursor = state.settings_cursor.min(last);
}
fn open_visualizer_editor(path: &Path) -> Result<()> {
let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_string());
let _ = crossterm::terminal::disable_raw_mode();
let _ = crossterm::execute!(
io::stdout(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::event::DisableBracketedPaste
);
let status = if cfg!(windows) {
Command::new("cmd")
.args(["/C", &format!("{editor} {}", path.display())])
.status()
} else {
Command::new("sh")
.arg("-c")
.arg(format!("{editor} {}", shell_quote(path)))
.status()
};
let _ = crossterm::execute!(
io::stdout(),
crossterm::terminal::EnterAlternateScreen,
crossterm::event::EnableBracketedPaste
);
let _ = crossterm::terminal::enable_raw_mode();
match status {
Ok(status) if status.success() => Ok(()),
Ok(status) => anyhow::bail!("editor exited with {status}"),
Err(err) => Err(err.into()),
}
}
fn shell_quote(path: &Path) -> String {
let value = path.to_string_lossy();
format!("'{}'", value.replace('\'', "'\\''"))
}
/// Start playing `queue[queue_pos]`: open the local file in a background
/// task and hand the reader to the audio thread.
fn play_current(state: &mut AppState, runtime: &mut Runtime) {
@@ -578,6 +668,7 @@ fn start_current_audio(
state.player.playing = true;
state.player.paused = paused;
state.player.position_secs = position_secs.max(0.0);
state.player.audio_analysis = player::AudioAnalysisSnapshot::default();
state.player.track_started_at = same_track_started_at.or_else(|| Some(now_epoch_seconds()));
state.player.prefetched_pos = None;
state.status_message = Some(format!("{}{}", track.title, track.artist_line()));
+38 -4
View File
@@ -543,7 +543,7 @@ impl FedInputField {
}
}
/// Rows of the Federation tab, in display order.
/// Rows of the federation block inside Settings, in display order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FedRow {
Toggle,
@@ -565,10 +565,40 @@ impl FedRow {
];
}
/// The Federation tab: settings mirror + the latest status snapshot.
/// Rows of the Settings tab, in display order. Federation rows are fixed;
/// visualization script rows are derived from the scripts currently found in
/// the config directory.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsRow {
Federation(FedRow),
VisualizationClock,
VisualizationScript(usize),
VisualizationNew,
VisualizationEdit,
}
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
let mut rows = Vec::new();
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
rows.push(SettingsRow::VisualizationClock);
rows.extend(
state
.visualizer
.scripts
.iter()
.enumerate()
.map(|(index, _)| SettingsRow::VisualizationScript(index)),
);
rows.push(SettingsRow::VisualizationNew);
if !state.visualizer.scripts.is_empty() {
rows.push(SettingsRow::VisualizationEdit);
}
rows
}
/// Federation settings mirror + the latest status snapshot for Settings.
#[derive(Debug, Default)]
pub struct FederationTab {
pub cursor: usize,
pub settings: crate::federation::FedSettings,
pub status: Option<crate::federation::FedStatus>,
}
@@ -635,7 +665,7 @@ impl Tab {
Tab::Global => "Global",
Tab::Playlists => "Playlists",
Tab::Queue => "Queue",
Tab::Federation => "Federation",
Tab::Federation => "Settings",
Tab::Logs => "Logs",
}
}
@@ -704,6 +734,7 @@ pub struct PlayerBar {
pub playing: bool,
pub paused: bool,
pub position_secs: f64,
pub audio_analysis: crate::player::AudioAnalysisSnapshot,
/// Epoch seconds when the current track started (for history reports).
pub track_started_at: Option<i64>,
/// Queue index already enqueued in the audio thread for gapless play.
@@ -725,6 +756,7 @@ impl Default for PlayerBar {
playing: false,
paused: false,
position_secs: 0.0,
audio_analysis: crate::player::AudioAnalysisSnapshot::default(),
track_started_at: None,
prefetched_pos: None,
original_order: None,
@@ -747,7 +779,9 @@ pub struct AppState {
pub help_visible: bool,
pub pending_keys: Option<String>,
pub status_message: Option<String>,
pub settings_cursor: usize,
pub player: PlayerBar,
pub visualizer: crate::visualizer::VisualizerState,
pub global: GlobalTab,
pub artist_views: HashMap<i64, Loadable<ArtistDetail>>,
pub release_views: HashMap<i64, Loadable<ReleaseDetail>>,
+113 -18
View File
@@ -6,7 +6,7 @@ use crate::library::models::TrackItem;
use super::state::{
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab,
TrackSelectionScope, ViewMode, fed_release_display_order, fed_release_rows,
release_display_order, release_rows,
release_display_order, release_rows, settings_rows,
};
pub const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500);
@@ -43,7 +43,7 @@ pub enum Effect {
restart_paused: Option<bool>,
stop: bool,
},
/// Persist the Federation-tab settings and start/stop the node.
/// Persist the federation settings and start/stop the node.
FedApplySettings,
/// Force an immediate library publish into the DHT.
FedSyncNow,
@@ -59,6 +59,10 @@ pub enum Effect {
FedFetchTrackInfo {
tracks: Vec<(i64, crate::federation::FedTrack)>,
},
/// Temporarily leaves the TUI and opens a visualization script in $EDITOR.
OpenVisualizerEditor {
path: std::path::PathBuf,
},
}
pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
@@ -154,6 +158,20 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
state.player.repeat = state.player.repeat.next();
Some(Effect::SetOptions)
}
Action::ToggleVisualizer => {
if state.visualizer.active {
state.visualizer.close();
} else if state.player.current.is_some() {
state.visualizer.open();
state.help_visible = false;
state.popup = None;
state.cmdline.active = false;
state.pending_keys = None;
} else {
state.status_message = Some("nothing playing — start a track first".into());
}
None
}
Action::MoveUp => {
move_selection(state, 0, -1);
None
@@ -219,6 +237,10 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
None
}
Action::Select => select_current(state),
Action::Back if state.visualizer.active => {
state.visualizer.close();
None
}
Action::Back if state.track_selection.is_active() => {
state.track_selection.clear();
state.status_message = Some("selection cleared".into());
@@ -1460,9 +1482,8 @@ fn page_step(state: &AppState) -> isize {
fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
if state.active_tab == Tab::Federation {
if dy != 0 {
let last = super::state::FedRow::ALL.len() as isize - 1;
state.federation.cursor =
(state.federation.cursor as isize + dy).clamp(0, last) as usize;
let last = settings_rows(state).len() as isize - 1;
state.settings_cursor = (state.settings_cursor as isize + dy).clamp(0, last) as usize;
}
return;
}
@@ -1719,7 +1740,7 @@ fn current_view_len(state: &AppState) -> usize {
return state.player.queue.len();
}
if state.active_tab == Tab::Federation {
return super::state::FedRow::ALL.len();
return settings_rows(state).len();
}
match state.global.stack.last() {
None => state.global.artists.len(),
@@ -1766,8 +1787,8 @@ fn jump_selection(state: &mut AppState, first: bool) {
return;
}
if state.active_tab == Tab::Federation {
let last = super::state::FedRow::ALL.len() - 1;
state.federation.cursor = if first { 0 } else { last };
let last = settings_rows(state).len().saturating_sub(1);
state.settings_cursor = if first { 0 } else { last };
return;
}
if state.active_tab == Tab::Logs {
@@ -2267,12 +2288,12 @@ fn fed_card_featured_artist_names(track: &crate::federation::FedCardTrack) -> Ve
names
}
/// Enter on the Federation tab: toggle switches, open text inputs, run
/// Enter on Settings: toggle switches, open text inputs, run
/// one-shot operations. The heavy lifting happens in perform_effect().
fn federation_select(state: &mut AppState) -> Option<Effect> {
use super::state::{FedInputField, FedRow, Popup};
match FedRow::ALL.get(state.federation.cursor)? {
FedRow::Toggle => {
use super::state::{FedInputField, FedRow, Popup, SettingsRow};
match settings_rows(state).get(state.settings_cursor).copied()? {
SettingsRow::Federation(FedRow::Toggle) => {
let settings = &mut state.federation.settings;
if !settings.enabled && settings.network_id.trim().is_empty() {
state.popup = Some(Popup::FedInput {
@@ -2284,7 +2305,7 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
settings.enabled = !settings.enabled;
Some(Effect::FedApplySettings)
}
FedRow::NetworkId => {
SettingsRow::Federation(FedRow::NetworkId) => {
state.popup = Some(Popup::FedInput {
field: FedInputField::NetworkId,
input: crate::app::input::LineEdit::new(
@@ -2293,19 +2314,64 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
});
None
}
FedRow::SaveOnListen => {
SettingsRow::Federation(FedRow::SaveOnListen) => {
state.federation.settings.save_on_listen = !state.federation.settings.save_on_listen;
Some(Effect::FedApplySettings)
}
FedRow::SyncNow => Some(Effect::FedSyncNow),
FedRow::ShowTicket => Some(Effect::FedShowTicket),
FedRow::Connect => {
SettingsRow::Federation(FedRow::SyncNow) => Some(Effect::FedSyncNow),
SettingsRow::Federation(FedRow::ShowTicket) => Some(Effect::FedShowTicket),
SettingsRow::Federation(FedRow::Connect) => {
state.popup = Some(Popup::FedInput {
field: FedInputField::ConnectTicket,
input: crate::app::input::LineEdit::default(),
});
None
}
SettingsRow::VisualizationClock => {
match state.visualizer.toggle_clock() {
Ok(()) => {
state.status_message = Some(format!(
"visualization clock {}",
if state.visualizer.config.show_clock {
"on"
} else {
"off"
}
));
}
Err(err) => state.status_message = Some(format!("visualization settings: {err:#}")),
}
None
}
SettingsRow::VisualizationScript(index) => {
match state.visualizer.select_script(index) {
Ok(()) => {
let name = state
.visualizer
.scripts
.get(index)
.map(|script| script.name.clone())
.unwrap_or_else(|| "visualization".to_string());
state.status_message = Some(format!("visualization: {name}"));
}
Err(err) => state.status_message = Some(format!("visualization settings: {err:#}")),
}
None
}
SettingsRow::VisualizationNew => match state.visualizer.create_script() {
Ok(path) => Some(Effect::OpenVisualizerEditor { path }),
Err(err) => {
state.status_message = Some(format!("visualization script: {err:#}"));
None
}
},
SettingsRow::VisualizationEdit => match state.visualizer.selected_script_path() {
Some(path) => Some(Effect::OpenVisualizerEditor { path }),
None => {
state.status_message = Some("no visualization script selected".into());
None
}
},
}
}
@@ -2397,7 +2463,7 @@ fn reset_tab(state: &mut AppState, tab: Tab) {
state.fed_artist_view = None;
}
Tab::Playlists => state.playlists.opened = None,
Tab::Federation => state.federation.cursor = 0,
Tab::Federation => state.settings_cursor = 0,
Tab::Logs => {
state.logs.follow = true;
state.logs.selected_seq = None;
@@ -2792,6 +2858,35 @@ mod tests {
}
}
#[test]
fn visualizer_requires_current_track() {
let mut state = AppState::default();
assert_eq!(update(&mut state, Action::ToggleVisualizer), None);
assert!(!state.visualizer.active);
assert_eq!(
state.status_message.as_deref(),
Some("nothing playing — start a track first")
);
}
#[test]
fn visualizer_toggles_and_back_closes_it() {
let mut state = AppState::default();
state.player.current = Some(test_track(7));
state.help_visible = true;
assert_eq!(update(&mut state, Action::ToggleVisualizer), None);
assert!(state.visualizer.active);
assert!(state.visualizer.started_at.is_some());
assert!(!state.help_visible);
assert_eq!(update(&mut state, Action::Back), None);
assert!(!state.visualizer.active);
assert!(state.visualizer.started_at.is_none());
}
#[test]
fn add_to_playlist_from_release_carries_selected_track() {
use crate::app::state::{PlaylistAddTarget, Popup};