330 lines
9.3 KiB
Rust
330 lines
9.3 KiB
Rust
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<uuid::Uuid, Item>,
|
|
pub tree: BTreeMap<Option<uuid::Uuid>, BTreeSet<uuid::Uuid>>,
|
|
}
|
|
|
|
impl ItemCache {
|
|
pub fn insert(&mut self, parent: impl Into<Option<uuid::Uuid>>, item: Item) {
|
|
let parent = parent.into();
|
|
self.tree.entry(parent).or_default().insert(item.id);
|
|
self.items.insert(item.id, item);
|
|
}
|
|
|
|
pub fn extend<I: IntoIterator<Item = Item>>(
|
|
&mut self,
|
|
parent: impl Into<Option<uuid::Uuid>>,
|
|
items: I,
|
|
) {
|
|
let parent = parent.into();
|
|
items.into_iter().for_each(|item| {
|
|
self.insert(parent, item);
|
|
});
|
|
}
|
|
pub fn items_of(&self, parent: impl Into<Option<uuid::Uuid>>) -> 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::<Vec<&Item>>()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Thumbnail {
|
|
pub id: SharedString,
|
|
pub blur_hash: Option<SharedString>,
|
|
}
|
|
|
|
impl From<api::jellyfin::BaseItemDto> 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<uuid::Uuid>,
|
|
pub name: Option<SharedString>,
|
|
pub thumbnail: Option<Thumbnail>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub enum Screen {
|
|
#[default]
|
|
Home,
|
|
Settings,
|
|
Profile,
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
struct State {
|
|
loading: Option<Loading>,
|
|
current: Option<uuid::Uuid>,
|
|
cache: ItemCache,
|
|
jellyfin_client: api::JellyfinClient,
|
|
messages: Vec<String>,
|
|
history: Vec<Option<uuid::Uuid>>,
|
|
}
|
|
|
|
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<uuid::Uuid>),
|
|
LoadedItem(Option<uuid::Uuid>, Vec<Item>),
|
|
Error(String),
|
|
SetToken(String),
|
|
Back,
|
|
Home,
|
|
}
|
|
|
|
fn update(state: &mut State, message: Message) -> Task<Message> {
|
|
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<Vec<Item>, 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<Vec<Item>, 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::<Vec<Element<'_, Message>>>(),
|
|
)
|
|
.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<Message>) {
|
|
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()
|
|
}
|