500 lines
15 KiB
Rust
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()
|
|
}
|