feat: switch between provider sessions without restarting

This commit is contained in:
2026-10-22 13:25:54 +02:00
parent d8e26612e4
commit acfb3be1e7
4 changed files with 150 additions and 52 deletions
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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),
_ => {}
} }
} }
} }
+23
View File
@@ -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::{