diff --git a/.gitignore b/.gitignore index a7468aa..274cc07 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ .session api/config.toml api/items.json +config.toml diff --git a/Cargo.lock b/Cargo.lock index a25dfde..7ef4555 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3582,6 +3582,7 @@ dependencies = [ "error-stack", "thiserror 2.0.17", "tokio", + "toml 0.9.8", "tracing", "tracing-subscriber", "ui-gpui", @@ -7317,6 +7318,7 @@ name = "ui-iced" version = "0.1.0" dependencies = [ "api", + "blurhash", "gpui_util", "iced", "tracing", diff --git a/Cargo.toml b/Cargo.toml index e354470..a7d56c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ dotenvy = "0.15.7" error-stack = "0.6" thiserror = "2.0" tokio = { version = "1.43.1", features = ["macros", "rt-multi-thread"] } +toml = "0.9.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/api/src/jellyfin.rs b/api/src/jellyfin.rs index 3f97e52..6261420 100644 --- a/api/src/jellyfin.rs +++ b/api/src/jellyfin.rs @@ -138,7 +138,7 @@ pub struct AlbumInfo { pub album_artists: Vec, /// Gets or sets the artist provider ids. #[serde(rename = "ArtistProviderIds")] - pub artist_provider_ids: std::collections::HashMap>, + pub artist_provider_ids: Option>>, #[serde(rename = "SongInfos")] pub song_infos: Vec, } @@ -592,7 +592,7 @@ Maps image type to dictionary mapping image tag to blurhash value.*/ pub trickplay: Option< std::collections::HashMap< String, - std::collections::HashMap, + Option>, >, >, /// Gets or sets the type of the location. @@ -729,31 +729,31 @@ Maps image type to dictionary mapping image tag to blurhash value.*/ #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct BaseItemDtoImageBlurHashes { #[serde(rename = "Primary")] - pub primary: std::collections::HashMap, + pub primary: Option>, #[serde(rename = "Art")] - pub art: std::collections::HashMap, + pub art: Option>, #[serde(rename = "Backdrop")] - pub backdrop: std::collections::HashMap, + pub backdrop: Option>, #[serde(rename = "Banner")] - pub banner: std::collections::HashMap, + pub banner: Option>, #[serde(rename = "Logo")] - pub logo: std::collections::HashMap, + pub logo: Option>, #[serde(rename = "Thumb")] - pub thumb: std::collections::HashMap, + pub thumb: Option>, #[serde(rename = "Disc")] - pub disc: std::collections::HashMap, + pub disc: Option>, #[serde(rename = "Box")] - pub _box: std::collections::HashMap, + pub _box: Option>, #[serde(rename = "Screenshot")] - pub screenshot: std::collections::HashMap, + pub screenshot: Option>, #[serde(rename = "Menu")] - pub menu: std::collections::HashMap, + pub menu: Option>, #[serde(rename = "Chapter")] - pub chapter: std::collections::HashMap, + pub chapter: Option>, #[serde(rename = "BoxRear")] - pub box_rear: std::collections::HashMap, + pub box_rear: Option>, #[serde(rename = "Profile")] - pub profile: std::collections::HashMap, + pub profile: Option>, } /// Query result container. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -794,31 +794,31 @@ pub struct BaseItemPerson { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct BaseItemPersonImageBlurHashes { #[serde(rename = "Primary")] - pub primary: std::collections::HashMap, + pub primary: Option>, #[serde(rename = "Art")] - pub art: std::collections::HashMap, + pub art: Option>, #[serde(rename = "Backdrop")] - pub backdrop: std::collections::HashMap, + pub backdrop: Option>, #[serde(rename = "Banner")] - pub banner: std::collections::HashMap, + pub banner: Option>, #[serde(rename = "Logo")] - pub logo: std::collections::HashMap, + pub logo: Option>, #[serde(rename = "Thumb")] - pub thumb: std::collections::HashMap, + pub thumb: Option>, #[serde(rename = "Disc")] - pub disc: std::collections::HashMap, + pub disc: Option>, #[serde(rename = "Box")] - pub _box: std::collections::HashMap, + pub _box: Option>, #[serde(rename = "Screenshot")] - pub screenshot: std::collections::HashMap, + pub screenshot: Option>, #[serde(rename = "Menu")] - pub menu: std::collections::HashMap, + pub menu: Option>, #[serde(rename = "Chapter")] - pub chapter: std::collections::HashMap, + pub chapter: Option>, #[serde(rename = "BoxRear")] - pub box_rear: std::collections::HashMap, + pub box_rear: Option>, #[serde(rename = "Profile")] - pub profile: std::collections::HashMap, + pub profile: Option>, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct BookInfo { @@ -1362,7 +1362,7 @@ pub struct DisplayPreferencesDto { pub primary_image_width: i32, /// Gets or sets the custom prefs. #[serde(rename = "CustomPrefs")] - pub custom_prefs: std::collections::HashMap>, + pub custom_prefs: Option>>, /// Gets or sets the scroll direction. #[serde(rename = "ScrollDirection")] pub scroll_direction: ScrollDirection, @@ -1640,7 +1640,7 @@ pub struct GeneralCommand { #[serde(rename = "ControllingUserId")] pub controlling_user_id: uuid::Uuid, #[serde(rename = "Arguments")] - pub arguments: std::collections::HashMap>, + pub arguments: Option>>, } /// General command websocket message. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] diff --git a/api/src/lib.rs b/api/src/lib.rs index 98cf325..73cdde1 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -24,7 +24,7 @@ type Result = std::result::Result; pub struct JellyfinClient { client: reqwest::Client, access_token: Option>, - config: Arc, + pub config: Arc, } impl JellyfinClient { @@ -56,7 +56,7 @@ impl JellyfinClient { Ok(token) } - pub async fn set_token(&mut self, token: impl AsRef) { + pub fn set_token(&mut self, token: impl AsRef) { self.access_token = Some(token.as_ref().into()); } @@ -143,10 +143,11 @@ impl JellyfinClient { .await .inspect_err(|err| tracing::warn!("Failed to load cached token: {}", err)) { - self.authenticate().await?; - self.save_token(path).await?; + tracing::info!("Authenticating with cached token from {:?}", path); + self.access_token = Some(token.clone().into()); Ok(token) } else { + tracing::info!("No cached token found at {:?}, authenticating...", path); let token = self .authenticate() .await? diff --git a/src/main.rs b/src/main.rs index 6013596..f104cfd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,29 +3,22 @@ use api::{JellyfinClient, JellyfinConfig}; use errors::*; fn jellyfin_config_try() -> Result { - dotenvy::dotenv() + let file = std::fs::read("config.toml").change_context(Error)?; + let config: JellyfinConfig = toml::from_slice(&file) .change_context(Error) - .inspect_err(|err| { - eprintln!("Failed to load .env file: {}", err); - }) - .ok(); - let config = JellyfinConfig::new( - std::env::var("JELLYFIN_USERNAME").change_context(Error)?, - std::env::var("JELLYFIN_PASSWORD").change_context(Error)?, - std::env::var("JELLYFIN_SERVER_URL").change_context(Error)?, - "jello".to_string(), - ); + .attach("Failed to parse Jellyfin Config")?; Ok(config) } fn jellyfin_config() -> JellyfinConfig { jellyfin_config_try().unwrap_or_else(|err| { - eprintln!("Error loading Jellyfin configuration: {}", err); + eprintln!("Error loading Jellyfin configuration: {:?}", err); std::process::exit(1); }) } fn main() -> Result<()> { + tracing_subscriber::fmt::init(); ui_iced::ui(jellyfin_config).change_context(Error)?; Ok(()) } diff --git a/typegen/src/main.rs b/typegen/src/main.rs index f219110..4e4bace 100644 --- a/typegen/src/main.rs +++ b/typegen/src/main.rs @@ -112,6 +112,8 @@ impl Property { }; if let Some(true) = self.nullable { format!("Option<{}>", out) + } else if self.nullable.is_none() && self._type == Some(Types::Object) { + format!("Option<{}>", out) } else { out } diff --git a/ui-iced/Cargo.toml b/ui-iced/Cargo.toml index 2cd604e..ffaa639 100644 --- a/ui-iced/Cargo.toml +++ b/ui-iced/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] api = { version = "0.1.0", path = "../api" } +blurhash = "0.2.3" gpui_util = "0.2.2" iced = { git = "https://github.com/iced-rs/iced", features = [ "advanced", diff --git a/ui-iced/src/blur_hash.rs b/ui-iced/src/blur_hash.rs new file mode 100644 index 0000000..86d75f3 --- /dev/null +++ b/ui-iced/src/blur_hash.rs @@ -0,0 +1,119 @@ +use std::sync::{Arc, LazyLock, atomic::AtomicBool}; + +use iced::{Element, advanced::Widget, widget::Image}; + +use crate::shared_string::SharedString; + +#[derive(Clone)] +pub struct BlurHash { + hash: SharedString, + handle: Arc, + width: u32, + height: u32, + punch: f32, +} + +impl BlurHash { + pub fn recompute(&mut self) { + let pixels = blurhash::decode(&self.hash, self.width, self.height, self.punch) + .unwrap_or_else(|_| vec![0; (self.width * self.height * 4) as usize]); + let handle = iced::advanced::image::Handle::from_rgba(self.width, self.height, pixels); + self.handle = Arc::new(handle); + } + + pub fn new(hash: impl AsRef) -> Self { + let hash = SharedString::from(hash.as_ref().to_string()); + let pixels = blurhash::decode(&hash, 32, 32, 1.0).unwrap_or_else(|_| vec![0; 32 * 32 * 4]); + let handle = iced::advanced::image::Handle::from_rgba(32, 32, pixels); + let handle = Arc::new(handle); + BlurHash { + hash, + handle, + width: 32, + height: 32, + punch: 1.0, + } + } + + pub fn width(mut self, height: u32) -> Self { + self.width = height; + self.recompute(); + self + } + + pub fn height(mut self, height: u32) -> Self { + self.height = height; + self.recompute(); + self + } + + pub fn punch(mut self, punch: f32) -> Self { + self.punch = punch; + self.recompute(); + self + } +} + +impl Widget for BlurHash +where + Renderer: iced::advanced::image::Renderer, +{ + fn size(&self) -> iced::Size { + iced::Size { + width: iced::Length::Fixed(self.width as f32), + height: iced::Length::Fixed(self.height as f32), + } + } + + fn layout( + &mut self, + _tree: &mut iced::advanced::widget::Tree, + renderer: &Renderer, + limits: &iced::advanced::layout::Limits, + ) -> iced::advanced::layout::Node { + iced::widget::image::layout( + renderer, + limits, + &self.handle, + self.width.into(), + self.height.into(), + None, + iced::ContentFit::default(), + iced::Rotation::default(), + false, + ) + } + + fn draw( + &self, + _state: &iced::advanced::widget::Tree, + renderer: &mut Renderer, + _theme: &Theme, + _style: &iced::advanced::renderer::Style, + layout: iced::advanced::Layout<'_>, + _cursor: iced::advanced::mouse::Cursor, + _viewport: &iced::Rectangle, + ) { + iced::widget::image::draw( + renderer, + layout, + &self.handle, + None, + iced::border::Radius::default(), + iced::ContentFit::default(), + iced::widget::image::FilterMethod::default(), + iced::Rotation::default(), + 1.0, + 1.0, + ); + } +} + +impl<'a, Message, Theme, Renderer> From for iced::Element<'a, Message, Theme, Renderer> +where + Renderer: iced::advanced::image::Renderer, +{ + fn from(blur_hash: BlurHash) -> Element<'a, Message, Theme, Renderer> { + iced::Element::new(blur_hash) + } +} diff --git a/ui-iced/src/lib.rs b/ui-iced/src/lib.rs index ea01193..7460254 100644 --- a/ui-iced/src/lib.rs +++ b/ui-iced/src/lib.rs @@ -1,8 +1,11 @@ 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; +use std::collections::{BTreeMap, BTreeSet}; #[derive(Debug, Clone)] pub struct Loading { @@ -13,20 +16,41 @@ pub struct Loading { #[derive(Default, Debug, Clone)] pub struct ItemCache { pub items: BTreeMap, + pub tree: BTreeMap, BTreeSet>, } impl ItemCache { - pub fn insert(&mut self, item: Item) { + 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, items: I) { - self.items - .extend(items.into_iter().map(|item| (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 Image { +pub struct Thumbnail { pub id: SharedString, pub blur_hash: Option, } @@ -36,15 +60,16 @@ impl From for Item { Item { id: dto.id, name: dto.name.map(Into::into), - picture: dto + parent_id: dto.parent_id, + thumbnail: dto .image_tags .and_then(|tags| tags.get("Primary").cloned()) - .map(|tag| Image { + .map(|tag| Thumbnail { id: tag.clone().into(), blur_hash: dto .image_blur_hashes .primary - .get(&tag) + .and_then(|hashes| hashes.get(&tag).cloned()) .map(|s| s.clone().into()), }), } @@ -54,8 +79,9 @@ impl From for Item { #[derive(Clone, Debug)] pub struct Item { pub id: uuid::Uuid, + pub parent_id: Option, pub name: Option, - pub picture: Option, + pub thumbnail: Option, } #[derive(Debug, Clone, Default)] @@ -71,6 +97,7 @@ struct State { current: Option, cache: ItemCache, jellyfin_client: api::JellyfinClient, + messages: Vec, } impl State { @@ -80,6 +107,7 @@ impl State { current: None, cache: ItemCache::default(), jellyfin_client, + messages: Vec::new(), } } } @@ -88,8 +116,8 @@ impl State { pub enum Message { OpenSettings, Refresh, - OpenItem(uuid::Uuid), - LoadedItem(uuid::Uuid, Vec), + OpenItem(Option), + LoadedItem(Option, Vec), Error(String), SetToken(String), } @@ -117,19 +145,35 @@ fn update(state: &mut State, message: Message) -> Task { ) } Message::LoadedItem(id, items) => { - state.cache.extend(items); - state.current = Some(id); + state.cache.extend(id, items); + state.current = id; Task::none() } Message::Refresh => { // Handle refresh logic - Task::none() + 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() } @@ -137,22 +181,25 @@ fn update(state: &mut State, message: Message) -> Task { } fn view(state: &State) -> Element<'_, Message> { - column([header(), body(state)]).into() + column([header(state), body(state), footer(state)]).into() } fn body(state: &State) -> Element<'_, Message> { - container(Text::new("Home Screen")) - .align_x(Alignment::Center) - .align_y(Alignment::Center) - .height(Length::Fill) - .width(Length::Fill) - .into() + container( + Grid::with_children(state.cache.items_of(state.current).into_iter().map(card)).spacing(70), + ) + .padding(70) + .align_x(Alignment::Center) + // .align_y(Alignment::Center) + .height(Length::Fill) + .width(Length::Fill) + .into() } -fn header() -> Element<'static, Message> { +fn header(state: &State) -> Element<'_, Message> { row([ container( - Text::new("Jello") + Text::new(state.jellyfin_client.config.server_url.as_str()) .width(Length::Fill) .align_x(Alignment::Start), ) @@ -184,6 +231,24 @@ fn header() -> Element<'static, Message> { .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 @@ -192,15 +257,23 @@ fn card(item: &Item) -> Element<'_, Message> { .unwrap_or("Unnamed Item"); container( column([ + BlurHash::new( + item.thumbnail + .as_ref() + .and_then(|t| t.blur_hash.as_ref()) + .map(|s| s.as_ref()) + .unwrap_or(""), + ) + .width(200) + .height(400) + .into(), Text::new(name).size(16).into(), - iced::widget::Image::new("placeholder.png") - .width(Length::Fill) - .height(150) - .into(), ]) - .spacing(10), + .align_x(Alignment::Center) + .width(Length::Fill) + .height(Length::Fill), ) - .padding(10) + .padding(70) .width(Length::FillPortion(5)) .height(Length::FillPortion(5)) .style(container::rounded_box)