use std::collections::HashSet;
use std::path::PathBuf;
use crate::config::{
Config, EffectsConfig, Language, PlayerMode, codec_name, language_name, next_codec, next_language, next_quality,
};
use crate::doctor::{Check, Manager};
use crate::domain::{TabId, Video};
use crate::i18n::Lang;
use crate::keymap::KeyContext;
use crate::media::{LocalFile, MediaKind, parse_dirs};
use crate::update::Update;
use crate::youtube::{Items, SearchPage, Source, Watch};
pub const FILTERED_LIMIT: u32 = 200;
const PREFETCH_ROWS: usize = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Section {
Inicio,
Descubrir,
Suscripciones,
VerDespues,
Canales,
BuscarVideos,
BuscarCanales,
MisVideos,
MiMusica,
Configuracion,
AcercaDe,
Salir,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Group {
YouTube,
Local,
App,
}
impl Group {
pub fn label(self, lang: Lang) -> &'static str {
match self {
Self::YouTube => "YOUTUBE",
Self::Local => lang.pick("EN TU EQUIPO", "ON THIS COMPUTER"),
Self::App => "",
}
}
}
impl Section {
pub const ALL: [Self; 12] = [
Self::Inicio,
Self::Descubrir,
Self::Suscripciones,
Self::VerDespues,
Self::Canales,
Self::BuscarVideos,
Self::BuscarCanales,
Self::MisVideos,
Self::MiMusica,
Self::Configuracion,
Self::AcercaDe,
Self::Salir,
];
pub fn group(self) -> Group {
match self {
Self::MisVideos | Self::MiMusica => Group::Local,
Self::Configuracion | Self::AcercaDe | Self::Salir => Group::App,
_ => Group::YouTube,
}
}
pub fn label(self, lang: Lang) -> &'static str {
let (es, en) = match self {
Self::Inicio => ("Inicio", "Home"),
Self::Descubrir => ("Descubrir", "Discover"),
Self::Suscripciones => ("Suscripciones", "Subscriptions"),
Self::VerDespues => ("Ver después", "Watch later"),
Self::Canales => ("Mis canales", "My channels"),
Self::BuscarVideos => ("Buscar videos", "Search videos"),
Self::BuscarCanales => ("Buscar canales", "Search channels"),
Self::MisVideos => ("Mis videos", "My videos"),
Self::MiMusica => ("Mi música", "My music"),
Self::Configuracion => ("Configuración", "Settings"),
Self::AcercaDe => ("Acerca de", "About"),
Self::Salir => ("Salir", "Quit"),
};
lang.pick(es, en)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Setting {
Browser,
ImportCookies,
Refresh,
Logout,
Language,
SubLang,
Quality,
VideoMode,
BackgroundAudio,
AppLang,
Icons,
Thumbnails,
Mouse,
FxStartup,
FxPages,
FxDialogs,
VideoDirs,
MusicDirs,
DownloadDir,
SystemCheck,
HideWatched,
CheckUpdates,
EditFile,
}
impl Setting {
pub const ALL: [Self; 23] = [
Self::Browser,
Self::ImportCookies,
Self::Refresh,
Self::Logout,
Self::Language,
Self::SubLang,
Self::Quality,
Self::VideoMode,
Self::BackgroundAudio,
Self::AppLang,
Self::Icons,
Self::Thumbnails,
Self::Mouse,
Self::FxStartup,
Self::FxPages,
Self::FxDialogs,
Self::VideoDirs,
Self::MusicDirs,
Self::DownloadDir,
Self::SystemCheck,
Self::HideWatched,
Self::CheckUpdates,
Self::EditFile,
];
pub fn label(self, lang: Lang) -> &'static str {
let (es, en) = match self {
Self::Browser => ("Sesión desde un navegador", "Log in with a browser"),
Self::ImportCookies => {
("Importar cookies.txt (sin navegador / SSH)", "Import cookies.txt (no browser / SSH)")
}
Self::Refresh => ("Releer la sesión", "Read the login again"),
Self::Logout => ("Cerrar sesión", "Log out"),
Self::Language => ("Idioma del video", "Video language"),
Self::SubLang => ("Idioma de subtítulos y doblaje", "Subtitles and dubbing language"),
Self::Quality => ("Calidad máxima", "Maximum quality"),
Self::VideoMode => ("Video", "Video"),
Self::BackgroundAudio => ("Al salir de la pestaña del video", "When leaving the video tab"),
Self::AppLang => ("Idioma de la app (C-y l)", "App language (C-y l)"),
Self::Icons => ("Íconos", "Icons"),
Self::Thumbnails => ("Portadas de los videos (kitty)", "Video thumbnails (kitty)"),
Self::Mouse => ("Mouse (para copiar texto: Shift)", "Mouse (to copy text: Shift)"),
Self::FxStartup => ("Animación al abrir la app", "Animation when the app starts"),
Self::FxPages => ("Animación al cambiar de página", "Animation when the page changes"),
Self::FxDialogs => ("Animación de ayuda y preguntas", "Animation of help and questions"),
Self::VideoDirs => ("Carpetas de videos", "Video folders"),
Self::MusicDirs => ("Carpetas de música", "Music folders"),
Self::DownloadDir => ("Carpeta de descargas", "Downloads folder"),
Self::SystemCheck => ("Revisar el sistema (mpv, yt-dlp…)", "Check the system (mpv, yt-dlp…)"),
Self::HideWatched => {
("Ocultar los vistos en Inicio (v los muestra)", "Hide watched in Home (v shows them)")
}
Self::CheckUpdates => ("Buscar versiones nuevas (una vez por día)", "Look for new versions (once a day)"),
Self::EditFile => ("Editar config.toml", "Edit config.toml"),
};
lang.pick(es, en)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Menu,
List,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChannelRef {
pub name: String,
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Scope {
All,
Mine,
Channel(ChannelRef),
}
impl Scope {
pub fn label(&self, lang: Lang) -> String {
match self {
Self::All => lang.pick("todo YouTube", "all of YouTube").into(),
Self::Mine => lang.pick("mis canales", "my channels").into(),
Self::Channel(c) => format!("{} {}", lang.pick("canal", "channel"), c.name),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Prompt {
Videos {
scope: Scope,
channel: Option<ChannelRef>,
},
Channels,
CookiesFile,
DownloadDir,
MediaDirs(MediaKind),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ListKind {
Plain,
Later,
Channel(ChannelRef),
MyChannels,
Feed,
Settings,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Listing {
pub title: String,
pub items: Items,
pub selected: usize,
pub kind: ListKind,
pub next: Option<String>,
pub more: Option<u64>,
pub all: Option<Vec<Video>>,
}
impl Listing {
pub fn new(title: String, items: Items, kind: ListKind) -> Self {
Self { title, items, selected: 0, kind, next: None, more: None, all: None }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Confirm {
Unsubscribe {
channel_id: String,
name: String,
},
Trash(Vec<PathBuf>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SettingValue {
Text(String),
Number(i64),
Bool(bool),
List(Vec<String>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Prefix,
CancelPrefix,
ToggleSidebar,
ToggleHelp,
Up,
Down,
Enter,
Back,
FocusSearch,
CycleScope,
Input(char),
Backspace,
Loaded(u64, Result<Items, String>),
Page(u64, Result<SearchPage, String>),
SubsLoaded(HashSet<String>),
Watched(TabId, Result<Watch, String>),
SessionReady {
cookies: Option<PathBuf>,
subs: Result<HashSet<String>, String>,
},
CookiesImported(Result<String, String>),
ConfigReloaded(Result<Config, String>),
ToggleLater,
ToggleSubscription,
Confirm(bool),
SubscriptionDone {
channel_id: String,
name: String,
result: Result<bool, String>,
},
Download,
OpenChannel,
Status(String),
NextTab,
PrevTab,
GoTab(usize),
CloseTab,
PlayerClosed(TabId, Option<String>),
TogglePause,
Seek(i32),
SeekTo(u32),
Volume(i32),
ToggleMute,
CycleSubtitles,
NextTrack,
PrevTrack,
CycleLanguage,
CycleSubLang,
CycleQuality,
CycleCodec,
FocusMenu,
ToggleUiLang,
ToggleFocus,
AskBrowser,
SystemChecked(Manager, Vec<Check>),
HistoryLoaded(HashSet<String>),
ToggleWatched,
UpdateFound(Update),
ToggleMark,
Trash,
Trashed(Result<Vec<PathBuf>, String>),
OpenSection(usize),
Select(usize),
Scroll(isize),
ToggleFullscreen,
Quit,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
CheckSystem,
LoadHistory,
SaveWatched(String),
Trash(Vec<PathBuf>),
Watch {
tab: TabId,
video: String,
},
Load {
id: u64,
source: Source,
limit: u32,
},
ScanBrowsers {
id: u64,
},
Search {
id: u64,
query: String,
next: Option<String>,
},
ScanMedia {
id: u64,
kind: MediaKind,
},
StartSession {
force: bool,
},
SaveSetting {
table: &'static str,
key: &'static str,
value: SettingValue,
},
ImportCookies(String),
EditConfig,
SaveLater(Vec<Video>),
SaveSubs(HashSet<String>),
Subscribe {
channel_id: String,
name: String,
subscribe: bool,
},
Download(Video),
Open(TabId, Playback),
Close(TabId),
Command(TabId, PlayerCommand),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlayerCommand {
TogglePause,
Seek(i32),
SeekTo(u32),
Volume(i32),
ToggleMute,
CycleSubtitles,
NextTrack,
PrevTrack,
Language(Language),
SubLang(&'static str),
Quality(u32),
Codec(&'static str),
Fullscreen(bool),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Playback {
pub title: String,
pub subtitle: String,
pub video: Option<Video>,
pub queue: Vec<String>,
pub audio_only: bool,
}
impl Playback {
pub fn youtube(video: Video) -> Self {
Self {
title: video.title.clone(),
subtitle: video.channel.clone(),
queue: vec![video.url()],
video: Some(video),
audio_only: false,
}
}
pub fn local(files: &[LocalFile], start: usize, kind: MediaKind) -> Option<Self> {
let first = files.get(start)?;
let queue = match kind {
MediaKind::Video => vec![first.path.display().to_string()],
MediaKind::Music => files[start..].iter().map(|f| f.path.display().to_string()).collect(),
};
Some(Self {
title: first.name.clone(),
subtitle: first.folder.clone(),
video: None,
queue,
audio_only: kind == MediaKind::Music,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tab {
pub id: TabId,
pub playback: Playback,
pub watch: Option<Watch>,
pub related: usize,
pub on_related: bool,
}
impl Tab {
fn new(id: TabId, playback: Playback) -> Self {
Self { id, playback, watch: None, related: 0, on_related: false }
}
pub fn related_videos(&self) -> &[Video] {
self.watch.as_ref().map_or(&[], |w| &w.related)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Filter {
None,
Mine,
NotMine,
}
#[derive(Debug, Clone)]
struct Pending {
id: u64,
title: String,
filter: Filter,
push: bool,
kind: ListKind,
}
#[derive(Debug, Clone)]
pub struct State {
pub config: Config,
pub sidebar: bool,
pub prefix: bool,
pub help: bool,
pub fullscreen: bool,
pub focus: Focus,
pub menu: usize,
pub prompt: Option<Prompt>,
pub query: String,
pub stack: Vec<Listing>,
pub loading: Option<String>,
pub later: Vec<Video>,
pub subs: HashSet<String>,
pub confirm: Option<Confirm>,
pub marked: HashSet<PathBuf>,
pub update: Option<Update>,
pub watched: HashSet<String>,
pub system: Option<(Manager, Vec<Check>)>,
pub show_watched: bool,
pub tabs: Vec<Tab>,
pub active: usize,
pub status: String,
pub quit: bool,
pub video_window: bool,
pending: Option<Pending>,
next_tab: u64,
next_request: u64,
}
impl State {
pub fn new(config: Config, later: Vec<Video>) -> Self {
Self {
sidebar: config.ui.sidebar,
show_watched: !config.ui.hide_watched,
focus: if config.ui.sidebar { Focus::Menu } else { Focus::List },
config,
prefix: false,
help: false,
fullscreen: false,
menu: 0,
prompt: None,
query: String::new(),
stack: Vec::new(),
loading: None,
later,
subs: HashSet::new(),
confirm: None,
marked: HashSet::new(),
update: None,
watched: HashSet::new(),
system: None,
tabs: Vec::new(),
active: 0,
status: String::new(),
quit: false,
video_window: false,
pending: None,
next_tab: 0,
next_request: 0,
}
}
pub fn lang(&self) -> Lang {
self.config.ui.lang.unwrap_or_default()
}
fn tr(&self, es: &'static str, en: &'static str) -> &'static str {
self.lang().pick(es, en)
}
pub fn videos_from_selection(&self, n: usize) -> &[Video] {
match self.stack.last() {
Some(Listing { items: Items::Videos(videos), selected, .. }) if self.active == 0 => {
let start = (*selected).min(videos.len());
&videos[start..(start + 1 + n).min(videos.len())]
}
_ => &[],
}
}
pub fn active_tab(&self) -> Option<&Tab> {
self.active.checked_sub(1).and_then(|i| self.tabs.get(i))
}
fn active_tab_mut(&mut self) -> Option<&mut Tab> {
self.active.checked_sub(1).and_then(|i| self.tabs.get_mut(i))
}
pub fn related_shown(&self) -> Option<&Tab> {
self.active_tab().filter(|t| self.video_window && !t.playback.audio_only && !t.related_videos().is_empty())
}
fn on_related(&self) -> bool {
self.related_shown().is_some_and(|t| t.on_related)
}
pub fn listing(&self) -> Option<&Listing> {
self.stack.last()
}
pub fn key_context(&self) -> KeyContext {
KeyContext {
prefix: self.prefix,
help: self.help,
confirm: self.confirm.is_some(),
typing: self.prompt.is_some(),
in_tab: self.active_tab().is_some(),
related: self.on_related(),
}
}
pub fn session_label(&self) -> String {
let session = &self.config.session;
if !session.cookies_file.is_empty() {
self.tr("cookies.txt importado", "imported cookies.txt").into()
} else if !session.browser.is_empty() {
crate::session::label(&session.browser)
} else {
self.tr("sin sesión", "not logged in").into()
}
}
fn logged_in(&self) -> bool {
let session = &self.config.session;
!session.cookies_file.is_empty() || !session.browser.is_empty()
}
pub fn setting_value(&self, setting: Setting) -> String {
let player = &self.config.player;
let lang = self.lang();
match setting {
Setting::Browser | Setting::Logout => self.session_label(),
Setting::ImportCookies | Setting::Refresh | Setting::EditFile => String::new(),
Setting::Language => language_label(player.language, lang).into(),
Setting::SubLang => language_name(&player.sub_lang, lang),
Setting::Quality => format!("{}p", player.max_height),
Setting::VideoMode => match player.mode {
PlayerMode::Auto => self.tr("automático (integrado en kitty)", "automatic (embedded in kitty)").into(),
PlayerMode::Embedded => self.tr("integrado", "embedded").into(),
PlayerMode::Window => self.tr("ventana aparte", "separate window").into(),
PlayerMode::Overlay => {
self.tr("superpuesto: ventana sobre la app (X11)", "overlay: a window over the app (X11)").into()
}
},
Setting::BackgroundAudio => {
if player.background_audio {
self.tr("sigue sonando", "keeps playing").into()
} else {
self.tr("se pausa", "pauses").into()
}
}
Setting::AppLang => lang.name().into(),
Setting::Icons => self.config.ui.icons.as_str().into(),
Setting::Thumbnails => {
let (es, en) = on_off(self.config.ui.thumbnails);
self.tr(es, en).into()
}
Setting::Mouse => {
let (es, en) = on_off(self.config.ui.mouse);
self.tr(es, en).into()
}
Setting::CheckUpdates => {
let (es, en) = on_off(self.config.ui.check_updates);
self.tr(es, en).into()
}
Setting::SystemCheck => match &self.system {
None => String::new(),
Some((_, checks)) if checks.iter().any(|c| c.tool.required() && !c.ok()) => {
self.tr("falta algo necesario", "something needed is missing").into()
}
Some((_, checks)) if checks.iter().all(Check::ok) => self.tr("todo instalado", "all installed").into(),
Some(_) => self.tr("lo necesario está", "the essentials are there").into(),
},
Setting::HideWatched => {
let (es, en) = on_off(self.config.ui.hide_watched);
self.tr(es, en).into()
}
Setting::FxStartup | Setting::FxPages | Setting::FxDialogs => {
let mut effects = self.config.effects.clone();
let on = effect_flag(&mut effects, setting).is_some_and(|(_, on)| *on);
let (es, en) = on_off(on);
self.tr(es, en).into()
}
Setting::VideoDirs => dirs_label(&self.config.media.video_dirs, "~/Videos", lang),
Setting::MusicDirs => dirs_label(&self.config.media.music_dirs, "~/Música", lang),
Setting::DownloadDir => match self.config.download.dir.as_str() {
"" => "~/Videos".into(),
dir => dir.into(),
},
}
}
pub fn update(&mut self, action: Action) -> Vec<Effect> {
self.prefix = matches!(action, Action::Prefix);
let effects = self.apply(action);
if self.active_tab().is_none() {
self.fullscreen = false;
}
effects
}
fn apply(&mut self, action: Action) -> Vec<Effect> {
match action {
Action::Prefix | Action::CancelPrefix => {}
Action::ToggleSidebar => {
self.sidebar = !self.sidebar;
self.focus = if self.sidebar && self.stack.is_empty() { Focus::Menu } else { Focus::List };
}
Action::ToggleHelp => self.help = !self.help,
Action::FocusMenu => {
self.active = 0;
self.prompt = None;
self.sidebar = true;
self.focus = Focus::Menu;
}
Action::AskBrowser => {
let session = &self.config.session;
if session.asked || !session.browser.is_empty() || !session.cookies_file.is_empty() {
return Vec::new();
}
let title =
self.tr("¿De qué navegador tomo tu sesión de YouTube?", "Which browser has your YouTube login?");
let id = self.request(title.into(), Filter::None, false, ListKind::Plain);
return vec![Effect::ScanBrowsers { id }];
}
Action::OpenSection(i) => {
let Some(section) = Section::ALL.get(i) else { return Vec::new() };
self.active = 0;
self.prompt = None;
self.menu = i;
self.focus = Focus::Menu;
return self.open_section(*section);
}
Action::Select(row) if self.related_shown().is_some() => {
if let Some(tab) = self.active_tab_mut() {
tab.related = row.min(tab.related_videos().len().saturating_sub(1));
tab.on_related = true;
}
}
Action::Select(row) => {
let Some(listing) = self.stack.last_mut().filter(|_| self.active == 0) else { return Vec::new() };
listing.selected = row.min(listing.items.len().saturating_sub(1));
self.focus = Focus::List;
return self.more_if_near_the_end();
}
Action::Scroll(rows) if self.related_shown().is_some() => {
if let Some(tab) = self.active_tab_mut() {
tab.on_related = true;
}
return self.move_selection(rows);
}
Action::Scroll(rows) => {
if self.active == 0 && !self.stack.is_empty() {
self.focus = Focus::List;
return self.move_selection(rows);
}
}
Action::ToggleFocus if self.active_tab().is_some() => {
if self.related_shown().is_some()
&& let Some(tab) = self.active_tab_mut()
{
tab.on_related = !tab.on_related;
}
}
Action::ToggleFocus => {
self.focus = match self.focus {
Focus::Menu if !self.stack.is_empty() => Focus::List,
Focus::List if self.sidebar => Focus::Menu,
focus => focus,
};
}
Action::ToggleFullscreen => {
self.fullscreen = !self.fullscreen && self.active_tab().is_some();
return self.command(PlayerCommand::Fullscreen(self.fullscreen));
}
Action::Up => return self.move_selection(-1),
Action::Down => return self.move_selection(1),
Action::Enter => return self.enter(),
Action::Back => self.back(),
Action::FocusSearch => self.search_videos(),
Action::CycleScope => self.cycle_scope(),
Action::Input(c) => self.query.push(c),
Action::Backspace => {
self.query.pop();
}
Action::Loaded(id, result) => self.loaded(id, result, None),
Action::Page(id, result) => self.page(id, result),
Action::SubsLoaded(subs) => self.subs = subs,
Action::Watched(id, result) => {
if let (Some(tab), Ok(watch)) = (self.tabs.iter_mut().find(|t| t.id == id), result) {
tab.watch = Some(watch);
}
}
Action::SessionReady { subs, .. } => return self.session_ready(subs),
Action::CookiesImported(Ok(path)) => {
self.status = self
.tr("cookies.txt importado, leyendo tus canales…", "cookies.txt imported, reading your channels…")
.into();
return self.set_session(String::new(), path);
}
Action::CookiesImported(Err(error)) | Action::ConfigReloaded(Err(error)) => self.status = error,
Action::ConfigReloaded(Ok(config)) => {
let session_changed = config.session != self.config.session;
self.config = config;
self.status = self.tr("Configuración recargada", "Settings reloaded").into();
if session_changed {
return vec![Effect::StartSession { force: true }];
}
}
Action::ToggleLater => return self.toggle_later(),
Action::ToggleSubscription => return self.toggle_subscription(),
Action::Confirm(yes) => match self.confirm.take().filter(|_| yes) {
Some(Confirm::Unsubscribe { channel_id, name }) => {
self.status = format!("{} {name}…", self.tr("Desuscribiendo de", "Unsubscribing from"));
return vec![Effect::Subscribe { channel_id, name, subscribe: false }];
}
Some(Confirm::Trash(paths)) => {
self.status = format!("🗑 {} {}…", self.tr("A la papelera:", "To the trash:"), paths.len());
return vec![Effect::Trash(paths)];
}
None => {}
},
Action::SystemChecked(manager, checks) => self.system_checked(manager, checks),
Action::HistoryLoaded(ids) => {
self.watched.extend(ids);
self.refilter_watched();
}
Action::ToggleWatched => {
self.show_watched = !self.show_watched;
self.refilter_watched();
self.status = if self.show_watched {
self.tr("Mostrando también los vistos (v los oculta)", "Showing watched videos too (v hides them)")
} else {
self.tr("Vistos ocultos (v los muestra)", "Watched videos hidden (v shows them)")
}
.into();
if !self.show_watched && self.logged_in() {
return vec![Effect::LoadHistory];
}
}
Action::UpdateFound(update) => {
let (available, see) = (self.tr("disponible", "available"), self.tr("mirá Acerca de", "see About"));
self.status = format!("⬆ tubetui {} {available} · {see}", update.version);
self.update = Some(update);
}
Action::ToggleMark => return self.toggle_mark(),
Action::Trash => self.ask_trash(),
Action::Trashed(result) => self.trashed(result),
Action::SubscriptionDone { channel_id, name, result } => {
return self.subscription_done(channel_id, name, result);
}
Action::Download => {
if let Some(video) = self.selected_video().cloned() {
self.status = format!("⬇ {}: {}", self.tr("Descargando", "Downloading"), video.title);
return vec![Effect::Download(video)];
}
}
Action::OpenChannel => return self.open_channel(),
Action::Status(text) => self.status = text,
Action::NextTab => self.active = (self.active + 1) % (self.tabs.len() + 1),
Action::PrevTab => self.active = self.active.checked_sub(1).unwrap_or(self.tabs.len()),
Action::GoTab(n) if n <= self.tabs.len() => self.active = n,
Action::GoTab(_) => {}
Action::CloseTab => {
if let Some(id) = self.active_tab().map(|t| t.id) {
self.remove_tab(id);
return vec![Effect::Close(id)];
}
}
Action::PlayerClosed(id, error) => {
self.remove_tab(id);
if let Some(error) = error {
self.status = error;
}
}
Action::TogglePause => return self.command(PlayerCommand::TogglePause),
Action::Seek(seconds) => return self.command(PlayerCommand::Seek(seconds)),
Action::SeekTo(second) => return self.command(PlayerCommand::SeekTo(second)),
Action::Volume(delta) => return self.command(PlayerCommand::Volume(delta)),
Action::ToggleMute => return self.command(PlayerCommand::ToggleMute),
Action::CycleSubtitles => return self.command(PlayerCommand::CycleSubtitles),
Action::NextTrack => return self.command(PlayerCommand::NextTrack),
Action::PrevTrack => return self.command(PlayerCommand::PrevTrack),
Action::CycleLanguage => {
let player = &mut self.config.player;
player.language = player.language.next();
let language = player.language;
let lang = self.lang();
self.status = format!(
"{}: {} ({})",
lang.pick("Idioma", "Language"),
language_label(language, lang),
lang.pick("recargando el video…", "reloading the video…")
);
let mut effects = vec![Effect::SaveSetting {
table: "player",
key: "language",
value: SettingValue::Text(language.as_str().into()),
}];
effects.extend(self.command(PlayerCommand::Language(language)));
return effects;
}
Action::CycleSubLang => {
let code = next_language(&self.config.player.sub_lang);
self.config.player.sub_lang = code.into();
let lang = self.lang();
self.status = format!(
"{} {} ({})",
lang.pick("Subtítulos y doblaje en", "Subtitles and dubbing in"),
language_name(code, lang),
lang.pick("recargando el video…", "reloading the video…")
);
let mut effects = vec![Effect::SaveSetting {
table: "player",
key: "sub_lang",
value: SettingValue::Text(code.into()),
}];
effects.extend(self.command(PlayerCommand::SubLang(code)));
return effects;
}
Action::CycleQuality => {
let height = next_quality(self.config.player.max_height);
self.config.player.max_height = height;
let lang = self.lang();
self.status = format!(
"{}: {height}p ({})",
lang.pick("Calidad máxima", "Maximum quality"),
lang.pick("recargando el video…", "reloading the video…")
);
let mut effects = vec![Effect::SaveSetting {
table: "player",
key: "max_height",
value: SettingValue::Number(height.into()),
}];
effects.extend(self.command(PlayerCommand::Quality(height)));
return effects;
}
Action::CycleCodec => {
let codec = next_codec(&self.config.player.prefer_codec);
self.config.player.prefer_codec = codec.into();
let lang = self.lang();
self.status = format!(
"{}: {} ({})",
lang.pick("Códec preferido", "Preferred codec"),
codec_name(codec, lang),
lang.pick("recargando el video…", "reloading the video…")
);
let mut effects = vec![Effect::SaveSetting {
table: "player",
key: "prefer_codec",
value: SettingValue::Text(codec.into()),
}];
effects.extend(self.command(PlayerCommand::Codec(codec)));
return effects;
}
Action::ToggleUiLang => return self.toggle_ui_lang(),
Action::Quit => self.quit = true,
}
Vec::new()
}
fn move_selection(&mut self, delta: isize) -> Vec<Effect> {
let step = |value: usize, len: usize| value.saturating_add_signed(delta).min(len.saturating_sub(1));
if self.on_related() {
if let Some(tab) = self.active_tab_mut() {
tab.related = step(tab.related, tab.related_videos().len());
}
return Vec::new();
}
match self.focus {
Focus::Menu => self.menu = step(self.menu, Section::ALL.len()),
Focus::List => {
if let Some(l) = self.stack.last_mut() {
l.selected = step(l.selected, l.items.len());
}
return self.more_if_near_the_end();
}
}
Vec::new()
}
fn more_if_near_the_end(&mut self) -> Vec<Effect> {
let id = self.next_request;
let Some(listing) = self.stack.last_mut() else { return Vec::new() };
if listing.more.is_some() || listing.selected + PREFETCH_ROWS < listing.items.len() {
return Vec::new();
}
let Some(next) = listing.next.clone() else { return Vec::new() };
listing.more = Some(id);
self.next_request += 1;
vec![Effect::Search { id, query: String::new(), next: Some(next) }]
}
fn enter(&mut self) -> Vec<Effect> {
if let Some(prompt) = self.prompt.clone() {
return self.submit(prompt);
}
if self.on_related() {
let video = self.active_tab().and_then(|t| t.related_videos().get(t.related)).cloned();
return video.map(|v| self.play(Playback::youtube(v))).unwrap_or_default();
}
if self.active_tab().is_some() {
return Vec::new();
}
if self.focus == Focus::Menu {
return self.open_section(Section::ALL[self.menu]);
}
let Some(listing) = self.stack.last() else { return Vec::new() };
let selected = listing.selected;
match &listing.items {
Items::Videos(videos) => match videos.get(selected).cloned() {
Some(video) => self.play(Playback::youtube(video)),
None => Vec::new(),
},
Items::Files(files, kind) => match Playback::local(files, selected, *kind) {
Some(playback) => self.play(playback),
None => Vec::new(),
},
Items::Channels(channels) => match channels.get(selected).cloned() {
Some(c) => self.open_channel_list(c.name, c.url),
None => Vec::new(),
},
Items::Profiles(profiles) => match profiles.get(selected).cloned() {
Some(spec) => {
self.config.session.asked = true;
let mut effects =
vec![Effect::SaveSetting { table: "session", key: "asked", value: SettingValue::Bool(true) }];
self.status = if spec.is_empty() {
self.tr("Cerrando sesión…", "Logging out…").into()
} else {
let label = crate::session::label(&spec);
let reading = self.tr("Leyendo la sesión de", "Reading the login of");
if cfg!(target_os = "linux") && !spec.starts_with("firefox") {
let note = self
.tr("puede pedir la contraseña del llavero", "the keyring may ask for its password");
format!("{reading} {label}… ({note})")
} else {
format!("{reading} {label}…")
}
};
effects.extend(self.set_session(spec, String::new()));
effects
}
None => Vec::new(),
},
Items::Settings => match Setting::ALL.get(selected) {
Some(setting) => self.change_setting(*setting),
None => Vec::new(),
},
Items::About => Vec::new(),
Items::System => vec![Effect::CheckSystem],
}
}
fn open_section(&mut self, section: Section) -> Vec<Effect> {
let title = section.label(self.lang()).to_owned();
match section {
Section::Inicio => self.load_feed(Source::Recommended, title, Filter::None),
Section::Descubrir => self.load_feed(Source::Recommended, title, Filter::NotMine),
Section::Suscripciones => self.load_feed(Source::Subscriptions, title, Filter::None),
Section::Canales => self.load(Source::MyChannels, title, Filter::None, false, ListKind::MyChannels),
Section::VerDespues => {
let items = Items::Videos(self.later.clone());
self.show(Listing::new(title, items, ListKind::Later));
Vec::new()
}
Section::BuscarVideos => {
self.search_videos();
Vec::new()
}
Section::BuscarCanales => {
self.start_prompt(Prompt::Channels, String::new());
Vec::new()
}
Section::MisVideos => self.scan_media(title, MediaKind::Video),
Section::MiMusica => self.scan_media(title, MediaKind::Music),
Section::Configuracion => {
self.show(Listing::new(title, Items::Settings, ListKind::Settings));
Vec::new()
}
Section::AcercaDe => {
self.show(Listing::new(title, Items::About, ListKind::Plain));
Vec::new()
}
Section::Salir => {
self.quit = true;
Vec::new()
}
}
}
fn scan_media(&mut self, title: String, kind: MediaKind) -> Vec<Effect> {
let id = self.request(title, Filter::None, false, ListKind::Plain);
vec![Effect::ScanMedia { id, kind }]
}
fn show(&mut self, listing: Listing) {
self.stack = vec![listing];
self.focus = Focus::List;
}
fn change_setting(&mut self, setting: Setting) -> Vec<Effect> {
let save = |table, key, value| vec![Effect::SaveSetting { table, key, value }];
let player = &mut self.config.player;
match setting {
Setting::Browser => {
let id = self.request(self.tr("Navegadores", "Browsers").into(), Filter::None, true, ListKind::Plain);
vec![Effect::ScanBrowsers { id }]
}
Setting::ImportCookies => {
self.start_prompt(Prompt::CookiesFile, String::new());
Vec::new()
}
Setting::Refresh => {
self.status = format!(
"{} ({})…",
self.tr("Releyendo la sesión", "Reading the login again"),
self.session_label()
);
vec![Effect::StartSession { force: true }]
}
Setting::Logout => {
self.status = self.tr("Sesión cerrada", "Logged out").into();
self.set_session(String::new(), String::new())
}
Setting::Language => {
player.language = player.language.next();
save("player", "language", SettingValue::Text(player.language.as_str().into()))
}
Setting::SubLang => {
player.sub_lang = next_language(&player.sub_lang).into();
self.status = self.tr("Se aplica a los próximos videos", "Applies to the next videos").into();
save("player", "sub_lang", SettingValue::Text(self.config.player.sub_lang.clone()))
}
Setting::Quality => {
player.max_height = next_quality(player.max_height);
save("player", "max_height", SettingValue::Number(player.max_height.into()))
}
Setting::VideoMode => {
player.mode = player.mode.next();
self.status = self.tr("Se aplica a los próximos videos", "Applies to the next videos").into();
save("player", "mode", SettingValue::Text(self.config.player.mode.as_str().into()))
}
Setting::BackgroundAudio => {
player.background_audio = !player.background_audio;
save("player", "background_audio", SettingValue::Bool(player.background_audio))
}
Setting::AppLang => self.toggle_ui_lang(),
Setting::Icons => {
self.config.ui.icons = self.config.ui.icons.next();
save("ui", "icons", SettingValue::Text(self.config.ui.icons.as_str().into()))
}
Setting::Thumbnails => {
self.config.ui.thumbnails = !self.config.ui.thumbnails;
save("ui", "thumbnails", SettingValue::Bool(self.config.ui.thumbnails))
}
Setting::Mouse => {
self.config.ui.mouse = !self.config.ui.mouse;
save("ui", "mouse", SettingValue::Bool(self.config.ui.mouse))
}
Setting::SystemCheck => {
self.show_system();
vec![Effect::CheckSystem]
}
Setting::HideWatched => {
self.config.ui.hide_watched = !self.config.ui.hide_watched;
self.show_watched = !self.config.ui.hide_watched;
self.refilter_watched();
save("ui", "hide_watched", SettingValue::Bool(self.config.ui.hide_watched))
}
Setting::CheckUpdates => {
self.config.ui.check_updates = !self.config.ui.check_updates;
save("ui", "check_updates", SettingValue::Bool(self.config.ui.check_updates))
}
Setting::FxStartup | Setting::FxPages | Setting::FxDialogs => {
match effect_flag(&mut self.config.effects, setting) {
Some((key, on)) => {
*on = !*on;
save("effects", key, SettingValue::Bool(*on))
}
None => Vec::new(),
}
}
Setting::VideoDirs => {
let current = self.config.media.video_dirs.join(", ");
self.start_prompt(Prompt::MediaDirs(MediaKind::Video), current);
Vec::new()
}
Setting::MusicDirs => {
let current = self.config.media.music_dirs.join(", ");
self.start_prompt(Prompt::MediaDirs(MediaKind::Music), current);
Vec::new()
}
Setting::DownloadDir => {
let current = self.config.download.dir.clone();
self.start_prompt(Prompt::DownloadDir, current);
Vec::new()
}
Setting::EditFile => vec![Effect::EditConfig],
}
}
fn set_session(&mut self, browser: String, cookies_file: String) -> Vec<Effect> {
self.config.session.browser = browser.clone();
self.config.session.cookies_file = cookies_file.clone();
vec![
Effect::SaveSetting { table: "session", key: "browser", value: SettingValue::Text(browser) },
Effect::SaveSetting { table: "session", key: "cookies_file", value: SettingValue::Text(cookies_file) },
Effect::StartSession { force: true },
]
}
fn search_videos(&mut self) {
let channel = match self.stack.last() {
Some(Listing { kind: ListKind::Channel(c), .. }) if self.active == 0 => Some(c.clone()),
_ => None,
};
let scope = channel.clone().map_or(Scope::All, Scope::Channel);
self.start_prompt(Prompt::Videos { scope, channel }, String::new());
}
fn cycle_scope(&mut self) {
if let Some(Prompt::Videos { scope, channel }) = &mut self.prompt {
*scope = match (&*scope, channel) {
(Scope::All, _) => Scope::Mine,
(Scope::Mine, Some(c)) => Scope::Channel(c.clone()),
(Scope::Mine, None) | (Scope::Channel(_), _) => Scope::All,
};
}
}
fn start_prompt(&mut self, prompt: Prompt, query: String) {
self.active = 0;
self.prompt = Some(prompt);
self.query = query;
}
fn submit(&mut self, prompt: Prompt) -> Vec<Effect> {
let query = self.query.trim().to_owned();
if query.is_empty() && !matches!(prompt, Prompt::DownloadDir | Prompt::MediaDirs(_)) {
return Vec::new();
}
self.prompt = None;
match prompt {
Prompt::Videos { scope: Scope::All, .. } => {
let id = self.request(query.clone(), Filter::None, false, ListKind::Plain);
vec![Effect::Search { id, query, next: None }]
}
Prompt::Videos { scope: Scope::Mine, .. } => {
let title = format!("{query} · {}", self.tr("mis canales", "my channels"));
self.load(Source::Search(query), title, Filter::Mine, false, ListKind::Plain)
}
Prompt::Videos { scope: Scope::Channel(c), .. } => {
let title = format!("«{query}»");
let source = Source::SearchInChannel { url: c.url, query };
self.load(source, title, Filter::None, true, ListKind::Plain)
}
Prompt::Channels => {
self.load(Source::ChannelSearch(query.clone()), query, Filter::None, false, ListKind::Plain)
}
Prompt::CookiesFile => {
self.status = self.tr("Importando cookies.txt…", "Importing cookies.txt…").into();
vec![Effect::ImportCookies(query)]
}
Prompt::DownloadDir => {
self.config.download.dir = query.clone();
vec![Effect::SaveSetting { table: "download", key: "dir", value: SettingValue::Text(query) }]
}
Prompt::MediaDirs(kind) => {
let dirs = parse_dirs(&query);
let key = match kind {
MediaKind::Video => {
self.config.media.video_dirs = dirs.clone();
"video_dirs"
}
MediaKind::Music => {
self.config.media.music_dirs = dirs.clone();
"music_dirs"
}
};
vec![Effect::SaveSetting { table: "media", key, value: SettingValue::List(dirs) }]
}
}
}
fn load(&mut self, source: Source, title: String, filter: Filter, push: bool, kind: ListKind) -> Vec<Effect> {
let limit = if filter == Filter::None { self.config.search.results } else { FILTERED_LIMIT };
let id = self.request(title, filter, push, kind);
vec![Effect::Load { id, source, limit }]
}
fn request(&mut self, title: String, filter: Filter, push: bool, kind: ListKind) -> u64 {
let id = self.next_request;
self.next_request += 1;
self.status = format!("{} {title}…", self.tr("Cargando", "Loading"));
self.loading = Some(title.clone());
self.pending = Some(Pending { id, title, filter, push, kind });
id
}
fn page(&mut self, id: u64, result: Result<SearchPage, String>) {
if self.pending.as_ref().is_some_and(|p| p.id == id) {
let next = result.as_ref().ok().and_then(|p| p.next.clone());
return self.loaded(id, result.map(|p| Items::Videos(p.videos)), next);
}
let Some(listing) = self.stack.iter_mut().find(|l| l.more == Some(id)) else { return };
listing.more = None;
match result {
Ok(page) => {
if let Items::Videos(videos) = &mut listing.items {
let seen: HashSet<String> = videos.iter().map(|v| v.id.clone()).collect();
videos.extend(page.videos.into_iter().filter(|v| !seen.contains(&v.id)));
}
listing.next = page.next;
self.status = format!("{} · {}", listing.title, listing.items.len());
}
Err(error) => self.status = error,
}
}
fn loaded(&mut self, id: u64, result: Result<Items, String>, next: Option<String>) {
let Some(pending) = self.pending.take_if(|p| p.id == id) else {
return; };
self.loading = None;
let mut items = match result {
Ok(items) => items,
Err(error) => {
self.status = error;
return;
}
};
if let Items::Videos(videos) = &mut items {
let mine = |v: &Video| v.channel_id.as_ref().is_some_and(|c| self.subs.contains(c));
match pending.filter {
Filter::None => {}
Filter::Mine => videos.retain(mine),
Filter::NotMine => videos.retain(|v| v.channel_id.is_some() && !mine(v)),
}
}
self.status = if items.is_empty() {
self.tr("Sin resultados", "No results").into()
} else if let Items::Profiles(profiles) = &items {
if profiles.iter().all(String::is_empty) {
self.tr(
"No encontré navegadores: iniciá sesión en youtube.com con tu navegador, navegá un poco y \
volvé a Configuración → Sesión desde un navegador; o importá un cookies.txt",
"No browsers found: log in to youtube.com in your browser, browse a little and come back \
to Settings → Log in with a browser; or import a cookies.txt",
)
.into()
} else {
self.tr(
"Elegí con Enter el navegador donde entrás a YouTube",
"Press Enter on the browser you use YouTube in",
)
.into()
}
} else {
format!("{} · {}", pending.title, items.len())
};
let mut listing = Listing { next, ..Listing::new(pending.title, items, pending.kind) };
if listing.kind == ListKind::Feed
&& let Items::Videos(videos) = &listing.items
{
listing.all = Some(videos.clone());
let hidden = filter_watched(&mut listing, &self.watched, self.show_watched);
if hidden > 0 {
let note = self.tr("vistos ocultos (v los muestra)", "watched hidden (v shows them)");
self.status = format!("{} · {} · {hidden} {note}", listing.title, listing.items.len());
}
}
if pending.push {
self.stack.push(listing);
self.focus = Focus::List;
} else {
self.show(listing);
}
}
fn show_system(&mut self) {
let title = self.tr("Revisión del sistema", "System check").to_owned();
if self.stack.last().is_none_or(|l| l.items != Items::System) {
self.active = 0;
self.stack.push(Listing::new(title, Items::System, ListKind::Plain));
self.focus = Focus::List;
}
}
fn system_checked(&mut self, manager: Manager, checks: Vec<Check>) {
let lacking: Vec<&str> =
checks.iter().filter(|c| c.tool.required() && !c.ok()).map(|c| c.tool.program()).collect();
let extras = checks.iter().filter(|c| !c.tool.required() && !c.ok()).count();
let on_screen = self.stack.last().is_some_and(|l| l.items == Items::System);
if !lacking.is_empty() {
self.status =
format!("⚠ {}: {}", self.tr("Falta o hay que actualizar", "Missing or outdated"), lacking.join(", "));
self.show_system();
} else if !on_screen && extras > 0 {
let note = self.tr(
"opcionales sin instalar · Configuración → Revisar el sistema",
"optional tools not installed · Settings → Check the system",
);
self.status = format!("{extras} {note}");
} else if on_screen {
self.status = self.tr("Sistema revisado", "System checked").into();
}
self.system = Some((manager, checks));
}
fn load_feed(&mut self, source: Source, title: String, filter: Filter) -> Vec<Effect> {
let mut effects = self.load(source, title, filter, false, ListKind::Feed);
if self.logged_in() && !self.show_watched {
effects.push(Effect::LoadHistory);
}
effects
}
fn refilter_watched(&mut self) {
for listing in &mut self.stack {
filter_watched(listing, &self.watched, self.show_watched);
}
}
fn back(&mut self) {
if self.prompt.take().is_some() {
return;
}
if self.fullscreen {
self.fullscreen = false;
return;
}
if self.focus == Focus::List {
if self.stack.len() > 1 {
self.stack.pop();
} else if self.sidebar {
self.focus = Focus::Menu;
}
}
}
fn session_ready(&mut self, subs: Result<HashSet<String>, String>) -> Vec<Effect> {
match subs {
Ok(subs) => {
self.status = if self.logged_in() {
let (session, channels) = (self.tr("Sesión", "Login"), self.tr("canales", "channels"));
format!("{session}: {} · {} {channels}", self.session_label(), subs.len())
} else {
self.tr("Sin sesión", "Not logged in").into()
};
self.subs = subs;
vec![Effect::SaveSubs(self.subs.clone())]
}
Err(error) => {
self.status = error;
Vec::new()
}
}
}
fn selected_video(&self) -> Option<&Video> {
if let Some(tab) = self.active_tab() {
if self.on_related() {
return tab.related_videos().get(tab.related);
}
return tab.playback.video.as_ref();
}
match self.stack.last() {
Some(Listing { items: Items::Videos(videos), selected, .. }) if self.focus == Focus::List => {
videos.get(*selected)
}
_ => None,
}
}
fn selected_channel(&self) -> Option<(String, String, Option<String>)> {
if let Some(v) = self.selected_video() {
return v.channel_id.clone().map(|id| (id, v.channel.clone(), v.channel_url.clone()));
}
match self.stack.last() {
Some(Listing { items: Items::Channels(channels), selected, .. }) if self.focus == Focus::List => {
channels.get(*selected).map(|c| (c.id.clone(), c.name.clone(), Some(c.url.clone())))
}
_ => None,
}
}
fn toggle_later(&mut self) -> Vec<Effect> {
let Some(video) = self.selected_video().cloned() else { return Vec::new() };
if let Some(i) = self.later.iter().position(|v| v.id == video.id) {
self.later.remove(i);
self.status =
format!("🗑 {}: {}", self.tr("Quitado de Ver después", "Removed from Watch later"), video.title);
} else {
self.status = format!("🕒 {}: {}", self.tr("Guardado para después", "Saved for later"), video.title);
self.later.insert(0, video);
}
for listing in self.stack.iter_mut().filter(|l| l.kind == ListKind::Later) {
listing.items = Items::Videos(self.later.clone());
listing.selected = listing.selected.min(self.later.len().saturating_sub(1));
}
vec![Effect::SaveLater(self.later.clone())]
}
fn toggle_subscription(&mut self) -> Vec<Effect> {
let Some((channel_id, name, _)) = self.selected_channel() else {
if self.selected_video().is_some() {
self.status = self.tr("Este video no tiene canal", "This video has no channel").into();
}
return Vec::new();
};
if self.subs.contains(&channel_id) {
self.confirm = Some(Confirm::Unsubscribe { channel_id, name });
Vec::new()
} else {
self.status = format!("{} {name}…", self.tr("Suscribiendo a", "Subscribing to"));
vec![Effect::Subscribe { channel_id, name, subscribe: true }]
}
}
fn selected_file(&self) -> Option<&LocalFile> {
match self.stack.last() {
Some(Listing { items: Items::Files(files, _), selected, .. }) if self.active == 0 => files.get(*selected),
_ => None,
}
}
fn toggle_mark(&mut self) -> Vec<Effect> {
let Some(path) = self.selected_file().map(|f| f.path.clone()) else { return Vec::new() };
if !self.marked.remove(&path) {
self.marked.insert(path);
}
let marked = self.marked_files().len();
self.status =
format!("{marked} {}", self.tr("marcados · x o Supr: a la papelera", "marked · x or Del: to the trash"));
self.move_selection(1)
}
fn marked_files(&self) -> Vec<PathBuf> {
match self.stack.last() {
Some(Listing { items: Items::Files(files, _), .. }) if self.active == 0 => {
files.iter().filter(|f| self.marked.contains(&f.path)).map(|f| f.path.clone()).collect()
}
_ => Vec::new(),
}
}
fn ask_trash(&mut self) {
let mut paths = self.marked_files();
if paths.is_empty() {
paths.extend(self.selected_file().map(|f| f.path.clone()));
}
if !paths.is_empty() {
self.confirm = Some(Confirm::Trash(paths));
}
}
fn trashed(&mut self, result: Result<Vec<PathBuf>, String>) {
let paths = match result {
Ok(paths) => paths,
Err(error) => {
self.status = format!(
"❌ {} {error}",
self.tr("No se pudo mandar a la papelera:", "Could not move to the trash:")
);
return;
}
};
for listing in &mut self.stack {
if let Items::Files(files, _) = &mut listing.items {
files.retain(|f| !paths.contains(&f.path));
listing.selected = listing.selected.min(files.len().saturating_sub(1));
}
}
self.marked.retain(|p| !paths.contains(p));
let (sent, recover) = (
self.tr("a la papelera", "moved to the trash"),
self.tr("(se recuperan desde la papelera del sistema)", "(recover them from the system trash)"),
);
self.status = format!("🗑 {} {sent} {recover}", paths.len());
}
fn subscription_done(&mut self, channel_id: String, name: String, result: Result<bool, String>) -> Vec<Effect> {
match result {
Ok(true) => {
self.subs.insert(channel_id);
self.status = format!("🔔 {}: {name}", self.tr("Suscripto", "Subscribed"));
}
Ok(false) => {
self.subs.remove(&channel_id);
self.status = format!("🔕 {}: {name}", self.tr("Desuscripto", "Unsubscribed"));
for listing in self.stack.iter_mut().filter(|l| l.kind == ListKind::MyChannels) {
if let Items::Channels(channels) = &mut listing.items {
channels.retain(|c| c.id != channel_id);
listing.selected = listing.selected.min(channels.len().saturating_sub(1));
}
}
}
Err(error) => {
let failed = self.tr("No se pudo cambiar la suscripción a", "Could not change the subscription to");
self.status = format!("❌ {failed} {name}: {error}");
return Vec::new();
}
}
vec![Effect::SaveSubs(self.subs.clone())]
}
fn open_channel(&mut self) -> Vec<Effect> {
match self.selected_channel() {
Some((_, name, Some(url))) => {
self.active = 0;
self.open_channel_list(name, url)
}
_ => Vec::new(),
}
}
fn open_channel_list(&mut self, name: String, url: String) -> Vec<Effect> {
let kind = ListKind::Channel(ChannelRef { name: name.clone(), url: url.clone() });
self.load(Source::Channel(url), name, Filter::None, true, kind)
}
fn play(&mut self, playback: Playback) -> Vec<Effect> {
if let Some(i) = self.tabs.iter().position(|t| t.playback.queue.first() == playback.queue.first()) {
self.active = i + 1;
return Vec::new();
}
if self.tabs.len() >= self.config.ui.max_tabs {
let (max, close) = (
self.tr("Máximo de", "At most"),
self.tr("pestañas: cerrá una con C-y x", "tabs: close one with C-y x"),
);
self.status = format!("{max} {} {close}", self.config.ui.max_tabs);
return Vec::new();
}
let id = TabId(self.next_tab);
self.next_tab += 1;
self.tabs.push(Tab::new(id, playback.clone()));
self.active = self.tabs.len();
let mut effects = vec![Effect::Open(id, playback.clone())];
if let Some(video) = &playback.video {
self.watched.insert(video.id.clone());
effects.push(Effect::Watch { tab: id, video: video.id.clone() });
effects.push(Effect::SaveWatched(video.id.clone()));
}
effects
}
fn remove_tab(&mut self, id: TabId) {
let Some(i) = self.tabs.iter().position(|t| t.id == id) else {
return;
};
self.tabs.remove(i);
let index = i + 1;
if self.active > index {
self.active -= 1;
} else if self.active == index {
self.active = index.min(self.tabs.len());
}
}
fn toggle_ui_lang(&mut self) -> Vec<Effect> {
let lang = self.lang().next();
self.config.ui.lang = Some(lang);
self.status = lang.pick("Idioma: español", "Language: English").into();
vec![Effect::SaveSetting { table: "ui", key: "lang", value: SettingValue::Text(lang.as_str().into()) }]
}
fn command(&self, command: PlayerCommand) -> Vec<Effect> {
self.active_tab().map(|t| Effect::Command(t.id, command)).into_iter().collect()
}
}
fn filter_watched(listing: &mut Listing, watched: &HashSet<String>, show: bool) -> usize {
let Some(all) = &listing.all else { return 0 };
let current = match &listing.items {
Items::Videos(videos) => videos.get(listing.selected).map(|v| v.id.clone()),
_ => None,
};
let videos: Vec<Video> = all.iter().filter(|v| show || !watched.contains(&v.id)).cloned().collect();
let hidden = all.len() - videos.len();
listing.selected = current
.and_then(|id| videos.iter().position(|v| v.id == id))
.unwrap_or(listing.selected.min(videos.len().saturating_sub(1)));
listing.items = Items::Videos(videos);
hidden
}
fn dirs_label(dirs: &[String], default: &str, lang: Lang) -> String {
if dirs.is_empty() { format!("{default} ({})", lang.pick("del sistema", "system")) } else { dirs.join(", ") }
}
fn effect_flag(effects: &mut EffectsConfig, setting: Setting) -> Option<(&'static str, &mut bool)> {
match setting {
Setting::FxStartup => Some(("startup", &mut effects.startup)),
Setting::FxPages => Some(("pages", &mut effects.pages)),
Setting::FxDialogs => Some(("dialogs", &mut effects.dialogs)),
_ => None,
}
}
fn on_off(on: bool) -> (&'static str, &'static str) {
if on { ("sí", "on") } else { ("no", "off") }
}
pub fn language_label(language: Language, lang: Lang) -> &'static str {
let (es, en) = match language {
Language::Subtitles => ("original + subtítulos", "original + subtitles"),
Language::Original => ("original, sin subtítulos", "original, no subtitles"),
Language::Dubbed => ("audio doblado", "dubbed audio"),
};
lang.pick(es, en)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{IconSet, UiConfig};
use crate::domain::{channel, video};
fn state() -> State {
State::new(Config::default(), Vec::new())
}
fn section_index(section: Section) -> usize {
Section::ALL.iter().position(|s| *s == section).unwrap()
}
fn open(s: &mut State, section: Section) -> Vec<Effect> {
s.update(Action::GoTab(0));
s.prompt = None;
s.focus = Focus::Menu;
s.menu = section_index(section);
s.update(Action::Enter)
}
fn load_id(effects: &[Effect]) -> u64 {
match effects {
[Effect::Load { id, .. }, ..] | [Effect::ScanBrowsers { id }] | [Effect::ScanMedia { id, .. }] => *id,
other => panic!("expected a load, got {other:?}"),
}
}
fn with_videos(ids: &[&str]) -> State {
let mut s = state();
let id = load_id(&open(&mut s, Section::Inicio));
s.update(Action::Loaded(id, Ok(Items::Videos(ids.iter().map(|i| video(i)).collect()))));
s
}
fn play(s: &mut State, index: usize) -> TabId {
s.update(Action::GoTab(0));
s.stack.last_mut().unwrap().selected = index;
match s.update(Action::Enter).as_slice() {
[Effect::Open(id, _), Effect::Watch { tab, .. }, Effect::SaveWatched(_)] if tab == id => *id,
other => panic!("expected Open and Watch, got {other:?}"),
}
}
fn type_text(s: &mut State, text: &str) {
for c in text.chars() {
s.update(Action::Input(c));
}
}
fn change(s: &mut State, setting: Setting) -> Vec<Effect> {
open(s, Section::Configuracion);
s.stack[0].selected = Setting::ALL.iter().position(|x| *x == setting).unwrap();
s.update(Action::Enter)
}
fn saved(table: &'static str, key: &'static str, value: SettingValue) -> Effect {
Effect::SaveSetting { table, key, value }
}
fn text(value: &str) -> SettingValue {
SettingValue::Text(value.into())
}
fn inside_channel() -> State {
let mut s = state();
let id = load_id(&open(&mut s, Section::Canales));
s.update(Action::Loaded(id, Ok(Items::Channels(vec![channel("UCx")]))));
let id = load_id(&s.update(Action::Enter));
s.update(Action::Loaded(id, Ok(Items::Videos(vec![video("v")]))));
s
}
#[test]
fn starts_on_the_menu() {
let s = state();
assert_eq!((s.active, s.focus, s.menu), (0, Focus::Menu, 0));
let config = Config { ui: UiConfig { sidebar: false, ..Default::default() }, ..Default::default() };
assert_eq!(State::new(config, vec![]).focus, Focus::List);
}
#[test]
fn prefix_lasts_one_action() {
let mut s = state();
s.update(Action::Prefix);
assert!(s.prefix);
s.update(Action::ToggleSidebar);
assert!(!s.prefix);
assert!(!s.sidebar);
}
#[test]
fn hiding_the_sidebar_moves_focus_to_the_list() {
let mut s = state();
s.update(Action::ToggleSidebar);
assert_eq!(s.focus, Focus::List);
s.update(Action::ToggleSidebar);
assert_eq!(s.focus, Focus::Menu);
}
#[test]
fn ui_language_toggles_and_is_saved() {
let mut s = state();
assert_eq!(s.lang(), Lang::Es, "Spanish until the runtime picks the system's");
assert_eq!(s.update(Action::ToggleUiLang), vec![saved("ui", "lang", text("en"))]);
assert_eq!((s.lang(), s.status.as_str()), (Lang::En, "Language: English"));
assert_eq!(Section::Inicio.label(s.lang()), "Home");
assert_eq!(s.update(Action::ToggleUiLang), vec![saved("ui", "lang", text("es"))]);
assert_eq!(change(&mut s, Setting::AppLang), vec![saved("ui", "lang", text("en"))]);
assert_eq!(change(&mut s, Setting::FxPages), vec![saved("effects", "pages", SettingValue::Bool(false))]);
assert!(!s.config.effects.pages && s.config.effects.startup && s.config.effects.dialogs);
assert_eq!(s.setting_value(Setting::FxPages), "off");
assert_eq!(s.setting_value(Setting::FxDialogs), "on");
}
#[test]
fn lists_are_titled_in_the_interface_language() {
let mut s = state();
s.update(Action::ToggleUiLang);
open(&mut s, Section::Suscripciones);
assert_eq!(s.loading.as_deref(), Some("Subscriptions"));
assert_eq!(s.status, "Loading Subscriptions…");
}
#[test]
fn about_is_a_page_on_inicio() {
let mut s = state();
assert!(open(&mut s, Section::AcercaDe).is_empty());
assert_eq!(s.listing().map(|l| &l.items), Some(&Items::About));
assert!(s.update(Action::Enter).is_empty());
}
#[test]
fn help_toggles() {
let mut s = state();
s.update(Action::ToggleHelp);
assert!(s.help && s.key_context().help);
s.update(Action::ToggleHelp);
assert!(!s.help);
}
#[test]
fn menu_selection_is_clamped() {
let mut s = state();
s.update(Action::Up);
assert_eq!(s.menu, 0);
for _ in 0..50 {
s.update(Action::Down);
}
assert_eq!(s.menu, Section::ALL.len() - 1);
}
#[test]
fn feed_sections_load_their_source() {
let cases = [
(Section::Inicio, Source::Recommended, 60),
(Section::Descubrir, Source::Recommended, FILTERED_LIMIT),
(Section::Suscripciones, Source::Subscriptions, 60),
(Section::Canales, Source::MyChannels, 60),
];
for (section, expected, expected_limit) in cases {
let mut s = state();
match open(&mut s, section).as_slice() {
[Effect::Load { source, limit, .. }] => {
assert_eq!((source, *limit), (&expected, expected_limit), "{section:?}");
}
other => panic!("{section:?}: {other:?}"),
}
assert!(s.loading.is_some(), "{section:?}");
}
}
#[test]
fn loaded_list_replaces_stack_and_focuses_it() {
let s = with_videos(&["a", "b"]);
assert_eq!(s.stack.len(), 1);
assert_eq!(s.focus, Focus::List);
assert_eq!(s.loading, None);
assert_eq!(s.listing().unwrap().items, Items::Videos(vec![video("a"), video("b")]));
}
#[test]
fn stale_results_are_ignored() {
let mut s = state();
let first = load_id(&open(&mut s, Section::Inicio));
let second = load_id(&open(&mut s, Section::Suscripciones));
s.update(Action::Loaded(first, Ok(Items::Videos(vec![video("old")]))));
assert!(s.stack.is_empty());
s.update(Action::Loaded(second, Ok(Items::Videos(vec![video("new")]))));
assert_eq!(s.listing().unwrap().title, "Suscripciones");
}
#[test]
fn load_error_is_reported_and_keeps_the_list() {
let mut s = with_videos(&["a"]);
let id = load_id(&open(&mut s, Section::Suscripciones));
s.update(Action::Loaded(id, Err("sin sesión".into())));
assert_eq!(s.status, "sin sesión");
assert_eq!(s.listing().unwrap().title, "Inicio");
}
#[test]
fn discover_hides_my_channels_and_channelless_entries() {
let mut s = state();
s.update(Action::SubsLoaded(HashSet::from(["UCa".to_string()])));
let id = load_id(&open(&mut s, Section::Descubrir));
let mix = Video { channel_id: None, ..video("RDmix") };
s.update(Action::Loaded(id, Ok(Items::Videos(vec![video("a"), video("b"), mix]))));
assert_eq!(s.listing().unwrap().items, Items::Videos(vec![video("b")]));
}
#[test]
fn search_all_youtube_by_default() {
let mut s = state();
assert!(open(&mut s, Section::BuscarVideos).is_empty());
assert_eq!(s.prompt, Some(Prompt::Videos { scope: Scope::All, channel: None }));
type_text(&mut s, "rust x");
s.update(Action::Backspace);
s.update(Action::Backspace);
match s.update(Action::Enter).as_slice() {
[Effect::Search { query, next: None, .. }] => assert_eq!(query, "rust"),
other => panic!("{other:?}"),
}
assert_eq!(s.prompt, None);
}
#[test]
fn videos_from_the_selection_feed_the_thumbnails() {
let mut s = with_videos(&["a", "b", "c", "d"]);
s.update(Action::Down);
let ids = |s: &State, n| s.videos_from_selection(n).iter().map(|v| v.id.clone()).collect::<Vec<_>>();
assert_eq!(ids(&s, 2), ["b", "c", "d"]);
assert_eq!(ids(&s, 10), ["b", "c", "d"]);
play(&mut s, 0);
assert!(s.videos_from_selection(3).is_empty(), "not while watching");
}
#[test]
fn search_results_come_in_pages_loaded_while_scrolling() {
let mut s = state();
s.update(Action::FocusSearch);
type_text(&mut s, "rust");
let id = match s.update(Action::Enter).as_slice() {
[Effect::Search { id, .. }] => *id,
other => panic!("{other:?}"),
};
let page = |ids: &[&str], next: Option<&str>| SearchPage {
videos: ids.iter().map(|i| video(i)).collect(),
next: next.map(str::to_owned),
};
let first: Vec<String> = (0..8).map(|i| format!("v{i}")).collect();
let first: Vec<&str> = first.iter().map(String::as_str).collect();
s.update(Action::Page(id, Ok(page(&first, Some("T1")))));
assert_eq!(s.listing().unwrap().items.len(), 8);
assert!(s.update(Action::Down).is_empty(), "far from the end");
assert!(s.update(Action::Down).is_empty(), "far from the end");
let more = match s.update(Action::Down).as_slice() {
[Effect::Search { id, next: Some(token), .. }] => {
assert_eq!(token, "T1");
*id
}
other => panic!("{other:?}"),
};
assert!(s.update(Action::Down).is_empty(), "one request at a time");
s.update(Action::Page(more, Ok(page(&["v7", "v8", "v9"], None))));
let listing = s.listing().unwrap();
assert_eq!(listing.items.len(), 10, "appended without duplicates");
assert_eq!((listing.selected, listing.next.as_deref(), listing.more), (4, None, None));
for _ in 0..10 {
assert!(s.update(Action::Down).is_empty(), "no more pages");
}
}
#[test]
fn search_in_my_channels_filters_results() {
let mut s = state();
s.update(Action::SubsLoaded(HashSet::from(["UCa".to_string()])));
s.update(Action::FocusSearch);
s.update(Action::CycleScope);
type_text(&mut s, "rust");
let id = match s.update(Action::Enter).as_slice() {
[Effect::Load { id, source: Source::Search(_), limit: FILTERED_LIMIT }] => *id,
other => panic!("{other:?}"),
};
s.update(Action::Loaded(id, Ok(Items::Videos(vec![video("a"), video("b")]))));
assert_eq!(s.listing().unwrap().items, Items::Videos(vec![video("a")]));
assert_eq!(s.listing().unwrap().title, "rust · mis canales");
}
#[test]
fn search_inside_a_channel_starts_scoped_and_cycles() {
let mut s = inside_channel();
s.update(Action::FocusSearch);
let here = ChannelRef { name: "name UCx".into(), url: "https://www.youtube.com/channel/UCx".into() };
assert_eq!(s.prompt, Some(Prompt::Videos { scope: Scope::Channel(here.clone()), channel: Some(here.clone()) }));
type_text(&mut s, "tui");
match s.update(Action::Enter).as_slice() {
[Effect::Load { source: Source::SearchInChannel { url, query }, .. }] => {
assert_eq!((url.as_str(), query.as_str()), (here.url.as_str(), "tui"));
}
other => panic!("{other:?}"),
}
s.update(Action::FocusSearch);
let scopes: Vec<String> = (0..3)
.map(|_| {
s.update(Action::CycleScope);
match &s.prompt {
Some(Prompt::Videos { scope, .. }) => scope.label(Lang::Es),
other => panic!("{other:?}"),
}
})
.collect();
assert_eq!(scopes, ["todo YouTube", "mis canales", "canal name UCx"]);
}
#[test]
fn without_a_channel_scope_cycles_between_two() {
let mut s = state();
s.update(Action::FocusSearch);
s.update(Action::CycleScope);
s.update(Action::CycleScope);
assert_eq!(s.prompt, Some(Prompt::Videos { scope: Scope::All, channel: None }));
}
#[test]
fn channel_search_prompt() {
let mut s = state();
open(&mut s, Section::BuscarCanales);
type_text(&mut s, "tango");
assert!(matches!(s.update(Action::Enter).as_slice(), [Effect::Load { source: Source::ChannelSearch(_), .. }]));
}
#[test]
fn blank_search_does_nothing_and_esc_cancels() {
let mut s = state();
s.update(Action::FocusSearch);
s.update(Action::Input(' '));
assert!(s.update(Action::Enter).is_empty());
s.update(Action::Back);
assert_eq!(s.prompt, None);
assert_eq!(s.focus, Focus::Menu);
}
#[test]
fn search_from_a_tab_goes_home() {
let mut s = with_videos(&["a"]);
play(&mut s, 0);
s.update(Action::FocusSearch);
assert_eq!(s.active, 0);
assert!(s.prompt.is_some());
}
#[test]
fn list_selection_is_clamped() {
let mut s = with_videos(&["a", "b"]);
s.update(Action::Up);
assert_eq!(s.listing().unwrap().selected, 0);
s.update(Action::Down);
s.update(Action::Down);
assert_eq!(s.listing().unwrap().selected, 1);
}
#[test]
fn channel_list_pushes_and_esc_pops() {
let mut s = inside_channel();
assert_eq!(s.stack.len(), 2);
assert_eq!(s.listing().unwrap().title, "name UCx");
assert!(matches!(s.listing().unwrap().kind, ListKind::Channel(_)));
s.update(Action::Back);
assert_eq!((s.stack.len(), s.focus), (1, Focus::List));
s.update(Action::Back);
assert_eq!(s.focus, Focus::Menu);
}
#[test]
fn open_channel_from_a_video() {
let mut s = with_videos(&["a"]);
match s.update(Action::OpenChannel).as_slice() {
[Effect::Load { source: Source::Channel(url), .. }] => assert!(url.ends_with("UCa")),
other => panic!("{other:?}"),
}
}
#[test]
fn play_opens_a_tab_and_switches_to_it() {
let mut s = with_videos(&["a", "b"]);
let id = play(&mut s, 1);
assert_eq!(s.active, 1);
assert_eq!(s.active_tab().unwrap(), &Tab::new(id, Playback::youtube(video("b"))));
}
#[test]
fn playing_an_open_video_reuses_its_tab() {
let mut s = with_videos(&["a", "b"]);
play(&mut s, 0);
play(&mut s, 1);
s.update(Action::GoTab(0));
s.stack[0].selected = 0;
assert!(s.update(Action::Enter).is_empty());
assert_eq!((s.tabs.len(), s.active), (2, 1));
}
#[test]
fn max_tabs_is_enforced() {
let config = Config { ui: UiConfig { max_tabs: 1, ..Default::default() }, ..Default::default() };
let mut s = State::new(config, vec![]);
let id = load_id(&open(&mut s, Section::Inicio));
s.update(Action::Loaded(id, Ok(Items::Videos(vec![video("a"), video("b")]))));
play(&mut s, 0);
s.update(Action::GoTab(0));
s.stack[0].selected = 1;
assert!(s.update(Action::Enter).is_empty());
assert!(s.status.contains("Máximo"));
}
#[test]
fn tabs_navigate_and_close_like_a_browser() {
let mut s = with_videos(&["a", "b", "c"]);
play(&mut s, 0);
let b = play(&mut s, 1);
play(&mut s, 2);
s.update(Action::NextTab);
assert_eq!(s.active, 0);
s.update(Action::PrevTab);
assert_eq!(s.active, 3);
s.update(Action::GoTab(9));
assert_eq!(s.active, 3);
s.update(Action::GoTab(2));
assert_eq!(s.update(Action::CloseTab), vec![Effect::Close(b)]);
assert_eq!(s.active_tab().unwrap().playback.video, Some(video("c")));
s.update(Action::CloseTab);
s.update(Action::CloseTab);
assert_eq!(s.active, 0);
assert!(s.update(Action::CloseTab).is_empty());
}
#[test]
fn player_closed_drops_tab_without_closing_again() {
let mut s = with_videos(&["a", "b"]);
let a = play(&mut s, 0);
play(&mut s, 1);
assert!(s.update(Action::PlayerClosed(a, Some("mpv no está".into()))).is_empty());
assert_eq!(s.active_tab().unwrap().playback.video, Some(video("b")));
assert_eq!(s.status, "mpv no está");
s.update(Action::PlayerClosed(TabId(99), None));
assert_eq!(s.tabs.len(), 1);
}
#[test]
fn player_commands_target_the_active_tab() {
let mut s = with_videos(&["a"]);
let a = play(&mut s, 0);
for (action, command) in [
(Action::TogglePause, PlayerCommand::TogglePause),
(Action::Seek(-5), PlayerCommand::Seek(-5)),
(Action::SeekTo(90), PlayerCommand::SeekTo(90)),
(Action::Volume(5), PlayerCommand::Volume(5)),
(Action::ToggleMute, PlayerCommand::ToggleMute),
(Action::CycleSubtitles, PlayerCommand::CycleSubtitles),
] {
assert_eq!(s.update(action), vec![Effect::Command(a, command)]);
}
s.update(Action::GoTab(0));
assert!(s.update(Action::Volume(5)).is_empty());
}
#[test]
fn later_toggles_from_list_and_tab_and_persists() {
let mut s = with_videos(&["a", "b"]);
assert_eq!(s.update(Action::ToggleLater), vec![Effect::SaveLater(vec![video("a")])]);
play(&mut s, 1);
assert_eq!(s.update(Action::ToggleLater), vec![Effect::SaveLater(vec![video("b"), video("a")])]);
s.update(Action::ToggleLater);
assert_eq!(s.later, vec![video("a")]);
assert!(s.status.contains("Quitado"));
}
#[test]
fn later_section_shows_and_tracks_the_list() {
let mut s = State::new(Config::default(), vec![video("a"), video("b")]);
assert!(open(&mut s, Section::VerDespues).is_empty());
assert_eq!(s.listing().unwrap().kind, ListKind::Later);
s.update(Action::Down);
s.update(Action::ToggleLater);
assert_eq!(s.listing().unwrap().items, Items::Videos(vec![video("a")]));
assert_eq!(s.listing().unwrap().selected, 0);
}
#[test]
fn later_needs_a_video() {
let mut s = state();
assert!(s.update(Action::ToggleLater).is_empty());
}
#[test]
fn subscribe_directly_but_confirm_unsubscribe() {
let mut s = with_videos(&["a"]);
let subscribe = Effect::Subscribe { channel_id: "UCa".into(), name: "chan a".into(), subscribe: true };
assert_eq!(s.update(Action::ToggleSubscription), vec![subscribe]);
s.update(Action::SubscriptionDone { channel_id: "UCa".into(), name: "chan a".into(), result: Ok(true) });
assert!(s.subs.contains("UCa"));
assert!(s.update(Action::ToggleSubscription).is_empty());
assert!(s.key_context().confirm);
assert!(s.update(Action::Confirm(false)).is_empty());
assert_eq!(s.confirm, None);
s.update(Action::ToggleSubscription);
let unsubscribe = Effect::Subscribe { channel_id: "UCa".into(), name: "chan a".into(), subscribe: false };
assert_eq!(s.update(Action::Confirm(true)), vec![unsubscribe]);
let effects =
s.update(Action::SubscriptionDone { channel_id: "UCa".into(), name: "chan a".into(), result: Ok(false) });
assert_eq!(effects, vec![Effect::SaveSubs(HashSet::new())]);
}
#[test]
fn subscription_failure_keeps_state() {
let mut s = with_videos(&["a"]);
let done = Action::SubscriptionDone { channel_id: "UCa".into(), name: "n".into(), result: Err("403".into()) };
assert!(s.update(done).is_empty());
assert!(s.subs.is_empty());
assert!(s.status.contains("403"));
}
fn check(tool: crate::doctor::Tool, found: bool) -> Check {
let found = found.then(|| (format!("/usr/bin/{}", tool.program()), Some("1.0".to_string())));
Check { tool, found, outdated: false, fix: None }
}
#[test]
fn the_system_check_opens_by_itself_only_when_something_needed_is_missing() {
use crate::doctor::Tool;
let mut s = state();
s.update(Action::SystemChecked(
Manager::Apt,
vec![check(Tool::Mpv, true), check(Tool::YtDlp, true), check(Tool::Ffmpeg, false)],
));
assert!(s.stack.is_empty(), "all needed is there: no page");
assert!(s.status.starts_with("1 opcionales"), "{}", s.status);
s.update(Action::SystemChecked(Manager::Apt, vec![check(Tool::Mpv, false), check(Tool::YtDlp, true)]));
assert_eq!(s.listing().unwrap().items, Items::System);
assert!(s.status.contains("mpv"), "{}", s.status);
assert_eq!(s.update(Action::Enter), [Effect::CheckSystem], "Enter checks again");
assert_eq!(s.stack.len(), 1, "not opened twice");
assert_eq!(s.setting_value(Setting::SystemCheck), "falta algo necesario");
}
#[test]
fn feeds_hide_watched_videos_and_v_shows_them() {
let mut s = state();
s.watched.insert("b".into());
let id = load_id(&open(&mut s, Section::Inicio));
s.update(Action::Loaded(id, Ok(Items::Videos(vec![video("a"), video("b"), video("c")]))));
let ids = |s: &State| match &s.listing().unwrap().items {
Items::Videos(v) => v.iter().map(|v| v.id.clone()).collect::<Vec<_>>(),
_ => panic!(),
};
assert_eq!(ids(&s), ["a", "c"]);
assert!(s.status.contains("1 vistos ocultos"), "{}", s.status);
s.update(Action::HistoryLoaded(HashSet::from(["c".to_string()])));
assert_eq!(ids(&s), ["a"]);
s.update(Action::ToggleWatched);
assert_eq!(ids(&s), ["a", "b", "c"]);
assert_eq!(s.listing().unwrap().selected, 0, "still on a");
s.stack[0].selected = 2;
s.update(Action::ToggleWatched);
assert_eq!(ids(&s), ["a"]);
assert_eq!(s.listing().unwrap().selected, 0, "c is hidden: clamped");
}
#[test]
fn playing_marks_the_video_watched() {
let mut s = with_videos(&["a"]);
let effects = s.update(Action::Enter);
assert_eq!(effects.last(), Some(&Effect::SaveWatched("a".into())));
assert!(s.watched.contains("a"));
let mut logged = state();
logged.config.session.browser = "brave".into();
assert!(open(&mut logged, Section::Inicio).contains(&Effect::LoadHistory), "my history, to hide it");
assert!(!open(&mut logged, Section::Canales).contains(&Effect::LoadHistory));
}
#[test]
fn marked_files_go_to_the_trash_after_asking() {
let mut s = state();
let id = load_id(&open(&mut s, Section::MiMusica));
s.update(Action::Loaded(id, Ok(Items::Files(files(&["a", "b", "c"]), MediaKind::Music))));
s.update(Action::ToggleMark); s.update(Action::Down); s.update(Action::ToggleMark);
assert_eq!(s.marked.len(), 2);
assert!(s.status.starts_with("2 marcados"), "{}", s.status);
s.update(Action::Trash);
let marked = vec![PathBuf::from("/m/a.mp3"), PathBuf::from("/m/c.mp3")];
assert_eq!(s.confirm, Some(Confirm::Trash(marked.clone())));
assert!(s.key_context().confirm);
assert_eq!(s.update(Action::Confirm(true)), [Effect::Trash(marked.clone())]);
s.update(Action::Trashed(Ok(marked)));
assert_eq!(s.listing().unwrap().items, Items::Files(files(&["b"]), MediaKind::Music));
assert!(s.marked.is_empty());
assert!(s.status.contains("2 a la papelera"), "{}", s.status);
s.update(Action::Trash);
assert_eq!(s.confirm, Some(Confirm::Trash(vec![PathBuf::from("/m/b.mp3")])));
assert!(s.update(Action::Confirm(false)).is_empty());
s.update(Action::Trashed(Err("permiso denegado".into())));
assert!(s.status.contains("permiso denegado"));
assert_eq!(s.listing().unwrap().items.len(), 1);
}
#[test]
fn marking_does_nothing_outside_file_lists() {
let mut s = with_videos(&["a"]);
assert!(s.update(Action::ToggleMark).is_empty());
s.update(Action::Trash);
assert_eq!(s.confirm, None);
}
#[test]
fn unsubscribing_takes_the_channel_out_of_my_channels() {
let mut s = state();
let id = load_id(&open(&mut s, Section::Canales));
s.update(Action::Loaded(id, Ok(Items::Channels(vec![channel("UCa"), channel("UCb")]))));
s.subs.extend(["UCa".to_string(), "UCb".to_string()]);
s.stack[0].selected = 1;
assert!(s.update(Action::ToggleSubscription).is_empty(), "asks first");
assert!(matches!(s.update(Action::Confirm(true)).as_slice(), [Effect::Subscribe { subscribe: false, .. }]));
s.update(Action::SubscriptionDone { channel_id: "UCb".into(), name: "b".into(), result: Ok(false) });
assert_eq!(s.listing().unwrap().items, Items::Channels(vec![channel("UCa")]));
assert_eq!(s.listing().unwrap().selected, 0);
}
#[test]
fn subscribe_from_channel_list() {
let mut s = state();
let id = load_id(&open(&mut s, Section::Canales));
s.update(Action::Loaded(id, Ok(Items::Channels(vec![channel("UCx")]))));
assert!(matches!(s.update(Action::ToggleSubscription).as_slice(), [Effect::Subscribe { subscribe: true, .. }]));
}
#[test]
fn video_without_channel_cannot_subscribe() {
let mut s = state();
let id = load_id(&open(&mut s, Section::Inicio));
s.update(Action::Loaded(id, Ok(Items::Videos(vec![Video { channel_id: None, ..video("a") }]))));
assert!(s.update(Action::ToggleSubscription).is_empty());
assert_eq!(s.status, "Este video no tiene canal");
}
#[test]
fn download_selected_video() {
let mut s = with_videos(&["a"]);
assert_eq!(s.update(Action::Download), vec![Effect::Download(video("a"))]);
s.focus = Focus::Menu;
assert!(s.update(Action::Download).is_empty());
}
#[test]
fn settings_screen_lists_every_setting() {
let mut s = state();
assert!(open(&mut s, Section::Configuracion).is_empty());
assert_eq!(s.listing().unwrap().kind, ListKind::Settings);
assert_eq!(s.listing().unwrap().items.len(), Setting::ALL.len());
assert_eq!(s.setting_value(Setting::Quality), "720p");
assert_eq!(s.setting_value(Setting::DownloadDir), "~/Videos");
assert_eq!(s.setting_value(Setting::Browser), "sin sesión");
}
#[test]
fn cycling_settings_saves_them() {
let mut s = state();
assert_eq!(change(&mut s, Setting::Language), [saved("player", "language", text("original"))]);
assert_eq!(s.config.player.language, Language::Original);
assert_eq!(change(&mut s, Setting::Quality), [saved("player", "max_height", SettingValue::Number(1080))]);
assert_eq!(
change(&mut s, Setting::VideoMode),
[saved("player", "mode", text("overlay"))],
"from the default, window"
);
assert_eq!(s.config.player.mode, PlayerMode::Overlay);
assert_eq!(change(&mut s, Setting::Icons), [saved("ui", "icons", text("nerd"))]);
assert_eq!(s.config.ui.icons, IconSet::Nerd);
assert_eq!(change(&mut s, Setting::EditFile), [Effect::EditConfig]);
}
#[test]
fn subtitle_language_cycles_and_saves() {
let mut s = state();
assert_eq!(s.setting_value(Setting::SubLang), "español (es)");
assert_eq!(change(&mut s, Setting::SubLang), [saved("player", "sub_lang", text("en"))]);
assert_eq!(s.setting_value(Setting::SubLang), "inglés (en)");
}
#[test]
fn background_audio_toggles_and_saves() {
let mut s = state();
assert_eq!(s.setting_value(Setting::BackgroundAudio), "sigue sonando");
assert_eq!(
change(&mut s, Setting::BackgroundAudio),
[saved("player", "background_audio", SettingValue::Bool(false))]
);
assert_eq!(s.setting_value(Setting::BackgroundAudio), "se pausa");
}
#[test]
fn language_changes_the_current_video_and_is_saved() {
let mut s = with_videos(&["a"]);
let a = play(&mut s, 0);
assert_eq!(
s.update(Action::CycleLanguage),
[
saved("player", "language", text("original")),
Effect::Command(a, PlayerCommand::Language(Language::Original)),
]
);
assert!(s.status.contains("recargando"));
s.update(Action::GoTab(0));
assert_eq!(s.update(Action::CycleLanguage), [saved("player", "language", text("dubbed"))]);
}
#[test]
fn related_videos_take_the_keys_with_tab() {
let mut s = with_videos(&["a"]);
s.video_window = true;
let a = play(&mut s, 0);
s.update(Action::Watched(TabId(99), Ok(Watch::default())));
assert_eq!(s.update(Action::ToggleFocus), [], "nothing related yet");
assert!(!s.key_context().related);
let watch = Watch { related: vec![video("r1"), video("r2"), video("r3")], ..Watch::default() };
s.update(Action::Watched(a, Ok(watch)));
s.update(Action::ToggleFocus);
assert!(s.key_context().related);
s.update(Action::Down);
s.update(Action::Down);
s.update(Action::Down);
assert_eq!(s.active_tab().unwrap().related, 2, "stops at the end");
s.update(Action::Up);
assert_eq!(s.selected_video().unwrap().id, "r2", "l / s / d / c act on it");
let effects = s.update(Action::Enter);
assert!(
matches!(effects.as_slice(), [Effect::Open(_, p), Effect::Watch { video, .. }, Effect::SaveWatched(_)]
if p.video.as_ref().unwrap().id == "r2" && video == "r2"),
"{effects:?}"
);
assert_eq!(s.tabs.len(), 2);
s.update(Action::GoTab(1));
s.update(Action::Select(0));
assert_eq!(s.active_tab().unwrap().related, 0);
s.update(Action::ToggleFocus);
assert!(!s.key_context().related, "Tab goes back to the player keys");
s.video_window = false;
s.update(Action::ToggleFocus);
assert!(!s.key_context().related, "embedded: the list is under the video");
}
#[test]
fn mouse_actions_open_select_and_scroll() {
let mut s = with_videos(&["a", "b", "c"]);
s.focus = Focus::Menu;
assert!(s.update(Action::Select(2)).is_empty());
assert_eq!((s.listing().unwrap().selected, s.focus), (2, Focus::List));
s.update(Action::Select(99));
assert_eq!(s.listing().unwrap().selected, 2, "clamped to the list");
s.update(Action::Scroll(-3));
assert_eq!(s.listing().unwrap().selected, 0);
let settings = Section::ALL.iter().position(|x| *x == Section::Configuracion).unwrap();
s.update(Action::OpenSection(settings));
assert_eq!((s.menu, s.listing().unwrap().items.clone()), (settings, Items::Settings));
assert!(s.update(Action::OpenSection(99)).is_empty());
}
#[test]
fn sub_language_changes_the_current_video_and_is_saved() {
let mut s = with_videos(&["a"]);
let a = play(&mut s, 0);
assert_eq!(
s.update(Action::CycleSubLang),
[saved("player", "sub_lang", text("en")), Effect::Command(a, PlayerCommand::SubLang("en"))]
);
assert_eq!(s.config.player.sub_lang, "en");
}
#[test]
fn quality_and_codec_change_the_current_video_and_are_saved() {
let mut s = with_videos(&["a"]);
let a = play(&mut s, 0);
assert_eq!(
s.update(Action::CycleQuality),
[
saved("player", "max_height", SettingValue::Number(1080)),
Effect::Command(a, PlayerCommand::Quality(1080))
]
);
assert!(s.status.contains("1080p"), "{}", s.status);
assert_eq!(
s.update(Action::CycleCodec),
[saved("player", "prefer_codec", text("vp")), Effect::Command(a, PlayerCommand::Codec("vp"))]
);
assert!(s.status.contains("VP9"), "{}", s.status);
assert_eq!(s.config.player.prefer_codec, "vp");
}
fn files(names: &[&str]) -> Vec<LocalFile> {
names
.iter()
.map(|n| LocalFile { path: format!("/m/{n}.mp3").into(), name: n.to_string(), folder: "Disco".into() })
.collect()
}
#[test]
fn menu_is_grouped() {
let groups: Vec<Group> = Section::ALL.iter().map(|s| s.group()).collect();
assert_eq!(groups[0], Group::YouTube);
assert_eq!(Section::MisVideos.group(), Group::Local);
assert_eq!(Section::MiMusica.group(), Group::Local);
assert_eq!(groups.last(), Some(&Group::App));
assert!(groups.windows(2).filter(|w| w[0] != w[1]).count() == 2);
}
#[test]
fn local_sections_scan_their_kind() {
let mut s = state();
assert!(matches!(
open(&mut s, Section::MisVideos).as_slice(),
[Effect::ScanMedia { kind: MediaKind::Video, .. }]
));
assert!(matches!(
open(&mut s, Section::MiMusica).as_slice(),
[Effect::ScanMedia { kind: MediaKind::Music, .. }]
));
}
#[test]
fn music_plays_from_the_chosen_song_to_the_end() {
let mut s = state();
let id = match open(&mut s, Section::MiMusica).as_slice() {
[Effect::ScanMedia { id, .. }] => *id,
other => panic!("{other:?}"),
};
s.update(Action::Loaded(id, Ok(Items::Files(files(&["a", "b", "c"]), MediaKind::Music))));
s.update(Action::Down);
let playback = match s.update(Action::Enter).as_slice() {
[Effect::Open(_, playback)] => playback.clone(),
other => panic!("{other:?}"),
};
assert_eq!(playback.queue, ["/m/b.mp3", "/m/c.mp3"]);
assert!(playback.audio_only);
assert_eq!((playback.title.as_str(), playback.video), ("b", None));
let tab = s.active_tab().unwrap().id;
assert_eq!(s.update(Action::NextTrack), [Effect::Command(tab, PlayerCommand::NextTrack)]);
assert!(s.update(Action::ToggleLater).is_empty());
}
#[test]
fn a_local_video_plays_alone() {
let playback = Playback::local(&files(&["a", "b"]), 0, MediaKind::Video).unwrap();
assert_eq!(playback.queue, ["/m/a.mp3"]);
assert!(!playback.audio_only);
assert!(Playback::local(&files(&["a"]), 5, MediaKind::Video).is_none());
}
#[test]
fn media_folders_are_edited_as_a_list() {
let mut s = state();
assert_eq!(s.setting_value(Setting::MusicDirs), "~/Música (del sistema)");
assert!(change(&mut s, Setting::MusicDirs).is_empty());
assert_eq!(s.prompt, Some(Prompt::MediaDirs(MediaKind::Music)));
type_text(&mut s, "~/Música, /mnt/discos");
let list = SettingValue::List(vec!["~/Música".into(), "/mnt/discos".into()]);
assert_eq!(s.update(Action::Enter), [saved("media", "music_dirs", list)]);
assert_eq!(s.setting_value(Setting::MusicDirs), "~/Música, /mnt/discos");
}
#[test]
fn menu_is_reachable_from_anywhere() {
let mut s = with_videos(&["a"]);
play(&mut s, 0);
s.update(Action::ToggleSidebar);
s.update(Action::FocusMenu);
assert_eq!((s.active, s.focus, s.sidebar), (0, Focus::Menu, true));
}
#[test]
fn tab_switches_between_menu_and_list() {
let mut s = state();
s.update(Action::ToggleFocus);
assert_eq!(s.focus, Focus::Menu, "nothing to focus yet");
let mut s = with_videos(&["a"]);
s.update(Action::ToggleFocus);
assert_eq!(s.focus, Focus::Menu);
s.update(Action::ToggleFocus);
assert_eq!(s.focus, Focus::List);
}
#[test]
fn fullscreen_only_for_a_video_and_esc_leaves_it() {
let mut s = with_videos(&["a"]);
s.update(Action::ToggleFullscreen);
assert!(!s.fullscreen, "no video on Inicio");
let a = play(&mut s, 0);
assert_eq!(s.update(Action::ToggleFullscreen), [Effect::Command(a, PlayerCommand::Fullscreen(true))]);
assert!(s.fullscreen);
s.update(Action::Back);
assert!(!s.fullscreen);
s.update(Action::ToggleFullscreen);
s.update(Action::GoTab(0));
assert!(!s.fullscreen, "leaving the tab ends fullscreen");
}
#[test]
fn download_dir_is_edited_in_place() {
let mut s = state();
assert!(change(&mut s, Setting::DownloadDir).is_empty());
assert_eq!(s.prompt, Some(Prompt::DownloadDir));
type_text(&mut s, "~/Descargas");
assert_eq!(s.update(Action::Enter), [saved("download", "dir", text("~/Descargas"))]);
assert_eq!(s.config.download.dir, "~/Descargas");
}
#[test]
fn browser_session_is_picked_from_the_scan() {
let mut s = state();
let id = load_id(&change(&mut s, Setting::Browser));
s.update(Action::Loaded(id, Ok(Items::Profiles(vec!["brave:Default".into(), String::new()]))));
assert_eq!(s.stack.len(), 2);
assert_eq!(
s.update(Action::Enter),
[
saved("session", "asked", SettingValue::Bool(true)),
saved("session", "browser", text("brave:Default")),
saved("session", "cookies_file", text("")),
Effect::StartSession { force: true },
]
);
assert_eq!(s.session_label(), "brave · Default");
if cfg!(target_os = "linux") {
assert!(s.status.contains("llavero"), "{}", s.status);
}
}
#[test]
fn cookies_file_import_replaces_the_browser() {
let mut s = state();
s.config.session.browser = "brave".into();
assert!(change(&mut s, Setting::ImportCookies).is_empty());
assert_eq!(s.prompt, Some(Prompt::CookiesFile));
type_text(&mut s, "~/cookies.txt");
assert_eq!(s.update(Action::Enter), [Effect::ImportCookies("~/cookies.txt".into())]);
let effects = s.update(Action::CookiesImported(Ok("/cache/cookies-imported.txt".into())));
assert_eq!(effects[0], saved("session", "browser", text("")));
assert_eq!(effects[1], saved("session", "cookies_file", text("/cache/cookies-imported.txt")));
assert_eq!(s.session_label(), "cookies.txt importado");
s.update(Action::CookiesImported(Err("no tiene sesión".into())));
assert_eq!(s.status, "no tiene sesión");
}
#[test]
fn logout_and_refresh() {
let mut s = state();
s.config.session.browser = "brave".into();
let effects = change(&mut s, Setting::Logout);
assert_eq!(effects.last(), Some(&Effect::StartSession { force: true }));
assert_eq!(s.session_label(), "sin sesión");
assert_eq!(change(&mut s, Setting::Refresh), [Effect::StartSession { force: true }]);
}
#[test]
fn first_start_asks_for_a_browser_once() {
let mut s = state();
let id = match s.update(Action::AskBrowser).as_slice() {
[Effect::ScanBrowsers { id }] => *id,
other => panic!("{other:?}"),
};
s.update(Action::Loaded(id, Ok(Items::Profiles(vec!["firefox:x".into(), String::new()]))));
assert!(s.status.contains("Elegí"), "{}", s.status);
let effects = s.update(Action::Enter);
assert_eq!(effects[0], saved("session", "asked", SettingValue::Bool(true)));
assert!(effects.contains(&Effect::StartSession { force: true }));
assert!(s.status.contains("firefox · x"), "{}", s.status);
assert!(s.update(Action::AskBrowser).is_empty(), "asked already");
let mut logged = state();
logged.config.session.browser = "brave".into();
assert!(logged.update(Action::AskBrowser).is_empty());
let mut bare = state();
let id = match bare.update(Action::AskBrowser).as_slice() {
[Effect::ScanBrowsers { id }] => *id,
other => panic!("{other:?}"),
};
bare.update(Action::Loaded(id, Ok(Items::Profiles(vec![String::new()]))));
assert!(bare.status.contains("cookies.txt"), "{}", bare.status);
assert!(bare.status.contains("iniciá sesión en youtube.com"), "{}", bare.status);
}
#[test]
fn session_ready_updates_channels() {
let mut s = state();
s.config.session.browser = "brave:Default".into();
let subs = HashSet::from(["UCa".to_string()]);
let effects = s.update(Action::SessionReady { cookies: None, subs: Ok(subs.clone()) });
assert_eq!(effects, vec![Effect::SaveSubs(subs)]);
assert!(s.status.contains("1 canales"));
assert!(s.update(Action::SessionReady { cookies: None, subs: Err("keyring".into()) }).is_empty());
assert_eq!(s.status, "keyring");
}
#[test]
fn reloaded_config_restarts_session_only_when_it_changed() {
let mut s = state();
let mut edited = Config::default();
edited.player.max_height = 480;
assert!(s.update(Action::ConfigReloaded(Ok(edited.clone()))).is_empty());
assert_eq!(s.config.player.max_height, 480);
edited.session.browser = "firefox".into();
assert_eq!(s.update(Action::ConfigReloaded(Ok(edited))), [Effect::StartSession { force: true }]);
s.update(Action::ConfigReloaded(Err("línea 3".into())));
assert_eq!(s.status, "línea 3");
}
#[test]
fn salir_quits() {
let mut s = state();
open(&mut s, Section::Salir);
assert!(s.quit);
}
}