Compare commits

...

7 Commits

10 changed files with 936 additions and 836 deletions

199
AGENTS.md Normal file
View File

@@ -0,0 +1,199 @@
# Agent Guidelines for Jello
This document provides guidelines for AI coding agents working on the Jello codebase.
## Project Overview
Jello is a WIP video client for Jellyfin written in Rust, focusing on HDR video playback using:
- **iced** - Primary GUI toolkit
- **gstreamer** - Video + audio decoding library
- **wgpu** - Rendering video from GStreamer in iced
## Build, Test, and Lint Commands
### Building
```bash
# Build in release mode
cargo build --release
cargo build -r
# Build specific workspace member
cargo build -p api
cargo build -p gst
cargo build -p ui-iced
# Run the application
cargo run --release -- -vv
just jello # Uses justfile
```
### Testing
```bash
# Run all tests in workspace
cargo test --workspace
# Run tests for a specific package
cargo test -p gst
cargo test -p api
cargo test -p iced-video
# Run a single test by name
cargo test test_appsink
cargo test -p gst test_appsink
# Run a specific test in a specific file
cargo test -p gst --test <test_file_name> <test_function_name>
# Run tests with output
cargo test -- --nocapture
cargo test -- --show-output
```
### Linting and Formatting
```bash
# Check code without building
cargo check
cargo check --workspace
# Run clippy (linter)
cargo clippy
cargo clippy --workspace
cargo clippy --workspace -- -D warnings
# Format code
cargo fmt
cargo fmt --all
# Check formatting without modifying files
cargo fmt --all -- --check
```
### Other Tools
```bash
# Check for security vulnerabilities and license compliance
cargo deny check
# Generate Jellyfin type definitions
just typegen
```
## Code Style Guidelines
### Rust Edition
- Use **Rust 2024 edition** (as specified in Cargo.toml files)
### Imports
- Use `use` statements at the top of files
- Group imports: std library, external crates, then local modules
- Use `crate::` for absolute paths within the crate
- Common pattern: create a `priv_prelude` module for internal imports
- Use `pub use` to re-export commonly used items
- Use wildcard imports (`use crate::priv_prelude::*;`) within internal modules when a prelude exists
Example:
```rust
use std::sync::Arc;
use reqwest::{Method, header::InvalidHeaderValue};
use serde::{Deserialize, Serialize};
use crate::errors::*;
```
### Naming Conventions
- **Types/Structs/Enums**: PascalCase (e.g., `JellyfinClient`, `Error`, `AppSink`)
- **Functions/Methods**: snake_case (e.g., `request_builder`, `stream_url`)
- **Variables**: snake_case (e.g., `access_token`, `device_id`)
- **Constants**: SCREAMING_SNAKE_CASE (e.g., `NEXT_ID`, `GST`)
- **Modules**: snake_case (e.g., `priv_prelude`, `error_stack`)
### Error Handling
- Use **`error-stack`** for error handling with context propagation
- Use **`thiserror`** for defining error types
- Standard error type pattern:
```rust
pub use error_stack::{Report, ResultExt};
#[derive(Debug, thiserror::Error)]
#[error("An error occurred")]
pub struct Error;
pub type Result<T, E = error_stack::Report<Error>> = core::result::Result<T, E>;
```
- Attach context to errors using `.change_context(Error)` and `.attach("description")`
- Use `#[track_caller]` on functions that may panic or error for better error messages
- Error handling example:
```rust
self.inner
.set_state(gstreamer::State::Playing)
.change_context(Error)
.attach("Failed to set pipeline to Playing state")?;
```
### Types
- Prefer explicit types over type inference when it improves clarity
- Use `impl Trait` for function parameters when appropriate (e.g., `impl AsRef<str>`)
- Use `Option<T>` and `Result<T, E>` idiomatically
- Use `Arc<T>` for shared ownership
- Use newtype patterns for semantic clarity (e.g., `ApiKey` wrapping `secrecy::SecretBox<String>`)
### Formatting
- Use 4 spaces for indentation
- Line length: aim for 100 characters, but not strictly enforced
- Use trailing commas in multi-line collections
- Follow standard Rust formatting conventions (enforced by `cargo fmt`)
### Documentation
- Add doc comments (`///`) for public APIs
- Use inline comments (`//`) sparingly, prefer self-documenting code
- Include examples in doc comments when helpful
### Async/Await
- Use `tokio` as the async runtime
- Mark async functions with `async` keyword
- Use `.await` for async operations
- Common pattern: `tokio::fs` for file operations
### Module Structure
- Use `mod.rs` or inline modules as appropriate
- Keep related functionality together
- Use `pub(crate)` for internal APIs
- Re-export commonly used items at crate root
### Macros
- Custom macros used: `wrap_gst!`, `parent_child!`
- Use macros for reducing boilerplate, only in the `gst` crate
### Testing
- Place tests in the same file with `#[test]` or `#[cfg(test)]`
- Use descriptive test function names (e.g., `test_appsink`, `unique_generates_different_ids`)
- Initialize tracing in tests when needed for debugging
### Dependencies
- Prefer well-maintained crates from crates.io
- Use `workspace.dependencies` for shared dependencies across workspace members
- Pin versions when stability is important
### Workspace Structure
The project uses a Cargo workspace with multiple members:
- `.` - Main jello binary
- `api` - Jellyfin API client
- `gst` - GStreamer wrapper
- `ui-iced` - Iced UI implementation
- `ui-gpui` - GPUI UI implementation (optional)
- `store` - Secret/data/storage management
- `jello-types` - Shared type definitions
- `typegen` - Jellyfin type generator
- `crates/iced-video` - Custom iced video widget
- `examples/hdr-gstreamer-wgpu` - HDR example
### Project-Specific Patterns
- Use `LazyLock` for global initialization (e.g., GStreamer init)
- Use the builder pattern with method chaining (e.g., `request_builder()`)
- Use `tap` crate's `.pipe()` for functional transformations
- Prefer `BTreeMap`/`BTreeSet` over `HashMap`/`HashSet` when order matters
- Prefer a functional programming style instead of an imperative one.
- When building UIs keep the handler and view code in the same module (eg. settings view and settings handle in the same file)
## License
All code in this project is MIT licensed.

