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