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
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ futures-util = { version = "0.3.34", default-features = false, features = ["sink
getrandom = "0.3.4"
html2text = "0.17.4"
keyring = "4.2.0"
nucleo-matcher = "0.3.1"
ratatui = { version = "0.30.2", features = ["serde"] }
reqwest = { version = "0.13.5", features = ["json", "query", "gzip"] }
rodio = { version = "0.23.0", default-features = false, features = ["playback", "mp3", "mp4", "flac", "vorbis", "wav"] }
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ A TUI (terminal user interface) podcast client for PinePods. Stream your podcast
- **Pages**: Home, Feed, Podcasts, Queue, Saved (with your collections), Playlists, History, Downloads, Search, Devices, Now Playing and Settings, plus episode details with show notes, chapters and transcripts
- **Find and follow podcasts**: search Podcast Index, iTunes and YouTube and subscribe from the terminal
- **Filter as you type**: `/` narrows any list; Search queries your server as you type
- **Command palette**: `:` or Ctrl+P finds any command, page, podcast or loaded episode by a few letters of its name
- **Themes**: the same 25 themes as the PinePods web app, starting with the one you picked there, or your terminal's own colors
- **Keys you can change**: vim-style and arrow keys by default, `?` lists them, `keymap.toml` rebinds them
- **Mouse**: click to select, click again to play or open, scroll lists, click the progress bar to seek
Expand Down Expand Up @@ -105,6 +106,7 @@ Every key you type goes into the fields, so **Ctrl+C** quits the login screen. *
- **j/k** or **↑/↓** move, **g/G** first/last, **Ctrl+D/U** page down/up
- **Enter** plays an episode or opens a podcast. **i** (or **l**) opens an episode's details and show notes. **Esc** goes back
- **/** filters the list you're on; **Enter** keeps the filter, **Esc** clears it
- **:** or **Ctrl+P** opens the command palette: type a few letters of a command, a page ("go queue"), one of your podcasts, an episode on screen somewhere ("play …") or a recent search, then **Enter**
- **?** shows every key for the page you're on. **q** quits (**Ctrl+C** works anywhere, even mid-typing)

### Playing
Expand Down Expand Up @@ -173,6 +175,7 @@ src/
└── tui/ # Terminal user interface
├── app.rs # Sidebar, now-playing bar, overlays, input routing
├── keymap.rs # Default bindings and keymap.toml
├── palette.rs # The command palette
├── task.rs # Background loading
├── ui/ # Shared widgets: lists, episode rows, show notes
└── pages/ # The screens
Expand Down Expand Up @@ -248,10 +251,10 @@ This project is licensed under the GNU General Public License v3.0; see the [LIC

## 🗺️ Roadmap

- [ ] History, playlists and collections
- [ ] Chapters and transcripts
- [ ] Podcast discovery and subscribing
- [ ] A command palette
- [x] History, playlists and collections
- [x] Chapters and transcripts
- [x] Podcast discovery and subscribing
- [x] A command palette
- [ ] Cover art in terminals that can show images
- [ ] Media keys and the system's now-playing controls (MPRIS on Linux, Now Playing on macOS)
- [ ] Headless mode, for a Raspberry Pi or speaker box that other devices play on
Expand Down
126 changes: 118 additions & 8 deletions src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ use unicode_width::UnicodeWidthStr;
use super::collection_picker::CollectionPicker;
use super::device_picker::DevicePicker;
use super::keymap::{Action, Context, Keymap};
use super::pages::episodes::EpisodeListPage;
use super::pages::search::{RecentSearch, SearchPage};
use super::pages::{Ctx, Outcome, Page, Section};
use super::palette::{self, Command, Palette};
use super::task::{Bus, Event, Pending, Resource, Toast, ToastKind};
use super::text::truncate;
use super::ui::{self, list::Click};
Expand All @@ -45,6 +48,7 @@ const SPEED_STEP: f32 = 0.1;
/// How long a volume or speed change shows in the now-playing bar.
const FLASH_FOR: Duration = Duration::from_millis(1500);
const MAX_TOASTS: usize = 4;
const MAX_RECENT_SEARCHES: usize = 8;

pub struct App {
api: Api,
Expand Down Expand Up @@ -77,6 +81,9 @@ pub struct App {
help: Option<u16>,
picker: Option<DevicePicker>,
collections: Option<CollectionPicker>,
palette: Option<Palette>,
/// Newest first.
searches: Vec<RecentSearch>,
/// The last remote-control notice already shown, so each shows once.
notice_seen: Option<Instant>,
quit: bool,
Expand Down Expand Up @@ -149,6 +156,8 @@ impl App {
help: None,
picker: None,
collections: None,
palette: None,
searches: Vec::new(),
notice_seen: None,
quit: false,
sidebar_rows: Vec::new(),
Expand Down Expand Up @@ -223,6 +232,12 @@ impl App {
page.changed(resource);
}
}
Event::Searched(search) => {
self.searches
.retain(|s| *s != search && !s.leads_to(&search));
self.searches.insert(0, search);
self.searches.truncate(MAX_RECENT_SEARCHES);
}
}
}
let now = Instant::now();
Expand All @@ -239,6 +254,9 @@ impl App {
if let Some(picker) = &mut self.collections {
picker.tick(&self.bus);
}
if let Some(palette) = &mut self.palette {
palette.tick(&self.bus);
}
self.show_remote_notice();
self.apply_server_theme();
self.apply_custom_theme();
Expand Down Expand Up @@ -413,6 +431,11 @@ impl App {
self.collections_key(key);
return;
}
if let Some(palette) = &mut self.palette {
let outcome = palette.key(key);
self.palette_outcome(outcome);
return;
}

if let Some(outcome) = self.with_page(|page, cx| page.key(key, cx)) {
self.apply_outcome(outcome);
Expand All @@ -423,14 +446,8 @@ impl App {
}

let contexts = self.page().contexts();
let action = self.keymap.action(key.into(), contexts);
if let Some(action) = action {
let outcome = self.with_page(|page, cx| page.action(action, cx));
if !matches!(outcome, Outcome::Ignored) {
self.apply_outcome(outcome);
return;
}
self.global(action);
if let Some(action) = self.keymap.action(key.into(), contexts) {
self.run_action(action);
return;
}
// Digits go to sidebar entries in the order shown; 0 is the tenth.
Expand All @@ -444,8 +461,22 @@ impl App {
}
}

/// What a key bound to `action` does: the page's meaning first, then the global one.
fn run_action(&mut self, action: Action) {
let outcome = self.with_page(|page, cx| page.action(action, cx));
if matches!(outcome, Outcome::Ignored) {
self.global(action);
} else {
self.apply_outcome(outcome);
}
}

/// Text pasted into the terminal goes to whatever field has focus.
pub fn paste(&mut self, text: &str) {
if let Some(palette) = &mut self.palette {
palette.paste(text);
return;
}
if !self.page().typing() {
return;
}
Expand Down Expand Up @@ -493,6 +524,10 @@ impl App {
self.open_picker();
Ok(())
}
Action::Palette => {
self.open_palette();
Ok(())
}
Action::AddToCollection => {
match self.page().selected_episode() {
Some(episode) => {
Expand Down Expand Up @@ -555,6 +590,73 @@ impl App {
};
}

fn open_palette(&mut self) {
// The open page's episodes come first, then the rest of what's loaded.
let current = Section::ALL
.iter()
.position(|s| *s == self.current)
.unwrap_or(0);
let mut stacks: Vec<&Vec<Box<dyn Page>>> = vec![&self.stacks[current]];
stacks.extend(
self.stacks
.iter()
.enumerate()
.filter(|(i, _)| *i != current)
.map(|(_, stack)| stack),
);
let pages = || stacks.iter().flat_map(|stack| stack.iter().rev());
let entries = palette::entries(
&self.keymap,
palette::Sources {
contexts: self.page().contexts(),
sections: &self.order,
searches: &self.searches,
podcasts: pages().flat_map(|p| p.podcasts()).collect(),
episodes: pages().flat_map(|p| p.episodes()).collect(),
},
);
// Home's top podcasts are only a few; the Podcasts page or Search has them all.
let have_podcasts = [Section::Podcasts, Section::Search]
.iter()
.any(|s| !self.stack(*s)[0].podcasts().is_empty());
let api = (!have_podcasts).then_some(&self.api);
self.palette = Some(Palette::new(entries, api));
}

fn palette_outcome(&mut self, outcome: palette::Outcome) {
match outcome {
palette::Outcome::Open => {}
palette::Outcome::Close => self.palette = None,
palette::Outcome::Run(command) => {
self.palette = None;
self.run_command(*command);
}
}
}

fn run_command(&mut self, command: Command) {
match command {
Command::Action(action) => self.run_action(action),
Command::Go(section) => self.switch_to(section),
Command::Search(search) => {
*self.stack_mut(Section::Search) = vec![Box::new(SearchPage::repeat(&search))];
if self.current == Section::Search {
self.with_page(|page, cx| page.show(cx));
} else {
self.switch_to(Section::Search);
}
}
Command::Podcast(podcast) => {
self.stack_mut(Section::Podcasts).truncate(1);
self.switch_to(Section::Podcasts);
self.apply_outcome(Outcome::Push(Box::new(EpisodeListPage::podcast(podcast))));
}
Command::Play(episode) => {
self.with_page(|_, cx| ui::episodes::play(&episode, cx));
}
}
}

fn open_picker(&mut self) {
let Some(episode) = self.page().selected_episode() else {
self.bus.info("Select an episode first");
Expand Down Expand Up @@ -642,6 +744,11 @@ impl App {
}
return;
}
if let Some(palette) = &mut self.palette {
let outcome = palette.mouse(event, click);
self.palette_outcome(outcome);
return;
}
if click {
if let Some(&(_, section)) = self.sidebar_rows.iter().find(|(r, _)| r.contains(at)) {
self.switch_to(section);
Expand Down Expand Up @@ -720,6 +827,9 @@ impl App {
if let Some(picker) = &mut self.collections {
picker.render(frame, area, &self.theme);
}
if let Some(palette) = &mut self.palette {
palette.render(frame, area, &self.theme);
}
if self.help.is_some() {
self.render_help(frame, area);
}
Expand Down
62 changes: 62 additions & 0 deletions src/tui/keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ pub enum Action {
SpeedDown,
NowPlaying,
PlayOn,
Palette,
// List
Up,
Down,
Expand Down Expand Up @@ -139,6 +140,65 @@ pub enum Action {
}

impl Action {
/// Every action, for the command palette's tests.
#[cfg(test)]
pub const ALL: [Action; 54] = [
Self::Quit,
Self::Help,
Self::NextPage,
Self::PrevPage,
Self::Refresh,
Self::TogglePlay,
Self::SeekForward,
Self::SeekBackward,
Self::VolumeUp,
Self::VolumeDown,
Self::SpeedUp,
Self::SpeedDown,
Self::NowPlaying,
Self::PlayOn,
Self::Palette,
Self::Up,
Self::Down,
Self::PageUp,
Self::PageDown,
Self::Top,
Self::Bottom,
Self::Activate,
Self::Open,
Self::Back,
Self::Filter,
Self::CycleFilter,
Self::Queue,
Self::PlayNext,
Self::Save,
Self::Download,
Self::MarkPlayed,
Self::AddToCollection,
Self::Unsubscribe,
Self::Subscribe,
Self::CycleView,
Self::MoveUp,
Self::MoveDown,
Self::MoveToTop,
Self::MoveToBottom,
Self::Remove,
Self::ClearQueue,
Self::Shuffle,
Self::Stop,
Self::ResetSpeed,
Self::TrimSilence,
Self::NextChapter,
Self::PrevChapter,
Self::PlayHere,
Self::SendHere,
Self::RemoteToggle,
Self::RemoteBack,
Self::RemoteForward,
Self::RemoteSlower,
Self::RemoteFaster,
];

pub fn description(self) -> &'static str {
match self {
Self::Quit => "Quit",
Expand All @@ -155,6 +215,7 @@ impl Action {
Self::SpeedDown => "Slower",
Self::NowPlaying => "Now playing",
Self::PlayOn => "Play on device",
Self::Palette => "Command palette",
Self::Up => "Up",
Self::Down => "Down",
Self::PageUp => "Page up",
Expand Down Expand Up @@ -213,6 +274,7 @@ const DEFAULTS: &[(Context, Action, &[&str])] = &[
(Context::Global, Action::SpeedDown, &["["]),
(Context::Global, Action::NowPlaying, &["n"]),
(Context::Global, Action::Back, &["esc", "backspace", "h"]),
(Context::Global, Action::Palette, &[":", "ctrl+p"]),
(Context::List, Action::Up, &["k", "up"]),
(Context::List, Action::Down, &["j", "down"]),
(Context::List, Action::PageUp, &["pageup", "ctrl+u"]),
Expand Down
1 change: 1 addition & 0 deletions src/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod collection_picker;
mod device_picker;
pub mod keymap;
pub mod pages;
mod palette;
pub mod task;
pub mod text;
pub mod ui;
Expand Down
Loading
Loading