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! {
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"),
],
+75 -13
View File
@@ -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)
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 {
App::with_state(options, config_path, config, state).run(terminal)
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;
}
})
}
+25 -17
View File
@@ -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,13 +2048,8 @@ 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<()> {
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) {
@@ -2063,25 +2066,30 @@ pub fn run(
EnableBracketedPaste,
SetCursorStyle::SteadyBlock
)?;
let mut browser = Browser::new(options, config_path, config, state);
browser.thumbnail.query_terminal();
self.thumbnail.query_terminal();
let started = Instant::now();
loop {
browser.update();
terminal.draw(|frame| browser.draw(frame, started.elapsed()))?;
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 browser.key(key) {
return Ok(());
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) => browser.paste(&text),
Event::Paste(text) => self.paste(&text),
_ => {}
}
}
}
}
}
#[cfg(test)]
#[path = "../tests/unit/miruro_ui.rs"]
+23
View File
@@ -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::{