mod shared_string; use shared_string::SharedString; mod blur_hash; use blur_hash::BlurHash; use iced::{Alignment, Element, Length, Task, widget::*}; use std::collections::{BTreeMap, BTreeSet}; #[derive(Debug, Clone)] pub struct Loading { to: Screen, from: Screen, } #[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() } } #[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()), }), } } } #[derive(Clone, Debug)] pub struct Item { pub id: uuid::Uuid, pub parent_id: Option, pub name: Option, pub thumbnail: Option, } #[derive(Debug, Clone, Default)] pub enum Screen { #[default] Home, Settings, Profile, } #[derive(Debug, Clone)] struct State { loading: Option, current: Option, cache: ItemCache, jellyfin_client: api::JellyfinClient, messages: Vec, history: Vec>, } impl State { pub fn new(jellyfin_client: api::JellyfinClient) -> Self { State { loading: None, current: None, cache: ItemCache::default(), jellyfin_client, messages: Vec::new(), history: Vec::new(), } } } #[derive(Debug, Clone)] pub enum Message { OpenSettings, Refresh, OpenItem(Option), LoadedItem(Option, Vec), Error(String), SetToken(String), Back, Home, } fn update(state: &mut State, message: Message) -> Task { match message { Message::OpenSettings => { // Setting place holder Task::none() } Message::OpenItem(id) => { let client = state.jellyfin_client.clone(); 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), }, ) } Message::LoadedItem(id, items) => { state.cache.extend(id, items); state.history.push(state.current); state.current = id; Task::none() } Message::Refresh => { // Handle refresh logic let 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), }, ) } 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.set_token(token); Task::none() } Message::Back => { state.current = state.history.pop().unwrap_or(None); Task::none() } Message::Home => { state.current = None; Task::done(Message::Refresh) } } } fn view(state: &State) -> Element<'_, Message> { column([header(state), body(state), footer(state)]).into() } fn body(state: &State) -> Element<'_, Message> { 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.config.server_url.as_str()) .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(), container( row([ button("Settings").on_press(Message::OpenSettings).into(), button("Refresh").on_press(Message::Refresh).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 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"); Button::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), ) // .width(Length::FillPortion(5)) // .height(Length::FillPortion(3)) .style(container::rounded_box), ) .on_press(Message::OpenItem(Some(item.id))) .into() } fn init(config: impl Fn() -> api::JellyfinConfig + 'static) -> impl Fn() -> (State, Task) { move || { let mut jellyfin = api::JellyfinClient::new(config()); ( State::new(jellyfin.clone()), Task::perform( async move { jellyfin.authenticate_with_cached_token(".session").await }, |token| match token { Ok(token) => Message::SetToken(token), Err(e) => Message::Error(format!("Authentication failed: {}", e)), }, ) .chain(Task::done(Message::Refresh)), ) } } pub fn ui(config: impl Fn() -> api::JellyfinConfig + 'static) -> iced::Result { iced::application(init(config), update, view).run() }