Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ spotify_player -o device.volume=80 -o theme=dracula
| `volume_scroll_step` | Volume change step when using mouse scroll. | `5` |
| `enable_mouse_scroll_volume` | Enable volume control via mouse scroll. | `true` |
| `custom_queue` | Enable app-managed queue for custom playback integration (requires `streaming` feature). | `true` |
| `enable_relative_line_number` | Enable Vim-style relative line numbers for lists and popups. | `false` |
| `device` | Device configuration (see below). | See below |

### Notes
Expand Down
1 change: 1 addition & 0 deletions examples/app.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ cover_img_width = 5
cover_img_pixels = 16
seek_duration_secs = 5
custom_queue = true
enable_relative_line_number = false

[device]
name = "spotify-player"
Expand Down
3 changes: 3 additions & 0 deletions spotify_player/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ pub struct AppConfig {
/// Requires streaming. When disabled, playback uses Spotify-native queue
/// management.
pub custom_queue: bool,

pub enable_relative_line_number: bool,
}

#[derive(Debug, Deserialize, Serialize, Clone)]
Expand Down Expand Up @@ -392,6 +394,7 @@ impl Default for AppConfig {
enable_mouse_scroll_volume: true,

custom_queue: true,
enable_relative_line_number: false,
}
}
}
Expand Down
19 changes: 11 additions & 8 deletions spotify_player/src/event/popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,21 +464,24 @@ fn handle_command_for_list_popup(
on_choose_func: impl FnOnce(&mut UIStateGuard, usize) -> anyhow::Result<()>,
on_close_func: impl FnOnce(&mut UIStateGuard),
) -> anyhow::Result<bool> {
let offset = ui.count_prefix.unwrap_or(1);
let popup = ui.popup.as_mut().with_context(|| "expect a popup")?;
let current_id = popup.list_selected().unwrap_or_default();

if n_items == 0 {
return Ok(false);
}

match command {
Command::SelectPreviousOrScrollUp => {
if current_id > 0 {
popup.list_select(Some(current_id - 1));
on_select_func(ui, current_id - 1);
}
let next_id = current_id.saturating_sub(offset);
popup.list_select(Some(next_id));
on_select_func(ui, next_id);
}
Command::SelectNextOrScrollDown => {
if current_id + 1 < n_items {
popup.list_select(Some(current_id + 1));
on_select_func(ui, current_id + 1);
}
let next_id = std::cmp::min(current_id + offset, n_items - 1);
popup.list_select(Some(next_id));
on_select_func(ui, next_id);
}
Command::ChooseSelected => {
if current_id < n_items {
Expand Down
110 changes: 82 additions & 28 deletions spotify_player/src/ui/page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,16 @@ pub fn render_search_page(
// 1. Get data
let data = state.data.read();

let (focus_state, current_query, line_input) = match ui.current_page() {
let (focus_state, current_query) = match ui.current_page() {
PageState::Search {
state,
current_query,
line_input,
} => (state.focus, current_query, line_input),
..
} => (state.focus, current_query.clone()),
_ => return,
};

let search_results = data.caches.search.get(current_query);
let search_results = data.caches.search.get(&current_query);

// 2. Construct the page's layout
let rect = construct_and_render_block("Search", &ui.theme, Borders::ALL, frame, rect);
Expand Down Expand Up @@ -140,8 +140,9 @@ pub fn render_search_page(
.unwrap_or_default();

let is_active = is_active && focus_state == SearchFocusState::Tracks;
let selected_index = if is_active { ui.current_page_mut().selected() } else { None };

utils::construct_list_widget(&ui.theme, track_items, is_active)
utils::construct_list_widget(&ui.theme, track_items, is_active, selected_index)
};

let (album_list, n_albums) = {
Expand All @@ -150,8 +151,9 @@ pub fn render_search_page(
.unwrap_or_default();

let is_active = is_active && focus_state == SearchFocusState::Albums;
let selected_index = if is_active { ui.current_page_mut().selected() } else { None };

utils::construct_list_widget(&ui.theme, album_items, is_active)
utils::construct_list_widget(&ui.theme, album_items, is_active, selected_index)
};

let (artist_list, n_artists) = {
Expand All @@ -160,8 +162,9 @@ pub fn render_search_page(
.unwrap_or_default();

let is_active = is_active && focus_state == SearchFocusState::Artists;
let selected_index = if is_active { ui.current_page_mut().selected() } else { None };

utils::construct_list_widget(&ui.theme, artist_items, is_active)
utils::construct_list_widget(&ui.theme, artist_items, is_active, selected_index)
};

let (playlist_list, n_playlists) = {
Expand All @@ -170,17 +173,19 @@ pub fn render_search_page(
.unwrap_or_default();

let is_active = is_active && focus_state == SearchFocusState::Playlists;
let selected_index = if is_active { ui.current_page_mut().selected() } else { None };

utils::construct_list_widget(&ui.theme, playlist_items, is_active)
utils::construct_list_widget(&ui.theme, playlist_items, is_active, selected_index)
};

let (show_list, n_shows) = {
let show_items = search_results
.map(|s| search_items(&s.shows))
.unwrap_or_default();
let is_active = is_active && focus_state == SearchFocusState::Shows;
let selected_index = if is_active { ui.current_page_mut().selected() } else { None };

utils::construct_list_widget(&ui.theme, show_items, is_active)
utils::construct_list_widget(&ui.theme, show_items, is_active, selected_index)
};

let (episode_list, n_episodes) = {
Expand All @@ -189,25 +194,27 @@ pub fn render_search_page(
.unwrap_or_default();

let is_active = is_active && focus_state == SearchFocusState::Episodes;
let selected_index = if is_active { ui.current_page_mut().selected() } else { None };

utils::construct_list_widget(&ui.theme, episode_items, is_active)
utils::construct_list_widget(&ui.theme, episode_items, is_active, selected_index)
};

// 4. Render the page's widgets
// Render the query input box
frame.render_widget(
line_input.widget(is_active && focus_state == SearchFocusState::Input),
search_input_rect,
);

// Render the search result windows.
// Need mutable access to the list/table states stored inside the page state for rendering.
let PageState::Search {
state: page_state, ..
state: page_state,
line_input,
..
} = ui.current_page_mut()
else {
return;
};

// Render the query input box
frame.render_widget(
line_input.widget(is_active && focus_state == SearchFocusState::Input),
search_input_rect,
);
utils::render_list_window(
frame,
track_list,
Expand Down Expand Up @@ -453,30 +460,39 @@ pub fn render_library_page(
})
.collect::<Vec<_>>();

let is_playlist_active = is_active
&& focus_state != LibraryFocusState::SavedAlbums
&& focus_state != LibraryFocusState::FollowedArtists;
let playlist_selected = if is_playlist_active { ui.current_page_mut().selected() } else { None };
let (playlist_list, n_playlists) = utils::construct_list_widget(
&ui.theme,
items,
is_active
&& focus_state != LibraryFocusState::SavedAlbums
&& focus_state != LibraryFocusState::FollowedArtists,
is_playlist_active,
playlist_selected,
);
// Construct the saved album window
let is_album_active = is_active && focus_state == LibraryFocusState::SavedAlbums;
let album_selected = if is_album_active { ui.current_page_mut().selected() } else { None };
let (album_list, n_albums) = utils::construct_list_widget(
&ui.theme,
ui.search_filtered_items(&data.user_data.saved_albums)
.into_iter()
.map(|a| (a.to_bidi_string(), curr_context_uri == Some(a.id.uri())))
.collect(),
is_active && focus_state == LibraryFocusState::SavedAlbums,
is_album_active,
album_selected,
);
// Construct the followed artist window
let is_artist_active = is_active && focus_state == LibraryFocusState::FollowedArtists;
let artist_selected = if is_artist_active { ui.current_page_mut().selected() } else { None };
let (artist_list, n_artists) = utils::construct_list_widget(
&ui.theme,
ui.search_filtered_items(&data.user_data.followed_artists)
.into_iter()
.map(|a| (a.to_bidi_string(), curr_context_uri == Some(a.id.uri())))
.collect(),
is_active && focus_state == LibraryFocusState::FollowedArtists,
is_artist_active,
artist_selected,
);

// 4. Render the page's widgets
Expand Down Expand Up @@ -520,6 +536,7 @@ pub fn render_browse_page(
let data = state.data.read();

// 2+3. Construct the page's layout and widgets
let selected_index = if is_active { ui.current_page_mut().selected() } else { None };
let (list, len) = match ui.current_page() {
PageState::Browse { state: ui_state } => match ui_state {
BrowsePageUIState::CategoryList { .. } => {
Expand All @@ -533,6 +550,7 @@ pub fn render_browse_page(
.map(|c| (c.name.clone(), false))
.collect(),
is_active,
selected_index,
)
}
BrowsePageUIState::CategoryPlaylistList { category, .. } => {
Expand All @@ -551,6 +569,7 @@ pub fn render_browse_page(
.map(|c| (c.name.clone(), false))
.collect(),
is_active,
selected_index,
)
}
},
Expand Down Expand Up @@ -894,10 +913,14 @@ fn render_artist_context_page_windows(
.map(|a| (a.name.clone(), false))
.collect::<Vec<_>>();

let is_artist_active = is_active && focus_state == ArtistFocusState::RelatedArtists;
let selected_index = if is_artist_active { ui.current_page_mut().selected() } else { None };

utils::construct_list_widget(
&ui.theme,
artist_items,
is_active && focus_state == ArtistFocusState::RelatedArtists,
is_artist_active,
selected_index,
)
};

Expand Down Expand Up @@ -967,12 +990,27 @@ fn render_track_table(
// enable Added column if any track in the table has added_at field specified
let added_at_enabled = tracks.iter().any(|t| t.added_at > 0);

let selected_index = if is_active && configs.app_config.enable_relative_line_number {
ui.current_page_mut().selected()
} else {
None
};

let n_tracks = tracks.len();
let rows = tracks
.into_iter()
.enumerate()
.map(|(id, t)| {
let track_no = (id + 1).to_string();
let track_no = match selected_index {
Some(sel_idx) => {
if id == sel_idx {
(id + 1).to_string()
} else {
(id as isize - sel_idx as isize).abs().to_string()
}
}
None => (id + 1).to_string(),
};
let (play_pause, style) = if playing_track_uri == t.id.uri() {
(playing_id.to_string(), ui.theme.current_playing())
} else {
Expand Down Expand Up @@ -1101,18 +1139,34 @@ fn render_episode_table(
}
}

let selected_index = if is_active && configs.app_config.enable_relative_line_number {
ui.current_page_mut().selected()
} else {
None
};

let n_episodes = episodes.len();
let rows = episodes
.into_iter()
.enumerate()
.map(|(id, e)| {
let (id, style) = if playing_episode_uri == e.id.uri() {
let index_str = match selected_index {
Some(sel_idx) => {
if id == sel_idx {
(id + 1).to_string()
} else {
(id as isize - sel_idx as isize).abs().to_string()
}
}
None => (id + 1).to_string(),
};
let (id_str, style) = if playing_episode_uri == e.id.uri() {
(playing_id.to_string(), ui.theme.current_playing())
} else {
((id + 1).to_string(), Style::default())
(index_str, Style::default())
};
Row::new(vec![
Cell::from(id),
Cell::from(id_str),
Cell::from(to_bidi_string(&e.name)),
Cell::from(e.release_date.clone()),
Cell::from(format!(
Expand Down
3 changes: 2 additions & 1 deletion spotify_player/src/ui/popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,8 @@ fn render_list_popup(
let chunks = Layout::vertical([Constraint::Fill(0), Constraint::Length(length)]).split(rect);

let rect = construct_and_render_block(title, &ui.theme, Borders::ALL, frame, chunks[1]);
let (list, len) = utils::construct_list_widget(&ui.theme, items, true);
let selected_index = ui.popup.as_ref().and_then(|p| p.list_selected());
let (list, len) = utils::construct_list_widget(&ui.theme, items, true, selected_index);

utils::render_list_window(
frame,
Expand Down
18 changes: 16 additions & 2 deletions spotify_player/src/ui/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,29 @@ pub fn construct_list_widget<'a>(
theme: &config::Theme,
items: Vec<(String, bool)>,
is_active: bool,
selected_index: Option<usize>,
) -> (List<'a>, usize) {
let configs = config::get_config();
let n_items = items.len();

(
List::new(
items
.into_iter()
.map(|(s, is_active)| {
ListItem::new(s).style(if is_active {
.enumerate()
.map(|(i, (s, is_playing))| {
let text = if is_active && configs.app_config.enable_relative_line_number {
if let Some(selected_index) = selected_index {
let diff = (i as isize - selected_index as isize).abs();
let width = std::cmp::min(n_items.to_string().len(), 2);
format!("{:>width$} {}", diff, s, width = width)
} else {
s
}
} else {
s
};
ListItem::new(text).style(if is_playing {
theme.current_playing()
} else {
Style::default()
Expand Down