Skip to content
Merged
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
78 changes: 76 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use rspotify::{
artist::FullArtist,
context::CurrentPlaybackContext,
device::DevicePayload,
idtypes::{ArtistId, ShowId, TrackId},
idtypes::{ArtistId, PlaylistId, ShowId, TrackId},
page::{CursorBasedPage, Page},
playing::PlayHistory,
playlist::{PlaylistItem, SimplifiedPlaylist},
Expand All @@ -29,7 +29,7 @@ use std::sync::Arc;
use std::{
cmp::{max, min},
collections::HashSet,
time::{Instant, SystemTime},
time::{Duration, Instant, SystemTime},
};

use arboard::Clipboard;
Expand Down Expand Up @@ -120,6 +120,8 @@ pub enum ArtistBlock {
pub enum DialogContext {
PlaylistWindow,
PlaylistSearch,
AddTrackToPlaylistPicker,
RemoveTrackFromPlaylistConfirm,
}

#[derive(Clone, Copy, PartialEq, Debug)]
Expand Down Expand Up @@ -272,6 +274,21 @@ pub struct TrackTable {
pub context: Option<TrackTableContext>,
}

#[derive(Clone)]
pub struct PendingPlaylistTrackAdd {
pub track_id: TrackId<'static>,
pub track_name: String,
}

#[derive(Clone)]
pub struct PendingPlaylistTrackRemoval {
pub playlist_id: PlaylistId<'static>,
pub playlist_name: String,
pub track_id: TrackId<'static>,
pub track_name: String,
pub position: usize,
}

#[derive(Clone)]
pub struct SelectedShow {
pub show: SimplifiedShow,
Expand Down Expand Up @@ -590,6 +607,15 @@ pub struct App {
pub status_message_expires_at: Option<Instant>,
/// Pending track table selection to apply when new page loads
pub pending_track_table_selection: Option<PendingTrackSelection>,
/// Maps visible track table rows to source playlist item positions.
/// Used to remove a single selected playlist occurrence safely.
pub playlist_track_positions: Option<Vec<usize>>,
/// Selected playlist index in the add-to-playlist picker dialog
pub playlist_picker_selected_index: usize,
/// Pending track to add in add-to-playlist dialog flow
pub pending_playlist_track_add: Option<PendingPlaylistTrackAdd>,
/// Pending track removal info in remove-from-playlist confirmation flow
pub pending_playlist_track_removal: Option<PendingPlaylistTrackRemoval>,
/// Full flat list of all user playlists (all pages combined)
pub all_playlists: Vec<SimplifiedPlaylist>,
/// Folder tree from rootlist (None if not fetched or streaming disabled)
Expand Down Expand Up @@ -738,6 +764,10 @@ impl Default for App {
status_message: None,
status_message_expires_at: None,
pending_track_table_selection: None,
playlist_track_positions: None,
playlist_picker_selected_index: 0,
pending_playlist_track_add: None,
pending_playlist_track_removal: None,
all_playlists: Vec::new(),
playlist_folder_nodes: None,
playlist_folder_items: Vec::new(),
Expand Down Expand Up @@ -783,6 +813,50 @@ impl App {
self.io_tx = None;
}

pub fn clear_playlist_track_dialog_state(&mut self) {
self.pending_playlist_track_add = None;
self.pending_playlist_track_removal = None;
self.playlist_picker_selected_index = 0;
}

pub fn set_status_message(&mut self, message: impl Into<String>, ttl_secs: u64) {
self.status_message = Some(message.into());
self.status_message_expires_at = Some(Instant::now() + Duration::from_secs(ttl_secs));
}

pub fn begin_add_track_to_playlist_flow(
&mut self,
track_id: Option<TrackId<'static>>,
track_name: String,
) {
let Some(track_id) = track_id else {
self.set_status_message("Track cannot be edited in playlist".to_string(), 4);
return;
};

if self.all_playlists.is_empty() {
if self.playlists.is_none() {
self.dispatch(IoEvent::GetPlaylists);
self.set_status_message("Playlists loading, try again".to_string(), 4);
} else {
self.set_status_message("No playlists available".to_string(), 4);
}
return;
}

self.dialog = None;
self.confirm = false;
self.clear_playlist_track_dialog_state();
self.pending_playlist_track_add = Some(PendingPlaylistTrackAdd {
track_id,
track_name,
});
self.push_navigation_stack(
RouteId::Dialog,
ActiveBlock::Dialog(DialogContext::AddTrackToPlaylistPicker),
);
}

pub fn is_playlist_item_visible_in_current_folder(&self, item: &PlaylistFolderItem) -> bool {
match item {
PlaylistFolderItem::Folder(f) => f.current_id == self.current_playlist_folder_id,
Expand Down
142 changes: 131 additions & 11 deletions src/handlers/dialog.rs
Original file line number Diff line number Diff line change
@@ -1,25 +1,106 @@
use super::super::app::{ActiveBlock, App, DialogContext};
use super::{
super::app::{ActiveBlock, App, DialogContext},
common_key_events,
};
use crate::event::Key;
use crate::network::IoEvent;

pub fn handler(key: Key, app: &mut App) {
let dialog_context = match app.get_current_route().active_block {
ActiveBlock::Dialog(context) => context,
_ => return,
};

match dialog_context {
DialogContext::AddTrackToPlaylistPicker => handle_add_to_playlist_picker(key, app),
DialogContext::PlaylistWindow
| DialogContext::PlaylistSearch
| DialogContext::RemoveTrackFromPlaylistConfirm => {
handle_confirmation_dialog(key, app, dialog_context)
}
}
}

fn handle_confirmation_dialog(key: Key, app: &mut App, dialog_context: DialogContext) {
match key {
Key::Enter => {
if let Some(route) = app.pop_navigation_stack() {
if app.confirm {
if let ActiveBlock::Dialog(d) = route.active_block {
match d {
DialogContext::PlaylistWindow => handle_playlist_dialog(app),
DialogContext::PlaylistSearch => handle_playlist_search_dialog(app),
}
if app.confirm {
match dialog_context {
DialogContext::PlaylistWindow => handle_playlist_dialog(app),
DialogContext::PlaylistSearch => handle_playlist_search_dialog(app),
DialogContext::RemoveTrackFromPlaylistConfirm => {
handle_remove_track_from_playlist_confirm(app);
}
DialogContext::AddTrackToPlaylistPicker => {}
}
}
close_dialog(app);
}
Key::Char('q') => {
app.pop_navigation_stack();
close_dialog(app);
}
k if common_key_events::right_event(k) => app.confirm = !app.confirm,
k if common_key_events::left_event(k) => app.confirm = !app.confirm,
_ => {}
}
}

fn handle_add_to_playlist_picker(key: Key, app: &mut App) {
let playlist_count = app.all_playlists.len();
match key {
k if common_key_events::down_event(k) => {
if playlist_count > 0 {
let next = common_key_events::on_down_press_handler(
&app.all_playlists,
Some(app.playlist_picker_selected_index),
);
app.playlist_picker_selected_index = next;
}
}
k if common_key_events::up_event(k) => {
if playlist_count > 0 {
let next = common_key_events::on_up_press_handler(
&app.all_playlists,
Some(app.playlist_picker_selected_index),
);
app.playlist_picker_selected_index = next;
}
}
k if common_key_events::high_event(k) => {
if playlist_count > 0 {
app.playlist_picker_selected_index = common_key_events::on_high_press_handler();
}
}
k if common_key_events::middle_event(k) => {
if playlist_count > 0 {
app.playlist_picker_selected_index =
common_key_events::on_middle_press_handler(&app.all_playlists);
}
}
k if common_key_events::low_event(k) => {
if playlist_count > 0 {
app.playlist_picker_selected_index =
common_key_events::on_low_press_handler(&app.all_playlists);
}
}
Key::Enter => {
if let Some(pending_add) = app.pending_playlist_track_add.clone() {
if let Some(playlist) = app.all_playlists.get(
app
.playlist_picker_selected_index
.min(playlist_count.saturating_sub(1)),
) {
app.dispatch(IoEvent::AddTrackToPlaylist(
playlist.id.clone().into_static(),
pending_add.track_id,
));
}
}
close_dialog(app);
}
Key::Char('q') => {
close_dialog(app);
}
Key::Right => app.confirm = !app.confirm,
Key::Left => app.confirm = !app.confirm,
_ => {}
}
}
Expand All @@ -31,3 +112,42 @@ fn handle_playlist_dialog(app: &mut App) {
fn handle_playlist_search_dialog(app: &mut App) {
app.user_unfollow_playlist_search_result()
}

fn handle_remove_track_from_playlist_confirm(app: &mut App) {
if let Some(pending_remove) = app.pending_playlist_track_removal.clone() {
app.dispatch(IoEvent::RemoveTrackFromPlaylistAtPosition(
pending_remove.playlist_id,
pending_remove.track_id,
pending_remove.position,
));
}
}

fn close_dialog(app: &mut App) {
app.pop_navigation_stack();
app.dialog = None;
app.confirm = false;
app.clear_playlist_track_dialog_state();
}

#[cfg(test)]
mod tests {
use super::*;
use crate::app::RouteId;

#[test]
fn confirmation_dialog_toggles_with_vim_hl() {
let mut app = App::default();
app.push_navigation_stack(
RouteId::Dialog,
ActiveBlock::Dialog(DialogContext::RemoveTrackFromPlaylistConfirm),
);
app.confirm = false;

handler(Key::Char('l'), &mut app);
assert!(app.confirm);

handler(Key::Char('h'), &mut app);
assert!(!app.confirm);
}
}
18 changes: 18 additions & 0 deletions src/handlers/empty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ pub fn handler(key: Key, app: &mut App) {
_ => {}
},
k if common_key_events::right_event(k) => common_key_events::handle_right_event(app),
Key::Char('w') => {
if app.get_current_route().hovered_block == ActiveBlock::PlayBar {
super::playbar::handler(key, app);
}
}
_ => (),
};
}
Expand Down Expand Up @@ -168,4 +173,17 @@ mod tests {
assert_eq!(current_route.active_block, ActiveBlock::Home);
assert_eq!(current_route.hovered_block, ActiveBlock::Home);
}

#[test]
fn on_w_press_over_playbar_adds_current_track() {
let mut app = App::default();
app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::PlayBar));

handler(Key::Char('w'), &mut app);

assert_eq!(
app.status_message.as_deref(),
Some("No track currently playing")
);
}
}
38 changes: 38 additions & 0 deletions src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ pub fn handle_app(key: Key, app: &mut App) {
app.load_settings_for_category();
app.push_navigation_stack(RouteId::Settings, ActiveBlock::Settings);
}
Key::Char('W') => match app.get_current_route().active_block {
ActiveBlock::Input | ActiveBlock::Dialog(_) | ActiveBlock::UpdatePrompt => {
handle_block_events(key, app);
}
_ => playbar::add_currently_playing_track_to_playlist(app),
},
_ => handle_block_events(key, app),
}
}
Expand Down Expand Up @@ -203,6 +209,9 @@ fn handle_escape(app: &mut App) {
}
ActiveBlock::Dialog(_) => {
app.pop_navigation_stack();
app.dialog = None;
app.confirm = false;
app.clear_playlist_track_dialog_state();
}
ActiveBlock::HelpMenu => {
app.pop_navigation_stack();
Expand Down Expand Up @@ -276,3 +285,32 @@ fn handle_jump_to_artist_album(app: &mut App) {
}
};
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn global_shift_w_adds_current_track_from_anywhere() {
let mut app = App::default();
app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library));

handle_app(Key::Char('W'), &mut app);

assert_eq!(
app.status_message.as_deref(),
Some("No track currently playing")
);
}

#[test]
fn global_shift_w_is_not_intercepted_in_input_mode() {
let mut app = App::default();
app.set_current_route_state(Some(ActiveBlock::Input), Some(ActiveBlock::Input));

handle_app(Key::Char('W'), &mut app);

assert_eq!(app.input, vec!['W']);
assert!(app.status_message.is_none());
}
}
Loading
Loading