mod settings; mod video; mod shared_string; use iced_video_player::{Video, VideoPlayer}; use shared_string::SharedString; use std::sync::Arc; mod blur_hash; use blur_hash::BlurHash; mod preview; // use preview::Preview; use iced::{Alignment, Element, Length, Task, widget::*}; use std::collections::{BTreeMap, BTreeSet}; #[derive(Debug, Clone)] pub struct Loading {} #[derive(Default, Debug, Clone)] pub struct ItemCache { pub items: BTreeMap, pub tree: BTreeMap, BTreeSet>, } impl ItemCache { pub fn insert(&mut self, parent: impl Into>, item: Item) { let parent = parent.into(); self.tree.entry(parent).or_default().insert(item.id); self.items.insert(item.id, item); } pub fn extend>( &mut self, parent: impl Into>, items: I, ) { let parent = parent.into(); items.into_iter().for_each(|item| { self.insert(parent, item); }); } pub fn items_of(&self, parent: impl Into>) -> Vec<&Item> { let parent = parent.into(); self.tree.get(&None); self.tree .get(&parent) .map(|ids| { ids.iter() .filter_map(|id| self.items.get(id)) .collect::>() }) .unwrap_or_default() } pub fn get(&self, id: &uuid::Uuid) -> Option<&Item> { self.items.get(id) } } #[derive(Clone, Debug)] pub struct Thumbnail { pub id: SharedString, pub blur_hash: Option, } impl From for Item { fn from(dto: api::jellyfin::BaseItemDto) -> Self { Item { id: dto.id, name: dto.name.map(Into::into), parent_id: dto.parent_id, thumbnail: dto .image_tags .and_then(|tags| tags.get("Primary").cloned()) .map(|tag| Thumbnail { id: tag.clone().into(), blur_hash: dto .image_blur_hashes .primary .and_then(|hashes| hashes.get(&tag).cloned()) .map(|s| s.clone().into()), }), _type: dto._type, } } } #[derive(Clone, Debug)] pub struct Item { pub id: uuid::Uuid, pub parent_id: Option, pub name: Option, pub thumbnail: Option, pub _type: api::jellyfin::BaseItemKind, } #[derive(Debug, Clone, Default)] pub enum Screen { #[default] Home, Settings, User, Video, } #[derive(Debug, Clone)] pub struct Config { pub server_url: Option, pub device_id: Option, pub device_name: Option, pub client_name: Option, pub version: Option, } impl Default for Config { fn default() -> Self { Config { server_url: Some("http://localhost:8096".to_string()), device_id: Some("jello-iced".to_string()), device_name: Some("Jello Iced".to_string()), client_name: Some("Jello".to_string()), version: Some("0.1.0".to_string()), } } } #[derive(Debug, Clone)] struct State { loading: Option, current: Option, cache: ItemCache, jellyfin_client: Option, messages: Vec, history: Vec>, query: Option, screen: Screen, settings: settings::SettingsState, is_authenticated: bool, video: Option>, } impl State { pub fn new() -> Self { State { loading: None, current: None, cache: ItemCache::default(), jellyfin_client: None, messages: Vec::new(), history: Vec::new(), query: None, screen: Screen::Home, settings: settings::SettingsState::default(), // username_input: String::new(), // password_input: String::new(), is_authenticated: false, video: None, } } } #[derive(Debug, Clone)] pub enum Message { Settings(settings::SettingsMessage), Refresh, Search, SearchQueryChanged(String), OpenItem(Option), LoadedItem(Option, Vec), Error(String), SetToken(String), Back, Home, // Login { // username: String, // password: String, // config: api::JellyfinConfig, // }, // LoginSuccess(String), // LoadedClient(api::JellyfinClient, bool), // Logout, Video(video::VideoMessage), } fn update(state: &mut State, message: Message) -> Task { // if let Some(client) = state.jellyfin_client.clone() { match message { Message::Settings(msg) => settings::update(&mut state.settings, msg), Message::OpenItem(id) => { if let Some(client) = state.jellyfin_client.clone() { use api::jellyfin::BaseItemKind::*; if let Some(cached) = id.as_ref().and_then(|id| state.cache.get(id)) && matches!(cached._type, Video | Movie | Episode) { let url = client .stream_url(id.expect("ID exists")) .expect("Failed to get stream URL"); Task::done(Message::Video(video::VideoMessage::Open(url))) } else { Task::perform( async move { let items: Result, api::JellyfinApiError> = client .items(id) .await .map(|items| items.into_iter().map(Item::from).collect()); (id, items) }, |(msg, items)| match items { Err(e) => Message::Error(format!("Failed to load item: {}", e)), Ok(items) => Message::LoadedItem(msg, items), }, ) } } else { Task::none() } } Message::LoadedItem(id, items) => { state.cache.extend(id, items); state.history.push(state.current); state.current = id; Task::none() } Message::Refresh => { if let Some(client) = state.jellyfin_client.clone() { let current = state.current; Task::perform( async move { let items: Result, api::JellyfinApiError> = client .items(current) .await .map(|items| items.into_iter().map(Item::from).collect()); (current, items) }, |(msg, items)| match items { Err(e) => Message::Error(format!("Failed to refresh items: {}", e)), Ok(items) => Message::LoadedItem(msg, items), }, ) } else { Task::none() } } Message::Error(err) => { tracing::error!("Error: {}", err); state.messages.push(err); Task::none() } Message::SetToken(token) => { tracing::info!("Authenticated with token: {}", token); state .jellyfin_client .as_mut() .map(|mut client| client.set_token(token)); state.is_authenticated = true; Task::none() } Message::Back => { state.current = state.history.pop().unwrap_or(None); Task::none() } Message::Home => { state.current = None; Task::done(Message::Refresh) } Message::SearchQueryChanged(query) => { state.query = Some(query); // Handle search query change Task::none() } Message::Search => { // Handle search action // let client = state.jellyfin_client.clone(); if let Some(client) = state.jellyfin_client.clone() { let query = state.query.clone().unwrap_or_default(); Task::perform(async move { client.search(query).await }, |r| match r { Err(e) => Message::Error(format!("Search failed: {}", e)), Ok(items) => { let items = items.into_iter().map(Item::from).collect(); Message::LoadedItem(None, items) } }) } else { Task::none() } } Message::Video(msg) => video::update(state, msg), _ => todo!(), } } fn view(state: &State) -> Element<'_, Message> { match state.screen { Screen::Settings => settings::settings(state), Screen::Home | _ => home(state), } } fn home(state: &State) -> Element<'_, Message> { column([header(state), body(state), footer(state)]) .width(Length::Fill) .height(Length::Fill) .into() } fn body(state: &State) -> Element<'_, Message> { if let Some(ref video) = state.video { video::player(video) } else { scrollable( container( Grid::with_children(state.cache.items_of(state.current).into_iter().map(card)) .fluid(400) .spacing(50), ) .padding(50) .align_x(Alignment::Center) // .align_y(Alignment::Center) .height(Length::Fill) .width(Length::Fill), ) .height(Length::Fill) .into() } } fn header(state: &State) -> Element<'_, Message> { row([ container( Button::new( Text::new( state .jellyfin_client .as_ref() .map(|c| c.config.server_url.as_str()) .unwrap_or("No Server"), ) .align_x(Alignment::Start), ) .on_press(Message::Home), ) .padding(10) .width(Length::Fill) .height(Length::Fill) .align_x(Alignment::Start) .align_y(Alignment::Center) .style(container::rounded_box) .into(), search(state), container( row([ button("Refresh").on_press(Message::Refresh).into(), button("Settings") .on_press(Message::Settings(settings::SettingsMessage::Open)) .into(), button("TestVideo") .on_press(Message::Video(video::VideoMessage::Test)) .into(), ]) .spacing(10), ) .padding(10) .width(Length::Fill) .height(Length::Fill) .align_x(Alignment::End) .align_y(Alignment::Center) .style(container::rounded_box) .into(), ]) .align_y(Alignment::Center) .width(Length::Fill) .height(50) .into() } fn search(state: &State) -> Element<'_, Message> { container( TextInput::new("Search...", state.query.as_deref().unwrap_or_default()) .padding(10) .size(16) .width(Length::Fill) .on_input(Message::SearchQueryChanged) .on_submit(Message::Search), ) .padding(10) .width(Length::Fill) .height(Length::Shrink) .style(container::rounded_box) .into() } fn footer(state: &State) -> Element<'_, Message> { container( column( state .messages .iter() .map(|msg| Text::new(msg).size(12).into()) .collect::>>(), ) .spacing(5), ) .padding(10) .width(Length::Fill) .height(Length::Shrink) .style(container::rounded_box) .into() } fn card(item: &Item) -> Element<'_, Message> { let name = item .name .as_ref() .map(|s| s.as_ref()) .unwrap_or("Unnamed Item"); MouseArea::new( container( column([ BlurHash::new( item.thumbnail .as_ref() .and_then(|t| t.blur_hash.as_ref()) .map(|s| s.as_ref()) .unwrap_or(""), ) .width(Length::Fill) .height(Length::FillPortion(5)) .into(), Text::new(name) .size(16) .align_y(Alignment::Center) .height(Length::FillPortion(1)) .into(), ]) .align_x(Alignment::Center) .width(Length::Fill) .height(Length::Fill), ) .style(container::rounded_box), ) .on_press(Message::OpenItem(Some(item.id))) .into() } fn init() -> (State, Task) { // Create a default config for initial state // let default_config = api::JellyfinConfig { // server_url: "http://localhost:8096".parse().expect("Valid URL"), // device_id: "jello-iced".to_string(), // device_name: "Jello Iced".to_string(), // client_name: "Jello".to_string(), // version: "0.1.0".to_string(), // }; // let default_client = api::JellyfinClient::new_with_config(default_config); ( State::new(), Task::perform( async move { let config_str = std::fs::read_to_string("config.toml") .map_err(|e| api::JellyfinApiError::IoError(e))?; let config: api::JellyfinConfig = toml::from_str(&config_str).map_err(|e| { api::JellyfinApiError::IoError(std::io::Error::new( std::io::ErrorKind::InvalidData, e, )) })?; // Try to load cached token and authenticate match std::fs::read_to_string(".session") { Ok(token) => { let client = api::JellyfinClient::pre_authenticated(token.trim(), config)?; Ok((client, true)) } Err(_) => { // No cached token, create unauthenticated client let client = api::JellyfinClient::new_with_config(config); Ok((client, false)) } } }, |result: Result<_, api::JellyfinApiError>| match result { // Ok((client, is_authenticated)) => Message::LoadedClient(client, is_authenticated), Err(e) => Message::Error(format!("Initialization failed: {}", e)), _ => Message::Error("Login Unimplemented".to_string()), }, ) .chain(Task::done(Message::Refresh)), ) } pub fn ui() -> iced::Result { iced::application(init, update, view).run() }