1000
Cargo.lock generated

File diff suppressed because it is too large Load Diff

35
flake.lock generated
View File

@@ -3,11 +3,11 @@
"advisory-db": { "advisory-db": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1766435619, "lastModified": 1768679419,
"narHash": "sha256-3A5Z5K28YB45REOHMWtyQ24cEUXW76MOtbT6abPrARE=", "narHash": "sha256-l9rM4lXBeS2mIAJsJjVfl0UABx3S3zg5tul7bv+bn50=",
"owner": "rustsec", "owner": "rustsec",
"repo": "advisory-db", "repo": "advisory-db",
"rev": "a98dbc80b16730a64c612c6ab5d5fecb4ebb79ba", "rev": "c700e1cd023ca87343cbd9217d50d47023e9adc7",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -18,11 +18,11 @@
}, },
"crane": { "crane": {
"locked": { "locked": {
"lastModified": 1766194365, "lastModified": 1768873933,
"narHash": "sha256-4AFsUZ0kl6MXSm4BaQgItD0VGlEKR3iq7gIaL7TjBvc=", "narHash": "sha256-CfyzdaeLNGkyAHp3kT5vjvXhA1pVVK7nyDziYxCPsNk=",
"owner": "ipetkov", "owner": "ipetkov",
"repo": "crane", "repo": "crane",
"rev": "7d8ec2c71771937ab99790b45e6d9b93d15d9379", "rev": "0bda7e7d005ccb5522a76d11ccfbf562b71953ca",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -34,10 +34,10 @@
"crates-io-index": { "crates-io-index": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1763363725, "lastModified": 1769614137,
"narHash": "sha256-cxr5xIKZFP45yV1ZHFTB1sHo5YGiR3FA8D9vAfDizMo=", "narHash": "sha256-3Td8fiv6iFVxeS0hYq3xdd10ZvUkC9INMAiQx/mECas=",
"ref": "refs/heads/master", "ref": "refs/heads/master",
"rev": "0382002e816a4cbd17d8d5b172f08b848aa22ff6", "rev": "c7e7d6394bc95555d6acd5c6783855f47d64c90d",
"shallow": true, "shallow": true,
"type": "git", "type": "git",
"url": "https://github.com/rust-lang/crates.io-index" "url": "https://github.com/rust-lang/crates.io-index"
@@ -50,7 +50,9 @@
}, },
"crates-nix": { "crates-nix": {
"inputs": { "inputs": {
"crates-io-index": "crates-io-index" "crates-io-index": [
"crates-io-index"
]
}, },
"locked": { "locked": {
"lastModified": 1763364255, "lastModified": 1763364255,
@@ -106,11 +108,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1766309749, "lastModified": 1768564909,
"narHash": "sha256-3xY8CZ4rSnQ0NqGhMKAy5vgC+2IVK0NoVEzDoOh4DA4=", "narHash": "sha256-Kell/SpJYVkHWMvnhqJz/8DqQg2b6PguxVWOuadbHCc=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "a6531044f6d0bef691ea18d4d4ce44d0daa6e816", "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -124,6 +126,7 @@
"inputs": { "inputs": {
"advisory-db": "advisory-db", "advisory-db": "advisory-db",
"crane": "crane", "crane": "crane",
"crates-io-index": "crates-io-index",
"crates-nix": "crates-nix", "crates-nix": "crates-nix",
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"nix-github-actions": "nix-github-actions", "nix-github-actions": "nix-github-actions",
@@ -138,11 +141,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1766371695, "lastModified": 1768877311,
"narHash": "sha256-W7CX9vy7H2Jj3E8NI4djHyF8iHSxKpb2c/7uNQ/vGFU=", "narHash": "sha256-abSDl0cNr0B+YCsIDpO1SjXD9JMxE4s8EFnhLEFVovI=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "d81285ba8199b00dc31847258cae3c655b605e8c", "rev": "59e4ab96304585fde3890025fd59bd2717985cc1",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -9,7 +9,14 @@
url = "github:nix-community/nix-github-actions"; url = "github:nix-community/nix-github-actions";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
crates-nix.url = "github:uttarayan21/crates.nix"; crates-io-index = {
url = "git+https://github.com/rust-lang/crates.io-index?shallow=1";
flake = false;
};
crates-nix = {
url = "github:uttarayan21/crates.nix";
inputs.crates-io-index.follows = "crates-io-index";
};
rust-overlay = { rust-overlay = {
url = "github:oxalica/rust-overlay"; url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
@@ -179,28 +186,38 @@
devShells = rec { devShells = rec {
rust-shell = rust-shell =
pkgs.mkShell.override { pkgs.mkShell.override {
stdenv = stdenv = pkgs.clangStdenv;
if pkgs.stdenv.isLinux # if pkgs.stdenv.isLinux
then (pkgs.stdenvAdapters.useMoldLinker pkgs.clangStdenv) # then (pkgs.stdenvAdapters.useMoldLinker pkgs.clangStdenv)
else pkgs.clangStdenv; # else pkgs.clangStdenv;
} (commonArgs }
(commonArgs
// { // {
# GST_PLUGIN_PATH = "/run/current-system/sw/lib/gstreamer-1.0/"; # GST_PLUGIN_PATH = "/run/current-system/sw/lib/gstreamer-1.0/";
GIO_EXTRA_MODULES = "${pkgs.glib-networking}/lib/gio/modules"; GIO_EXTRA_MODULES = "${pkgs.glib-networking}/lib/gio/modules";
packages = with pkgs; packages = with pkgs;
[ [
toolchainWithRustAnalyzer toolchainWithRustAnalyzer
cargo-nextest bacon
cargo-audit
cargo-deny cargo-deny
cargo-expand cargo-expand
bacon
cargo-make
cargo-hack cargo-hack
cargo-make
cargo-nextest
cargo-outdated cargo-outdated
lld lld
lldb lldb
cargo-audit
(crates.buildCrate "cargo-with" {doCheck = false;}) (crates.buildCrate "cargo-with" {doCheck = false;})
(crates.buildCrate "dioxus-cli" {
nativeBuildInputs = with pkgs; [pkg-config];
buildInputs = [openssl];
doCheck = false;
})
(crates.buildCrate "cargo-hot" {
nativeBuildInputs = with pkgs; [pkg-config];
buildInputs = [openssl];
})
] ]
++ (lib.optionals pkgs.stdenv.isDarwin [ ++ (lib.optionals pkgs.stdenv.isDarwin [
apple-sdk_26 apple-sdk_26
@@ -211,7 +228,7 @@
samply samply
cargo-flamegraph cargo-flamegraph
perf perf
mold # mold
]); ]);
}); });
default = rust-shell; default = rust-shell;

View File

@@ -4,10 +4,9 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
bson = { version = "3.1.0", features = ["serde"] }
futures = "0.3.31" futures = "0.3.31"
parking_lot = "0.12.5" parking_lot = "0.12.5"
redb = { version = "3.1.0", features = ["uuid"] } secrecy = "0.10.3"
serde = "1.0.228" serde = "1.0.228"
tokio = { version = "1.48.0", features = ["rt"] } tokio = { version = "1.48.0", features = ["rt"] }
uuid = "1.18.1" uuid = { version = "1.18.1", features = ["v4"] }

View File

@@ -1,10 +1,10 @@
pub mod redb; use std::collections::BTreeMap;
pub mod sqlite;
pub mod toml;
pub trait Store { use uuid::Uuid;
fn image(&self, id: &str) -> Option<Vec<u8>>;
fn save_image(&mut self, id: &str, data: &[u8]); pub struct ApiKey {
inner: secrecy::SecretBox<String>,
}
pub struct SecretStore {
api_keys: BTreeMap<Uuid, ApiKey>,
} }
pub struct Settings {}

View File

@@ -1,225 +0,0 @@
use std::{
borrow::Borrow,
collections::VecDeque,
marker::PhantomData,
path::Path,
sync::{Arc, RwLock, atomic::AtomicBool},
};
use futures::task::AtomicWaker;
use redb::{Error, Key, ReadableDatabase, TableDefinition, Value};
use serde::{Serialize, de::DeserializeOwned};
const USERS: TableDefinition<uuid::Uuid, Vec<u8>> = TableDefinition::new("users");
const SERVERS: TableDefinition<uuid::Uuid, Vec<u8>> = TableDefinition::new("servers");
const SETTINGS: TableDefinition<uuid::Uuid, Vec<u8>> = TableDefinition::new("settings");
#[derive(Debug)]
pub struct TableInner<T> {
db: Arc<T>,
}
impl<T> Clone for TableInner<T> {
fn clone(&self) -> Self {
Self {
db: Arc::clone(&self.db),
}
}
}
impl<T> TableInner<T> {
fn new(db: Arc<T>) -> Self {
Self { db }
}
}
impl TableInner<DatabaseHandle> {
async fn get<'a, K: Key, V: Serialize + DeserializeOwned>(
&self,
table: TableDefinition<'static, K, Vec<u8>>,
key: impl Borrow<K::SelfType<'a>>,
) -> Result<Option<V>> {
let db: &redb::Database = &self.db.as_ref().database;
let db_reader = db.begin_read()?;
let table = db_reader.open_table(table)?;
table
.get(key)?
.map(|value| bson::deserialize_from_slice(&value.value()))
.transpose()
.map_err(|e| redb::Error::Io(std::io::Error::other(e)))
}
async fn insert<
'a,
'b,
K: Key + Send + Sync,
V: Serialize + DeserializeOwned + Send + Sync + 'a,
>(
&'b self,
table: TableDefinition<'static, K, Vec<u8>>,
key: impl Borrow<K::SelfType<'a>> + Send + 'b,
value: V,
) -> Result<Option<V>> {
let db: &redb::Database = &self.db.as_ref().database;
// self.db
// .writing
// .store(true, std::sync::atomic::Ordering::SeqCst);
// let out = tokio::task::spawn_blocking(move || -> Result<Option<V>>
let out = tokio::task::spawn_blocking(|| -> Result<Option<V>> {
let db_writer = db.begin_write()?;
let out = {
let mut table = db_writer.open_table(table)?;
let serialized_value = bson::serialize_to_vec(&value)
.map_err(|e| redb::Error::Io(std::io::Error::other(e)))?;
let previous = table.insert(key, &serialized_value)?;
let out = previous
.map(|value| bson::deserialize_from_slice(&value.value()))
.transpose()
.map_err(|e| redb::Error::Io(std::io::Error::other(e)));
out
};
db_writer.commit()?;
out
})
.await
.expect("Task panicked");
out
}
}
// impl<K: Key, V: Serialize + DeserializeOwned> Table<K, V> for TableInner {
// async fn get(&self, key: K) -> Result<Option<Value>> {}
// async fn insert(&self, key: K, value: V) -> Result<Option<Value>> {}
// async fn modify(&self, key: K, v: FnOnce(V) -> V) -> Result<bool> {}
// async fn remove(&self, key: K) -> Result<Option<Value>> {}
// }
#[derive(Debug)]
pub struct Users<T>(TableInner<T>);
impl<T> Clone for Users<T> {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
impl<T> Users<T> {
const TABLE: TableDefinition<'static, uuid::Uuid, Vec<u8>> = USERS;
}
#[derive(Debug)]
pub struct Servers<T>(TableInner<T>);
impl<T> Clone for Servers<T> {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
impl<T> Servers<T> {
const TABLE: TableDefinition<'static, uuid::Uuid, Vec<u8>> = SERVERS;
}
#[derive(Debug)]
pub struct Settings<T>(TableInner<T>);
impl<T> Clone for Settings<T> {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
impl<T> Settings<T> {
const TABLE: TableDefinition<'static, uuid::Uuid, Vec<u8>> = SETTINGS;
}
#[derive(Debug, Clone)]
pub struct Database {
users: Users<DatabaseHandle>,
servers: Servers<DatabaseHandle>,
settings: Settings<DatabaseHandle>,
handle: Arc<DatabaseHandle>,
}
#[derive(Debug)]
pub struct DatabaseHandle {
database: redb::Database,
writing: AtomicBool,
wakers: RwLock<VecDeque<AtomicWaker>>,
}
#[derive(Debug)]
pub struct DatabaseWriterGuard<'a> {
handle: &'a DatabaseHandle,
dropper: Arc<AtomicBool>,
}
// impl Drop for DatabaseWriterGuard<'_> {
// fn drop(&mut self) {
// self.handle
// .writing
// .store(false, std::sync::atomic::Ordering::SeqCst);
// let is_panicking = std::thread::panicking();
// let Ok(writer) = self.handle.wakers.write() else {
// if is_panicking {
// return;
// } else {
// panic!("Wakers lock poisoned");
// }
// }
// if let Some(waker) = (self.handle.wakers.write()).pop() {
// waker.wake();
// };
// // let mut wakers = self.handle.wakers.write().expect();
// // if let Some(waker) = self.handle.wakers.write().expect("Wakers lock poisoned").pop_front() {
// // waker.wake();
// // }
// // while let Some(waker) = wakers.pop_front() {
// // waker.wake();
// // }
// }
// }
type Result<O, E = redb::Error> = core::result::Result<O, E>;
pub trait Table<K: Key> {
fn insert<V: Serialize + DeserializeOwned>(
&self,
key: K,
value: V,
) -> impl Future<Output = Result<Option<V>>> + Send;
fn modify<V: Serialize + DeserializeOwned, O: Serialize + DeserializeOwned>(
&self,
key: K,
v: impl FnOnce(V) -> O,
) -> impl Future<Output = Result<bool>> + Send;
fn remove<V: Serialize + DeserializeOwned>(
&self,
key: K,
) -> impl Future<Output = Result<Option<V>>> + Send;
fn get<V: Serialize + DeserializeOwned>(
&self,
key: K,
) -> impl Future<Output = Result<Option<V>>> + Send;
}
impl Database {
pub fn create(path: impl AsRef<Path>) -> Result<Self, Error> {
let writing = AtomicBool::new(false);
let wakers = RwLock::new(VecDeque::new());
let db = redb::Database::create(path)?;
let db = Arc::new(DatabaseHandle {
database: db,
writing,
wakers,
});
let table_inner = TableInner::new(Arc::clone(&db));
let users = Users(table_inner.clone());
let servers = Servers(table_inner.clone());
let settings = Settings(table_inner.clone());
Ok(Self {
servers,
users,
settings,
handle: db,
})
}
}

View File

@@ -21,9 +21,10 @@ iced = { workspace = true, features = [
iced-video = { workspace = true } iced-video = { workspace = true }
iced_aw = "0.13.0"
iced_wgpu = "0.14.0" iced_wgpu = "0.14.0"
iced_winit = "0.14.0" iced_winit = "0.14.0"
reqwest = "0.12.24" reqwest = "0.13"
tap = "1.0.1" tap = "1.0.1"
toml = "0.9.8" toml = "0.9.8"
tracing = "0.1.41" tracing = "0.1.41"

View File

@@ -4,6 +4,7 @@ mod video;
mod shared_string; mod shared_string;
use iced_video::{Ready, Video, VideoHandle}; use iced_video::{Ready, Video, VideoHandle};
use shared_string::SharedString; use shared_string::SharedString;
use tap::Pipe as _;
use std::sync::Arc; use std::sync::Arc;
@@ -25,6 +26,8 @@ pub struct ItemCache {
pub tree: BTreeMap<Option<uuid::Uuid>, BTreeSet<uuid::Uuid>>, pub tree: BTreeMap<Option<uuid::Uuid>, BTreeSet<uuid::Uuid>>,
} }
const BACKGROUND_COLOR: iced::Color = iced::Color::from_rgba8(30, 30, 30, 0.7);
impl ItemCache { impl ItemCache {
pub fn insert(&mut self, parent: impl Into<Option<uuid::Uuid>>, item: Item) { pub fn insert(&mut self, parent: impl Into<Option<uuid::Uuid>>, item: Item) {
let parent = parent.into(); let parent = parent.into();
@@ -155,8 +158,6 @@ impl State {
query: None, query: None,
screen: Screen::Home, screen: Screen::Home,
settings: settings::SettingsState::default(), settings: settings::SettingsState::default(),
// username_input: String::new(),
// password_input: String::new(),
is_authenticated: false, is_authenticated: false,
video: None, video: None,
} }
@@ -172,17 +173,8 @@ pub enum Message {
OpenItem(Option<uuid::Uuid>), OpenItem(Option<uuid::Uuid>),
LoadedItem(Option<uuid::Uuid>, Vec<Item>), LoadedItem(Option<uuid::Uuid>, Vec<Item>),
Error(String), Error(String),
SetToken(String),
Back, Back,
Home, Home,
// Login {
// username: String,
// password: String,
// config: api::JellyfinConfig,
// },
// LoginSuccess(String),
// LoadedClient(api::JellyfinClient, bool),
// Logout,
Video(video::VideoMessage), Video(video::VideoMessage),
} }
@@ -249,15 +241,6 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
state.messages.push(err); state.messages.push(err);
Task::none() 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 => { Message::Back => {
state.current = state.history.pop().unwrap_or(None); state.current = state.history.pop().unwrap_or(None);
Task::none() Task::none()
@@ -268,7 +251,6 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
} }
Message::SearchQueryChanged(query) => { Message::SearchQueryChanged(query) => {
state.query = Some(query); state.query = Some(query);
// Handle search query change
Task::none() Task::none()
} }
Message::Search => { Message::Search => {
@@ -293,9 +275,29 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
} }
fn view(state: &State) -> Element<'_, Message> { fn view(state: &State) -> Element<'_, Message> {
let content = home(state);
match state.screen { match state.screen {
Screen::Settings => settings::settings(state), Screen::Settings => {
Screen::Home | _ => home(state), let settings = settings::settings(state);
let settings = container(settings)
.width(Length::FillPortion(4))
.height(Length::FillPortion(4))
.style(container::rounded_box)
.pipe(mouse_area)
.on_press(Message::Refresh)
.pipe(|c| iced::widget::column![space::vertical(), c, space::vertical()])
.pipe(container)
.width(Length::Fill)
.width(Length::Fill)
.align_y(Alignment::Center)
.align_x(Alignment::Center)
.style(|_| container::background(BACKGROUND_COLOR))
.padding(50)
.pipe(mouse_area)
.on_press(Message::Settings(settings::SettingsMessage::Close));
stack![content, settings].into()
}
Screen::Home | _ => content,
} }
} }
@@ -310,18 +312,16 @@ fn body(state: &State) -> Element<'_, Message> {
if let Some(ref video) = state.video { if let Some(ref video) = state.video {
video::player(video) video::player(video)
} else { } else {
scrollable(
container(
Grid::with_children(state.cache.items_of(state.current).into_iter().map(card)) Grid::with_children(state.cache.items_of(state.current).into_iter().map(card))
.fluid(400) .fluid(400)
.spacing(50), .spacing(50)
) .pipe(container)
.padding(50) .padding(50)
.align_x(Alignment::Center) .align_x(Alignment::Center)
// .align_y(Alignment::Center) // .align_y(Alignment::Center)
.height(Length::Fill) .height(Length::Fill)
.width(Length::Fill), .width(Length::Fill)
) .pipe(scrollable)
.height(Length::Fill) .height(Length::Fill)
.into() .into()
} }
@@ -329,19 +329,17 @@ fn body(state: &State) -> Element<'_, Message> {
fn header(state: &State) -> Element<'_, Message> { fn header(state: &State) -> Element<'_, Message> {
row([ row([
container( text(
Button::new(
Text::new(
state state
.jellyfin_client .jellyfin_client
.as_ref() .as_ref()
.map(|c| c.config.server_url.as_str()) .map(|c| c.config.server_url.as_str())
.unwrap_or("No Server"), .unwrap_or("No Server"),
) )
.align_x(Alignment::Start), .align_x(Alignment::Start)
) .pipe(button)
.on_press(Message::Home), .on_press(Message::Home)
) .pipe(container)
.padding(10) .padding(10)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
@@ -350,7 +348,6 @@ fn header(state: &State) -> Element<'_, Message> {
.style(container::rounded_box) .style(container::rounded_box)
.into(), .into(),
search(state), search(state),
container(
row([ row([
button("Refresh").on_press(Message::Refresh).into(), button("Refresh").on_press(Message::Refresh).into(),
button("Settings") button("Settings")
@@ -360,8 +357,8 @@ fn header(state: &State) -> Element<'_, Message> {
.on_press(Message::Video(video::VideoMessage::Test)) .on_press(Message::Video(video::VideoMessage::Test))
.into(), .into(),
]) ])
.spacing(10), .spacing(10)
) .pipe(container)
.padding(10) .padding(10)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
@@ -377,14 +374,13 @@ fn header(state: &State) -> Element<'_, Message> {
} }
fn search(state: &State) -> Element<'_, Message> { fn search(state: &State) -> Element<'_, Message> {
container(
TextInput::new("Search...", state.query.as_deref().unwrap_or_default()) TextInput::new("Search...", state.query.as_deref().unwrap_or_default())
.padding(10) .padding(10)
.size(16) .size(16)
.width(Length::Fill) .width(Length::Fill)
.on_input(Message::SearchQueryChanged) .on_input(Message::SearchQueryChanged)
.on_submit(Message::Search), .on_submit(Message::Search)
) .pipe(container)
.padding(10) .padding(10)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Shrink) .height(Length::Shrink)

View File

@@ -66,7 +66,7 @@ pub enum ServerMessage {
Clear, Clear,
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum SettingsScreen { pub enum SettingsScreen {
#[default] #[default]
Main, Main,
@@ -195,9 +195,28 @@ impl ServerForm {
} }
mod screens { mod screens {
use iced_aw::Tabs;
use super::*; use super::*;
pub fn settings(state: &State) -> Element<'_, Message> { pub fn settings(state: &State) -> Element<'_, Message> {
row([settings_list(state), settings_screen(state)]).into() Tabs::new(|f| Message::Settings(SettingsMessage::Select(f)))
.push(
SettingsScreen::Main,
iced_aw::TabLabel::Text("General".into()),
main(state),
)
.push(
SettingsScreen::Servers,
iced_aw::TabLabel::Text("Servers".into()),
server(state),
)
.push(
SettingsScreen::Users,
iced_aw::TabLabel::Text("Users".into()),
user(state),
)
.set_active_tab(&state.settings.screen)
.into()
} }
pub fn settings_screen(state: &State) -> Element<'_, Message> { pub fn settings_screen(state: &State) -> Element<'_, Message> {
@@ -207,63 +226,64 @@ mod screens {
SettingsScreen::Users => user(state), SettingsScreen::Users => user(state),
}) })
.width(Length::FillPortion(10)) .width(Length::FillPortion(10))
.height(Length::Fill)
.style(|theme| container::background(theme.extended_palette().background.base.color))
.pipe(container)
.padding(10)
.style(|theme| container::background(theme.extended_palette().secondary.base.color))
.width(Length::FillPortion(10))
.into() .into()
} }
pub fn settings_list(state: &State) -> Element<'_, Message> { pub fn settings_list(state: &State) -> Element<'_, Message> {
scrollable(
column( column(
[ [
button(center_text("Main")).on_press(Message::Settings( button(center_text("General")).on_press(Message::Settings(
SettingsMessage::Select(SettingsScreen::Main), SettingsMessage::Select(SettingsScreen::Main),
)), )),
button(center_text("Servers")).on_press(Message::Settings( button(center_text("Servers")).on_press(Message::Settings(
SettingsMessage::Select(SettingsScreen::Servers), SettingsMessage::Select(SettingsScreen::Servers),
)), )),
button(center_text("Users")).on_press(Message::Settings( button(center_text("Users")).on_press(Message::Settings(SettingsMessage::Select(
SettingsMessage::Select(SettingsScreen::Users), SettingsScreen::Users,
)), ))),
] ]
.map(|p| p.clip(true).width(Length::Fill).into()), .map(|p| p.clip(true).width(Length::Fill).into()),
) )
.width(Length::FillPortion(2)) .width(Length::FillPortion(2))
.spacing(10) .spacing(10)
.padding(10), .padding(10)
) .pipe(scrollable)
.into() .into()
} }
pub fn main(state: &State) -> Element<'_, Message> { pub fn main(state: &State) -> Element<'_, Message> {
// placeholder for now
container(
Column::new() Column::new()
.push(text("Main Settings")) .push(text("Main Settings"))
.push(toggler(true).label("Foobar")) .push(toggler(true).label("HDR"))
.spacing(20) .spacing(20)
.padding(20), .padding(20)
) .pipe(container)
.into() .into()
} }
pub fn server(state: &State) -> Element<'_, Message> { pub fn server(state: &State) -> Element<'_, Message> {
container(
Column::new() Column::new()
.push(text("Server Settings")) .push(text("Server Settings"))
.push(state.settings.server_form.view()) .push(state.settings.server_form.view())
// .push(toggler(false).label("Enable Server"))
.spacing(20) .spacing(20)
.padding(20), .padding(20)
) .pipe(container)
.into() .into()
} }
pub fn user(state: &State) -> Element<'_, Message> { pub fn user(state: &State) -> Element<'_, Message> {
container(
Column::new() Column::new()
.push(text("User Settings")) .push(text("User Settings"))
.push(state.settings.login_form.view()) .push(state.settings.login_form.view())
// .push(userlist(&state))
.spacing(20) .spacing(20)
.padding(20), .padding(20)
) .pipe(container)
.into() .into()
} }
} }