feat: switch between provider sessions without restarting
This commit is contained in:
@@ -104,6 +104,7 @@ macro_rules! actions {
|
||||
actions! {
|
||||
Interrupt, "app.interrupt", "Exit immediately", ALL, ["ctrl+c"];
|
||||
Quit, "app.quit", "Quit the application", &[Context::Normal, Context::Info, Context::Description, Context::CollectionNormal, Context::CollectionInfo, Context::CollectionDescription, Context::Help], ["q"];
|
||||
ProviderChoose, "app.provider", "Choose the browsing provider", &[Context::Normal, Context::CollectionNormal], ["p"];
|
||||
Help, "help.toggle", "Open or close keyboard help", NON_TEXT, ["?"];
|
||||
InputSubmit, "input.submit", "Submit search, browse selector, leave tag filter, stage new tag or save category name", TEXT, ["enter"];
|
||||
InputCancel, "input.cancel", "Leave text input without submitting", TEXT, ["escape"];
|
||||
@@ -518,6 +519,10 @@ impl Bindings {
|
||||
(&[VideoFirst], "First loaded video"),
|
||||
(&[VideoLast], "Last loaded video"),
|
||||
(&[ViewRefresh], "Reload config and refresh"),
|
||||
(
|
||||
&[ProviderChoose],
|
||||
"Choose the browsing provider (NORMAL only)",
|
||||
),
|
||||
(&[Help], "Open / close this reference"),
|
||||
(&[Quit, Interrupt], "Quit"),
|
||||
],
|
||||
|
||||
+76
-14
@@ -157,7 +157,7 @@ use ratatui::{
|
||||
use video_state::{CollectionKind, WatchStatus};
|
||||
use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[derive(Clone, Parser)]
|
||||
#[command(
|
||||
version,
|
||||
about = "Browse YouTube or Miruro and open selected videos in Terebi",
|
||||
@@ -611,7 +611,7 @@ impl KeyHints {
|
||||
(
|
||||
Context::Normal,
|
||||
format!(
|
||||
"NORMAL · {}/{} select · {} play · {status} · {}/{} later · {}/{} saved · {} search · {} help · {} quit",
|
||||
"NORMAL · {}/{} select · {} play · {status} · {}/{} later · {}/{} saved · {} search · {} provider · {} help · {} quit",
|
||||
key(Action::VideoNext),
|
||||
key(Action::VideoPrevious),
|
||||
key(Action::VideoPlay),
|
||||
@@ -620,6 +620,7 @@ impl KeyHints {
|
||||
key(Action::SavedVideoSave),
|
||||
key(Action::SavedVideoBrowse),
|
||||
key(Action::SearchEdit),
|
||||
key(Action::ProviderChoose),
|
||||
key(Action::Help),
|
||||
key(Action::Quit)
|
||||
),
|
||||
@@ -651,7 +652,7 @@ impl KeyHints {
|
||||
(
|
||||
Context::CollectionNormal,
|
||||
format!(
|
||||
"NORMAL · {}/{} select · {} play · {status} · {}/{} later · {}/{} saved · {} remove selected · {} remove list · {} search · {} help · {} quit",
|
||||
"NORMAL · {}/{} select · {} play · {status} · {}/{} later · {}/{} saved · {} remove selected · {} remove list · {} search · {} provider · {} help · {} quit",
|
||||
key(Action::VideoNext),
|
||||
key(Action::VideoPrevious),
|
||||
key(Action::VideoPlay),
|
||||
@@ -662,6 +663,7 @@ impl KeyHints {
|
||||
key(Action::CollectionRemoveSelected),
|
||||
key(Action::CollectionRemoveBulk),
|
||||
key(Action::SearchEdit),
|
||||
key(Action::ProviderChoose),
|
||||
key(Action::Help),
|
||||
key(Action::Quit)
|
||||
),
|
||||
@@ -3558,7 +3560,7 @@ impl App {
|
||||
);
|
||||
}
|
||||
|
||||
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
fn run(&mut self, terminal: &mut DefaultTerminal, initial: bool) -> io::Result<Option<bool>> {
|
||||
struct RestoreTerminalInput;
|
||||
impl Drop for RestoreTerminalInput {
|
||||
fn drop(&mut self) {
|
||||
@@ -3576,7 +3578,7 @@ impl App {
|
||||
SetCursorStyle::SteadyBlock
|
||||
)?;
|
||||
self.thumbnail.query_terminal();
|
||||
if !self.views[self.view_index].query.trim().is_empty() {
|
||||
if initial && !self.views[self.view_index].query.trim().is_empty() {
|
||||
self.search();
|
||||
}
|
||||
let started = Instant::now();
|
||||
@@ -3587,8 +3589,13 @@ impl App {
|
||||
if event::poll(refresh)? {
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
if self.config.keys.resolve(self.key_context(), key)
|
||||
== Some(Action::ProviderChoose)
|
||||
{
|
||||
return choose_provider(terminal, &self.config.keys, Some(false));
|
||||
}
|
||||
if self.key(key) {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Event::Paste(text) => self.paste(&text),
|
||||
@@ -3694,15 +3701,17 @@ fn provider_selection(
|
||||
fn choose_provider(
|
||||
terminal: &mut DefaultTerminal,
|
||||
bindings: &keys::Bindings,
|
||||
current: Option<bool>,
|
||||
) -> io::Result<Option<bool>> {
|
||||
let mut selected = 0;
|
||||
let mut selected = usize::from(current.unwrap_or(false));
|
||||
let mut selection = ListState::default();
|
||||
let hint = format!(
|
||||
"{}/{} select · {} open · {} quit",
|
||||
"{}/{} select · {} open · {} {}",
|
||||
bindings.label(Action::SelectorNext),
|
||||
bindings.label(Action::SelectorPrevious),
|
||||
bindings.label(Action::SelectorOpen),
|
||||
bindings.label(Action::SelectorClose),
|
||||
if current.is_some() { "cancel" } else { "quit" },
|
||||
);
|
||||
let providers = List::new(PROVIDERS.map(|(name, _)| name))
|
||||
.highlight_style(ui::SELECTED)
|
||||
@@ -3740,6 +3749,9 @@ fn choose_provider(
|
||||
.resolve(Context::Normal, key)
|
||||
.filter(|action| *action == Action::Quit)
|
||||
});
|
||||
if current.is_some() && action == Some(Action::SelectorClose) {
|
||||
return Ok(current);
|
||||
}
|
||||
if let std::ops::ControlFlow::Break(provider) =
|
||||
provider_selection(&mut selected, action)
|
||||
{
|
||||
@@ -3761,16 +3773,66 @@ fn main() -> io::Result<()> {
|
||||
let config = config::load(&config_path)?;
|
||||
ratatui::run(|terminal| {
|
||||
if !options.miruro {
|
||||
let Some(miruro) = choose_provider(terminal, &config.keys)? else {
|
||||
let Some(miruro) = choose_provider(terminal, &config.keys, None)? else {
|
||||
return Ok(());
|
||||
};
|
||||
options.miruro = miruro;
|
||||
}
|
||||
let state = video_state::State::open(&config_path)?;
|
||||
if options.miruro {
|
||||
miruro_ui::run(terminal, options, config_path, config, state)
|
||||
} else {
|
||||
App::with_state(options, config_path, config, state).run(terminal)
|
||||
let mut youtube: Option<App> = None;
|
||||
let mut miruro: Option<miruro_ui::Browser> = None;
|
||||
let mut initial = true;
|
||||
loop {
|
||||
let next = if options.miruro {
|
||||
if miruro.is_none() {
|
||||
miruro = Some(miruro_ui::Browser::new(
|
||||
options.clone(),
|
||||
config_path.clone(),
|
||||
config::load(&config_path)?,
|
||||
video_state::State::open(&config_path)?,
|
||||
));
|
||||
}
|
||||
miruro.as_mut().unwrap().run(terminal)?
|
||||
} else {
|
||||
let first = youtube.is_none();
|
||||
if first {
|
||||
youtube = Some(App::with_state(
|
||||
options.clone(),
|
||||
config_path.clone(),
|
||||
config::load(&config_path)?,
|
||||
video_state::State::open(&config_path)?,
|
||||
));
|
||||
}
|
||||
youtube.as_mut().unwrap().run(terminal, first)?
|
||||
};
|
||||
let Some(next) = next else {
|
||||
return Ok(());
|
||||
};
|
||||
if initial {
|
||||
options.query = None;
|
||||
initial = false;
|
||||
}
|
||||
if next != options.miruro {
|
||||
if next && miruro.is_none() {
|
||||
miruro = Some(miruro_ui::Browser::new(
|
||||
options.clone(),
|
||||
config_path.clone(),
|
||||
config::load(&config_path)?,
|
||||
video_state::State::open(&config_path)?,
|
||||
));
|
||||
} else if !next && youtube.is_none() {
|
||||
youtube = Some(App::with_state(
|
||||
options.clone(),
|
||||
config_path.clone(),
|
||||
config::load(&config_path)?,
|
||||
video_state::State::open(&config_path)?,
|
||||
));
|
||||
}
|
||||
std::mem::swap(
|
||||
&mut youtube.as_mut().unwrap().video_state,
|
||||
&mut miruro.as_mut().unwrap().state,
|
||||
);
|
||||
}
|
||||
options.miruro = next;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+46
-38
@@ -294,11 +294,11 @@ impl Screen {
|
||||
}
|
||||
|
||||
// History owns the actual screens, so back/forward never has to refetch a catalog.
|
||||
struct Browser {
|
||||
pub(crate) struct Browser {
|
||||
options: Options,
|
||||
config_path: PathBuf,
|
||||
config: config::Config,
|
||||
state: State,
|
||||
pub(crate) state: State,
|
||||
screens: Vec<Screen>,
|
||||
index: usize,
|
||||
input: Option<String>,
|
||||
@@ -380,7 +380,12 @@ impl Picker {
|
||||
}
|
||||
|
||||
impl Browser {
|
||||
fn new(options: Options, config_path: PathBuf, config: config::Config, state: State) -> Self {
|
||||
pub(crate) fn new(
|
||||
options: Options,
|
||||
config_path: PathBuf,
|
||||
config: config::Config,
|
||||
state: State,
|
||||
) -> Self {
|
||||
let mut query = String::new();
|
||||
crate::paste_input(&mut query, options.query.as_deref().unwrap_or(""));
|
||||
let mut browser = Self {
|
||||
@@ -1279,6 +1284,7 @@ impl Browser {
|
||||
"Show series/episode info with cover art",
|
||||
),
|
||||
(Action::Help, "Keyboard help"),
|
||||
(Action::ProviderChoose, "Choose provider"),
|
||||
(Action::Quit, "Exit"),
|
||||
(Action::Interrupt, "Exit immediately"),
|
||||
];
|
||||
@@ -1437,6 +1443,7 @@ impl Browser {
|
||||
"NORMAL",
|
||||
&[
|
||||
(Action::Help, "help"),
|
||||
(Action::ProviderChoose, "provider"),
|
||||
(Action::VideoPlay, "play"),
|
||||
(Action::InfoOverview, "info"),
|
||||
(Action::WatchStatusWatching, "mark watching"),
|
||||
@@ -1453,6 +1460,7 @@ impl Browser {
|
||||
"NORMAL",
|
||||
&[
|
||||
(Action::Help, "help"),
|
||||
(Action::ProviderChoose, "provider"),
|
||||
(Action::SearchEdit, "search"),
|
||||
(
|
||||
Action::VideoPlay,
|
||||
@@ -2040,44 +2048,44 @@ fn media_details(media: &Media) -> String {
|
||||
text
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
terminal: &mut DefaultTerminal,
|
||||
options: Options,
|
||||
config_path: PathBuf,
|
||||
config: config::Config,
|
||||
state: State,
|
||||
) -> io::Result<()> {
|
||||
struct RestoreTerminalInput;
|
||||
impl Drop for RestoreTerminalInput {
|
||||
fn drop(&mut self) {
|
||||
let _ = execute!(
|
||||
io::stdout(),
|
||||
DisableBracketedPaste,
|
||||
SetCursorStyle::DefaultUserShape
|
||||
);
|
||||
impl Browser {
|
||||
pub(crate) fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<Option<bool>> {
|
||||
struct RestoreTerminalInput;
|
||||
impl Drop for RestoreTerminalInput {
|
||||
fn drop(&mut self) {
|
||||
let _ = execute!(
|
||||
io::stdout(),
|
||||
DisableBracketedPaste,
|
||||
SetCursorStyle::DefaultUserShape
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _restore = RestoreTerminalInput;
|
||||
execute!(
|
||||
io::stdout(),
|
||||
EnableBracketedPaste,
|
||||
SetCursorStyle::SteadyBlock
|
||||
)?;
|
||||
let mut browser = Browser::new(options, config_path, config, state);
|
||||
browser.thumbnail.query_terminal();
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
browser.update();
|
||||
terminal.draw(|frame| browser.draw(frame, started.elapsed()))?;
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
if browser.key(key) {
|
||||
return Ok(());
|
||||
let _restore = RestoreTerminalInput;
|
||||
execute!(
|
||||
io::stdout(),
|
||||
EnableBracketedPaste,
|
||||
SetCursorStyle::SteadyBlock
|
||||
)?;
|
||||
self.thumbnail.query_terminal();
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
self.update();
|
||||
terminal.draw(|frame| self.draw(frame, started.elapsed()))?;
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
if self.config.keys.resolve(self.context(), key)
|
||||
== Some(Action::ProviderChoose)
|
||||
{
|
||||
return crate::choose_provider(terminal, &self.config.keys, Some(true));
|
||||
}
|
||||
if self.key(key) {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Event::Paste(text) => self.paste(&text),
|
||||
_ => {}
|
||||
}
|
||||
Event::Paste(text) => browser.paste(&text),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,29 @@ fn press(
|
||||
bindings.resolve(context, KeyEvent::new(code, modifiers))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_choice_is_configurable_and_normal_only() {
|
||||
let defaults = Bindings::default();
|
||||
let overrides = configured("[keys.app]\nprovider = 'alt+p'\n").unwrap();
|
||||
for &context in ALL {
|
||||
let expected = matches!(context, Context::Normal | Context::CollectionNormal)
|
||||
.then_some(Action::ProviderChoose);
|
||||
assert_eq!(
|
||||
press(&defaults, context, KeyCode::Char('p'), KeyModifiers::NONE)
|
||||
.filter(|action| *action == Action::ProviderChoose),
|
||||
expected,
|
||||
);
|
||||
assert_eq!(
|
||||
press(&overrides, context, KeyCode::Char('p'), KeyModifiers::ALT),
|
||||
expected,
|
||||
);
|
||||
assert_ne!(
|
||||
press(&overrides, context, KeyCode::Char('p'), KeyModifiers::NONE),
|
||||
Some(Action::ProviderChoose),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_section_headers_are_bold_in_the_grouped_overview() {
|
||||
use ratatui::{
|
||||
|
||||
Reference in New Issue
Block a user