Files
jello/ui-iced/src/lib.rs

500 lines
15 KiB
Rust

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<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()
}
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<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()),
}),
_type: dto._type,
}
}
}
#[derive(Clone, Debug)]
pub struct Item {
pub id: uuid::Uuid,
pub parent_id: Option<uuid::Uuid>,
pub name: Option<SharedString>,
pub thumbnail: Option<Thumbnail>,
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<String>,
pub device_id: Option<String>,
pub device_name: Option<String>,
pub client_name: Option<String>,
pub version: Option<String>,
}
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<Loading>,
current: Option<uuid::Uuid>,
cache: ItemCache,
jellyfin_client: Option<api::JellyfinClient>,
messages: Vec<String>,
history: Vec<Option<uuid::Uuid>>,
query: Option<String>,
screen: Screen,
settings: settings::SettingsState,
is_authenticated: bool,
video: Option<Arc<Video>>,
}
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<uuid::Uuid>),
LoadedItem(Option<uuid::Uuid>, Vec<Item>),
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<Message> {
// 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<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),
},
)
}
} 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<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),
},
)
} 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::<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");
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<Message>) {
// 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()
}