Added Visualisation engine on rhai
This commit is contained in:
+4
-1
@@ -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
@@ -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
@@ -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
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user