use anyhow::Result; use ratatui::widgets::ListState; use std::path::PathBuf; use crate::config::Config; use crate::emby::{Client, Item}; use crate::player::Player; pub const RESUME_VIRTUAL_ID: &str = "__resume__"; pub const RESUME_TITLE: &str = "Continue Watching"; #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum View { Libraries, Items, } #[derive(Debug, Clone)] pub enum Action { Play { id: String, /// Display title (e.g. "Series — S01E02 — Episode Title"). title: String, /// Resume position from server-side UserData, in Emby ticks (100 ns units). start_ticks: i64, }, } pub struct App { pub client: Client, pub view: View, pub libraries: Vec, pub resume: Vec, /// Libraries view list = optional virtual "Continue Watching" entry + libraries. pub home_entries: Vec, pub items: Vec, pub list_state: ListState, pub current_title: Option, pub breadcrumbs: Vec<(String, String)>, // (id, name) /// Selected index at each level we navigated away from, so `back` can restore /// the cursor where the user left it (e.g. Series → Season → Episode → back /// brings you to the same season/series row, not row 0). pub selection_stack: Vec, pub status: String, /// Live, editable configuration. Mirrors what's on disk; the settings /// screen mutates this and persists it through `config_path`. pub config: Config, /// Where to persist updated settings. `None` when no writable location was /// determined (rare; the settings screen will then refuse to save). pub config_path: Option, } impl App { pub async fn new( client: Client, config: Config, config_path: Option, ) -> Result { let libraries = client.views().await?; // Best-effort: don't fail startup if the resume endpoint hiccups. let resume = client.resume_items(20).await.unwrap_or_default(); let home_entries = build_home_entries(&libraries, &resume); let mut list_state = ListState::default(); if !home_entries.is_empty() { list_state.select(Some(0)); } let status = format!( "Connected as {} · {} libraries · {} in progress", client.user_name, libraries.len(), resume.len() ); Ok(Self { client, view: View::Libraries, libraries, resume, home_entries, items: vec![], list_state, current_title: None, breadcrumbs: vec![], selection_stack: vec![], status, config, config_path, }) } pub fn current_list(&self) -> &[Item] { match self.view { View::Libraries => &self.home_entries, View::Items => &self.items, } } /// Refresh the Continue Watching list (after playback ends, for example). pub async fn refresh_resume(&mut self) { if let Ok(resume) = self.client.resume_items(20).await { self.resume = resume; self.home_entries = build_home_entries(&self.libraries, &self.resume); } } pub fn move_down(&mut self) { let len = self.current_list().len(); if len == 0 { return; } let i = self.list_state.selected().unwrap_or(0); self.list_state.select(Some((i + 1).min(len - 1))); } pub fn move_up(&mut self) { let i = self.list_state.selected().unwrap_or(0); self.list_state.select(Some(i.saturating_sub(1))); } pub fn page_down(&mut self) { let len = self.current_list().len(); if len == 0 { return; } let i = self.list_state.selected().unwrap_or(0); self.list_state.select(Some((i + 10).min(len - 1))); } pub fn page_up(&mut self) { let i = self.list_state.selected().unwrap_or(0); self.list_state.select(Some(i.saturating_sub(10))); } pub async fn activate(&mut self) -> Result> { let Some(i) = self.list_state.selected() else { return Ok(None); }; match self.view { View::Libraries => { let Some(entry) = self.home_entries.get(i).cloned() else { return Ok(None); }; // Remember which library/virtual entry was selected for restore on back. self.selection_stack.clear(); self.selection_stack.push(i); if entry.id == RESUME_VIRTUAL_ID { self.items = self.resume.clone(); self.view = View::Items; self.breadcrumbs.clear(); self.breadcrumbs .push((RESUME_VIRTUAL_ID.into(), RESUME_TITLE.into())); self.current_title = Some(RESUME_TITLE.into()); self.list_state .select(if self.items.is_empty() { None } else { Some(0) }); self.status = format!("{}: {} items", RESUME_TITLE, self.items.len()); return Ok(None); } self.status = format!("Loading {}...", entry.name); self.items = self.client.items(&entry.id).await?; self.view = View::Items; self.breadcrumbs.clear(); self.breadcrumbs .push((entry.id.clone(), entry.name.clone())); self.current_title = Some(entry.name.clone()); self.list_state .select(if self.items.is_empty() { None } else { Some(0) }); self.status = format!("{}: {} items", entry.name, self.items.len()); Ok(None) } View::Items => { let Some(item) = self.items.get(i).cloned() else { return Ok(None); }; if item.is_folder { // Remember position in this folder before drilling down. self.selection_stack.push(i); self.status = format!("Loading {}...", item.name); self.items = self.client.items(&item.id).await?; self.breadcrumbs.push((item.id.clone(), item.name.clone())); self.current_title = Some(item.name.clone()); self.list_state .select(if self.items.is_empty() { None } else { Some(0) }); self.status = format!("{}: {} items", item.name, self.items.len()); Ok(None) } else { let start_ticks = item .user_data .as_ref() .map(|ud| ud.position_ticks) .unwrap_or(0); let title = item.display_title(); Ok(Some(Action::Play { id: item.id, title, start_ticks, })) } } } } pub async fn back(&mut self) -> Result<()> { if self.view != View::Items { return Ok(()); } // Pop the current breadcrumb (current folder) self.breadcrumbs.pop(); let restore = self.selection_stack.pop(); if let Some((parent_id, parent_name)) = self.breadcrumbs.last().cloned() { if parent_id == RESUME_VIRTUAL_ID { self.items = self.resume.clone(); } else { self.items = self.client.items(&parent_id).await?; } self.current_title = Some(parent_name.clone()); self.list_state.select(restore_selection(restore, self.items.len())); self.status = format!("{}: {} items", parent_name, self.items.len()); } else { self.view = View::Libraries; self.current_title = None; self.items.clear(); self.list_state .select(restore_selection(restore, self.home_entries.len())); self.status = format!( "{} libraries · {} in progress", self.libraries.len(), self.resume.len() ); } Ok(()) } pub fn play(&mut self, action: &Action) -> Result<()> { match action { Action::Play { id, title, start_ticks, } => { let url = self.client.stream_url(id); self.status = format!("Playing {title}..."); let start_seconds = (*start_ticks as f64) / 10_000_000.0; let reporter = self.client.reporter(id.clone()); let mut player = Player::new( self.config.audio_lang.as_deref(), self.config.subtitle_lang.as_deref(), )?; let r = player.play_blocking(&url, title, start_seconds, Some(&reporter)); self.status = match &r { Ok(_) => format!("Finished: {title}"), Err(e) => format!("Playback error: {e}"), }; r } } } pub fn breadcrumb_path(&self) -> String { if self.breadcrumbs.is_empty() { "Libraries".into() } else { self.breadcrumbs .iter() .map(|(_, n)| n.as_str()) .collect::>() .join(" / ") } } } /// Clamp the remembered selection to the current list size. Returns None if /// the list is empty (so no row is highlighted). fn restore_selection(saved: Option, len: usize) -> Option { if len == 0 { return None; } Some(saved.unwrap_or(0).min(len - 1)) } pub(crate) fn build_home_entries(libraries: &[Item], resume: &[Item]) -> Vec { let mut entries: Vec = Vec::with_capacity(libraries.len() + 1); if !resume.is_empty() { entries.push(Item { id: RESUME_VIRTUAL_ID.into(), name: format!("{RESUME_TITLE} ({})", resume.len()), item_type: "Collection".into(), is_folder: true, ..Item::default() }); } entries.extend(libraries.iter().cloned()); entries }