Compare commits
7 Commits
c8c371230f
...
sloppy
| Author | SHA1 | Date | |
|---|---|---|---|
| f016c56ba6 | |||
| 4b5dea576f | |||
| e66c457b57 | |||
| 76fc14c73b | |||
| 5b4fbd5df6 | |||
| e7fd01c0af | |||
| a040478069 |
199
AGENTS.md
Normal file
199
AGENTS.md
Normal 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
1000
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -34,10 +34,10 @@ impl JellyfinClient {
|
||||
username: impl AsRef<str>,
|
||||
password: impl AsRef<str>,
|
||||
config: JellyfinConfig,
|
||||
) -> Result<(Self, jellyfin::AuthenticationResult)> {
|
||||
) -> Result<Self> {
|
||||
let url = format!("{}/Users/AuthenticateByName", config.server_url);
|
||||
let client = reqwest::Client::new();
|
||||
let auth_result = client
|
||||
let token = client
|
||||
.post(url)
|
||||
.json(&jellyfin::AuthenticateUserByName {
|
||||
username: Some(username.as_ref().to_string()),
|
||||
@@ -47,14 +47,10 @@ impl JellyfinClient {
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<jellyfin::AuthenticationResult>()
|
||||
.await?;
|
||||
|
||||
let token = auth_result
|
||||
.await?
|
||||
.access_token
|
||||
.as_ref()
|
||||
.ok_or_else(|| std::io::Error::other("No field access_token in auth response"))?;
|
||||
|
||||
Ok((Self::pre_authenticated(token, config)?, auth_result))
|
||||
Self::pre_authenticated(token, config)
|
||||
}
|
||||
|
||||
pub fn pre_authenticated(token: impl AsRef<str>, config: JellyfinConfig) -> Result<Self> {
|
||||
@@ -84,10 +80,6 @@ impl JellyfinClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn access_token(&self) -> Option<&str> {
|
||||
self.access_token.as_deref().map(|s| &*s)
|
||||
}
|
||||
|
||||
pub async fn save_token(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
|
||||
if let Some(token) = &self.access_token {
|
||||
tokio::fs::write(path, &**token).await
|
||||
@@ -112,19 +104,6 @@ impl JellyfinClient {
|
||||
self.access_token = Some(token.as_ref().into());
|
||||
}
|
||||
|
||||
pub async fn me(&self) -> Result<jellyfin::UserDto> {
|
||||
let uri = "Users/Me";
|
||||
let text = self
|
||||
.request_builder(reqwest::Method::GET, uri)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.text()
|
||||
.await?;
|
||||
let out: jellyfin::UserDto = serde_json::from_str(&text)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn request_builder(
|
||||
&self,
|
||||
method: reqwest::Method,
|
||||
|
||||
35
flake.lock
generated
35
flake.lock
generated
@@ -3,11 +3,11 @@
|
||||
"advisory-db": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1766435619,
|
||||
"narHash": "sha256-3A5Z5K28YB45REOHMWtyQ24cEUXW76MOtbT6abPrARE=",
|
||||
"lastModified": 1768679419,
|
||||
"narHash": "sha256-l9rM4lXBeS2mIAJsJjVfl0UABx3S3zg5tul7bv+bn50=",
|
||||
"owner": "rustsec",
|
||||
"repo": "advisory-db",
|
||||
"rev": "a98dbc80b16730a64c612c6ab5d5fecb4ebb79ba",
|
||||
"rev": "c700e1cd023ca87343cbd9217d50d47023e9adc7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -18,11 +18,11 @@
|
||||
},
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1766194365,
|
||||
"narHash": "sha256-4AFsUZ0kl6MXSm4BaQgItD0VGlEKR3iq7gIaL7TjBvc=",
|
||||
"lastModified": 1768873933,
|
||||
"narHash": "sha256-CfyzdaeLNGkyAHp3kT5vjvXhA1pVVK7nyDziYxCPsNk=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "7d8ec2c71771937ab99790b45e6d9b93d15d9379",
|
||||
"rev": "0bda7e7d005ccb5522a76d11ccfbf562b71953ca",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -34,10 +34,10 @@
|
||||
"crates-io-index": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1763363725,
|
||||
"narHash": "sha256-cxr5xIKZFP45yV1ZHFTB1sHo5YGiR3FA8D9vAfDizMo=",
|
||||
"lastModified": 1769614137,
|
||||
"narHash": "sha256-3Td8fiv6iFVxeS0hYq3xdd10ZvUkC9INMAiQx/mECas=",
|
||||
"ref": "refs/heads/master",
|
||||
"rev": "0382002e816a4cbd17d8d5b172f08b848aa22ff6",
|
||||
"rev": "c7e7d6394bc95555d6acd5c6783855f47d64c90d",
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/rust-lang/crates.io-index"
|
||||
@@ -50,7 +50,9 @@
|
||||
},
|
||||
"crates-nix": {
|
||||
"inputs": {
|
||||
"crates-io-index": "crates-io-index"
|
||||
"crates-io-index": [
|
||||
"crates-io-index"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1763364255,
|
||||
@@ -106,11 +108,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1766309749,
|
||||
"narHash": "sha256-3xY8CZ4rSnQ0NqGhMKAy5vgC+2IVK0NoVEzDoOh4DA4=",
|
||||
"lastModified": 1768564909,
|
||||
"narHash": "sha256-Kell/SpJYVkHWMvnhqJz/8DqQg2b6PguxVWOuadbHCc=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a6531044f6d0bef691ea18d4d4ce44d0daa6e816",
|
||||
"rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -124,6 +126,7 @@
|
||||
"inputs": {
|
||||
"advisory-db": "advisory-db",
|
||||
"crane": "crane",
|
||||
"crates-io-index": "crates-io-index",
|
||||
"crates-nix": "crates-nix",
|
||||
"flake-utils": "flake-utils",
|
||||
"nix-github-actions": "nix-github-actions",
|
||||
@@ -138,11 +141,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1766371695,
|
||||
"narHash": "sha256-W7CX9vy7H2Jj3E8NI4djHyF8iHSxKpb2c/7uNQ/vGFU=",
|
||||
"lastModified": 1768877311,
|
||||
"narHash": "sha256-abSDl0cNr0B+YCsIDpO1SjXD9JMxE4s8EFnhLEFVovI=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "d81285ba8199b00dc31847258cae3c655b605e8c",
|
||||
"rev": "59e4ab96304585fde3890025fd59bd2717985cc1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
39
flake.nix
39
flake.nix
@@ -9,7 +9,14 @@
|
||||
url = "github:nix-community/nix-github-actions";
|
||||
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 = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
@@ -179,28 +186,38 @@
|
||||
devShells = rec {
|
||||
rust-shell =
|
||||
pkgs.mkShell.override {
|
||||
stdenv =
|
||||
if pkgs.stdenv.isLinux
|
||||
then (pkgs.stdenvAdapters.useMoldLinker pkgs.clangStdenv)
|
||||
else pkgs.clangStdenv;
|
||||
} (commonArgs
|
||||
stdenv = pkgs.clangStdenv;
|
||||
# if pkgs.stdenv.isLinux
|
||||
# then (pkgs.stdenvAdapters.useMoldLinker pkgs.clangStdenv)
|
||||
# else pkgs.clangStdenv;
|
||||
}
|
||||
(commonArgs
|
||||
// {
|
||||
# GST_PLUGIN_PATH = "/run/current-system/sw/lib/gstreamer-1.0/";
|
||||
GIO_EXTRA_MODULES = "${pkgs.glib-networking}/lib/gio/modules";
|
||||
packages = with pkgs;
|
||||
[
|
||||
toolchainWithRustAnalyzer
|
||||
cargo-nextest
|
||||
bacon
|
||||
cargo-audit
|
||||
cargo-deny
|
||||
cargo-expand
|
||||
bacon
|
||||
cargo-make
|
||||
cargo-hack
|
||||
cargo-make
|
||||
cargo-nextest
|
||||
cargo-outdated
|
||||
lld
|
||||
lldb
|
||||
cargo-audit
|
||||
(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 [
|
||||
apple-sdk_26
|
||||
@@ -211,7 +228,7 @@
|
||||
samply
|
||||
cargo-flamegraph
|
||||
perf
|
||||
mold
|
||||
# mold
|
||||
]);
|
||||
});
|
||||
default = rust-shell;
|
||||
|
||||
@@ -4,10 +4,9 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
bson = { version = "3.1.0", features = ["serde"] }
|
||||
futures = "0.3.31"
|
||||
parking_lot = "0.12.5"
|
||||
redb = { version = "3.1.0", features = ["uuid"] }
|
||||
secrecy = "0.10.3"
|
||||
serde = "1.0.228"
|
||||
tokio = { version = "1.48.0", features = ["rt"] }
|
||||
uuid = "1.18.1"
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
pub mod redb;
|
||||
pub mod sqlite;
|
||||
pub mod toml;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub trait Store {
|
||||
fn image(&self, id: &str) -> Option<Vec<u8>>;
|
||||
fn save_image(&mut self, id: &str, data: &[u8]);
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct ApiKey {
|
||||
inner: secrecy::SecretBox<String>,
|
||||
}
|
||||
pub struct SecretStore {
|
||||
api_keys: BTreeMap<Uuid, ApiKey>,
|
||||
}
|
||||
|
||||
pub struct Settings {}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,10 @@ iced = { workspace = true, features = [
|
||||
|
||||
|
||||
iced-video = { workspace = true }
|
||||
iced_aw = "0.13.0"
|
||||
iced_wgpu = "0.14.0"
|
||||
iced_winit = "0.14.0"
|
||||
reqwest = "0.12.24"
|
||||
reqwest = "0.13"
|
||||
tap = "1.0.1"
|
||||
toml = "0.9.8"
|
||||
tracing = "0.1.41"
|
||||
|
||||
@@ -4,6 +4,7 @@ mod video;
|
||||
mod shared_string;
|
||||
use iced_video::{Ready, Video, VideoHandle};
|
||||
use shared_string::SharedString;
|
||||
use tap::Pipe as _;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -25,6 +26,8 @@ pub struct ItemCache {
|
||||
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 {
|
||||
pub fn insert(&mut self, parent: impl Into<Option<uuid::Uuid>>, item: Item) {
|
||||
let parent = parent.into();
|
||||
@@ -141,7 +144,6 @@ struct State {
|
||||
settings: settings::SettingsState,
|
||||
is_authenticated: bool,
|
||||
video: Option<Arc<VideoHandle<Message, Ready>>>,
|
||||
user: Option<api::jellyfin::UserDto>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
@@ -156,18 +158,14 @@ impl State {
|
||||
query: None,
|
||||
screen: Screen::Home,
|
||||
settings: settings::SettingsState::default(),
|
||||
// username_input: String::new(),
|
||||
// password_input: String::new(),
|
||||
is_authenticated: false,
|
||||
video: None,
|
||||
user: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
Noop,
|
||||
Settings(settings::SettingsMessage),
|
||||
Refresh,
|
||||
Search,
|
||||
@@ -175,33 +173,11 @@ pub enum Message {
|
||||
OpenItem(Option<uuid::Uuid>),
|
||||
LoadedItem(Option<uuid::Uuid>, Vec<Item>),
|
||||
Error(String),
|
||||
SetToken(String),
|
||||
Back,
|
||||
Home,
|
||||
Video(video::VideoMessage),
|
||||
}
|
||||
|
||||
// impl std::fmt::Debug for Message {
|
||||
// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// match self {
|
||||
// Message::Settings(msg) => f.debug_tuple("Settings").field(msg).finish(),
|
||||
// Message::Refresh => f.write_str("Refresh"),
|
||||
// Message::Search => f.write_str("Search"),
|
||||
// Message::SearchQueryChanged(q) => f.debug_tuple("SearchQueryChanged").field(q).finish(),
|
||||
// Message::OpenItem(id) => f.debug_tuple("OpenItem").field(id).finish(),
|
||||
// Message::LoadedItem(id, items) => {
|
||||
// f.debug_tuple("LoadedItem").field(id).field(items).finish()
|
||||
// }
|
||||
// Message::Error(e) => f.debug_tuple("Error").field(e).finish(),
|
||||
// Message::SetToken(_t) => f.debug_tuple("SetToken").field(&"...").finish(), // Mask token
|
||||
// Message::Back => f.write_str("Back"),
|
||||
// Message::Home => f.write_str("Home"),
|
||||
// Message::Video(msg) => f.debug_tuple("Video").field(msg).finish(),
|
||||
// Message::Noop => f.write_str("Noop"),
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::Settings(msg) => settings::update(state, msg),
|
||||
@@ -265,15 +241,6 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
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()
|
||||
@@ -284,7 +251,6 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
}
|
||||
Message::SearchQueryChanged(query) => {
|
||||
state.query = Some(query);
|
||||
// Handle search query change
|
||||
Task::none()
|
||||
}
|
||||
Message::Search => {
|
||||
@@ -304,40 +270,34 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
}
|
||||
}
|
||||
Message::Video(msg) => video::update(state, msg),
|
||||
Message::Noop => Task::none(),
|
||||
_ => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn view(state: &State) -> Element<'_, Message> {
|
||||
let content = home(state);
|
||||
|
||||
if matches!(state.screen, Screen::Settings) {
|
||||
stack![
|
||||
content,
|
||||
mouse_area(
|
||||
container(mouse_area(settings::settings(state)).on_press(Message::Refresh))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.style(|_theme| {
|
||||
container::Style {
|
||||
background: Some(
|
||||
iced::Color {
|
||||
a: 0.3,
|
||||
..iced::Color::BLACK
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
..container::Style::default()
|
||||
}
|
||||
})
|
||||
)
|
||||
.on_press(Message::Settings(settings::SettingsMessage::Close)),
|
||||
]
|
||||
.into()
|
||||
} else {
|
||||
content
|
||||
match state.screen {
|
||||
Screen::Settings => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,25 +312,23 @@ 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),
|
||||
)
|
||||
Grid::with_children(state.cache.items_of(state.current).into_iter().map(card))
|
||||
.fluid(400)
|
||||
.spacing(50)
|
||||
.pipe(container)
|
||||
.padding(50)
|
||||
.align_x(Alignment::Center)
|
||||
// .align_y(Alignment::Center)
|
||||
.height(Length::Fill)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
.width(Length::Fill)
|
||||
.pipe(scrollable)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
fn header(state: &State) -> Element<'_, Message> {
|
||||
let mut left_content = row![
|
||||
row([
|
||||
text(
|
||||
state
|
||||
.jellyfin_client
|
||||
@@ -378,36 +336,29 @@ fn header(state: &State) -> Element<'_, Message> {
|
||||
.map(|c| c.config.server_url.as_str())
|
||||
.unwrap_or("No Server"),
|
||||
)
|
||||
.align_x(Alignment::Start),
|
||||
];
|
||||
|
||||
if let Some(user) = &state.user {
|
||||
left_content =
|
||||
left_content.push(text(format!(" | {}", user.name.as_deref().unwrap_or("?"))));
|
||||
}
|
||||
|
||||
row([
|
||||
container(Button::new(left_content).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(),
|
||||
.align_x(Alignment::Start)
|
||||
.pipe(button)
|
||||
.on_press(Message::Home)
|
||||
.pipe(container)
|
||||
.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),
|
||||
)
|
||||
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)
|
||||
.pipe(container)
|
||||
.padding(10)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
@@ -423,19 +374,18 @@ fn header(state: &State) -> Element<'_, Message> {
|
||||
}
|
||||
|
||||
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()
|
||||
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)
|
||||
.pipe(container)
|
||||
.padding(10)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Shrink)
|
||||
.style(container::rounded_box)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn footer(state: &State) -> Element<'_, Message> {
|
||||
@@ -520,14 +470,8 @@ fn init() -> (State, Task<Message>) {
|
||||
match std::fs::read_to_string(".session") {
|
||||
Ok(token) => {
|
||||
let client = api::JellyfinClient::pre_authenticated(token.trim(), config)?;
|
||||
|
||||
// We need to fetch the current user to fully restore state
|
||||
// let _user_id = client.user_id.unwrap_or_default(); // user_id field doesn't exist on client
|
||||
|
||||
// We will fetch the user info in the chain if we are authenticated
|
||||
Ok((client, true))
|
||||
}
|
||||
|
||||
Err(_) => {
|
||||
// No cached token, create unauthenticated client
|
||||
let client = api::JellyfinClient::new_with_config(config);
|
||||
@@ -536,10 +480,12 @@ fn init() -> (State, Task<Message>) {
|
||||
}
|
||||
},
|
||||
|result: Result<_, api::JellyfinApiError>| match result {
|
||||
Ok((client, is_auth)) => Message::LoadedClient(client, is_auth),
|
||||
// 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)),
|
||||
)
|
||||
.chain(Task::done(Message::Refresh)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,61 +10,20 @@ pub fn update(state: &mut State, message: SettingsMessage) -> Task<Message> {
|
||||
SettingsMessage::Open => {
|
||||
tracing::trace!("Opening settings");
|
||||
state.screen = Screen::Settings;
|
||||
Task::none()
|
||||
}
|
||||
SettingsMessage::Close => {
|
||||
tracing::trace!("Closing settings");
|
||||
state.screen = Screen::Home;
|
||||
Task::none()
|
||||
}
|
||||
SettingsMessage::Select(screen) => {
|
||||
tracing::trace!("Switching settings screen to {:?}", screen);
|
||||
state.settings.screen = screen;
|
||||
Task::none()
|
||||
}
|
||||
// LoginResult(Result<(api::JellyfinClient, api::jellyfin::AuthenticationResult), String>),
|
||||
// LoadedClient(api::JellyfinClient, bool),
|
||||
// UserLoaded(Result<api::jellyfin::UserDto, String>),
|
||||
// ConfigSaved(Result<api::JellyfinConfig, String>),
|
||||
// Logout,
|
||||
SettingsMessage::User(user) => {
|
||||
if let UserMessage::Add = user {
|
||||
// Handle adding user / login
|
||||
let username = state.settings.login_form.username.clone();
|
||||
let password = state.settings.login_form.password.clone();
|
||||
|
||||
let mut config = api::JellyfinConfig {
|
||||
server_url: "http://localhost:8096".parse().unwrap(), // Default fallback
|
||||
device_id: "jello-iced".to_string(),
|
||||
device_name: "Jello Iced".to_string(),
|
||||
client_name: "Jello".to_string(),
|
||||
version: "0.1.0".to_string(),
|
||||
};
|
||||
|
||||
// Try to use existing config if possible
|
||||
if let Some(client) = &state.jellyfin_client {
|
||||
config = client.config.as_ref().clone();
|
||||
} else if let Ok(config_str) = std::fs::read_to_string("config.toml") {
|
||||
if let Ok(loaded_config) = toml::from_str(&config_str) {
|
||||
config = loaded_config;
|
||||
}
|
||||
}
|
||||
|
||||
return Task::perform(
|
||||
async move {
|
||||
api::JellyfinClient::authenticate(username, password, config)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
},
|
||||
Message::LoginResult,
|
||||
);
|
||||
}
|
||||
state.settings.login_form.update(user);
|
||||
Task::none()
|
||||
}
|
||||
SettingsMessage::User(user) => state.settings.login_form.update(user),
|
||||
|
||||
SettingsMessage::Server(server) => state.settings.server_form.update(server),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub fn empty() -> Element<'static, Message> {
|
||||
@@ -73,9 +32,9 @@ pub fn empty() -> Element<'static, Message> {
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SettingsState {
|
||||
pub login_form: LoginForm,
|
||||
pub server_form: ServerForm,
|
||||
pub screen: SettingsScreen,
|
||||
login_form: LoginForm,
|
||||
server_form: ServerForm,
|
||||
screen: SettingsScreen,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -95,7 +54,6 @@ pub enum UserMessage {
|
||||
// Edit(uuid::Uuid),
|
||||
// Delete(uuid::Uuid),
|
||||
Clear,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -108,7 +66,7 @@ pub enum ServerMessage {
|
||||
Clear,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub enum SettingsScreen {
|
||||
#[default]
|
||||
Main,
|
||||
@@ -132,9 +90,8 @@ pub struct UserItem {
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LoginForm {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub error: Option<String>,
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl LoginForm {
|
||||
@@ -142,11 +99,9 @@ impl LoginForm {
|
||||
match message {
|
||||
UserMessage::UsernameChanged(data) => {
|
||||
self.username = data;
|
||||
self.error = None; // Clear error on input
|
||||
}
|
||||
UserMessage::PasswordChanged(data) => {
|
||||
self.password = data;
|
||||
self.error = None; // Clear error on input
|
||||
}
|
||||
UserMessage::Add => {
|
||||
// Handle adding user
|
||||
@@ -154,48 +109,30 @@ impl LoginForm {
|
||||
UserMessage::Clear => {
|
||||
self.username.clear();
|
||||
self.password.clear();
|
||||
self.error = None;
|
||||
}
|
||||
UserMessage::Error(msg) => {
|
||||
self.error = Some(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn view(&self, is_authenticated: bool) -> Element<'_, Message> {
|
||||
let mut col = iced::widget::column![text("Login Form"),];
|
||||
|
||||
if !is_authenticated {
|
||||
let mut inputs = iced::widget::column![
|
||||
text_input("Enter Username", &self.username).on_input(|data| {
|
||||
Message::Settings(SettingsMessage::User(UserMessage::UsernameChanged(data)))
|
||||
pub fn view(&self) -> Element<'_, Message> {
|
||||
iced::widget::column![
|
||||
text("Login Form"),
|
||||
text_input("Enter Username", &self.username).on_input(|data| {
|
||||
Message::Settings(SettingsMessage::User(UserMessage::UsernameChanged(data)))
|
||||
}),
|
||||
text_input("Enter Password", &self.password)
|
||||
.secure(true)
|
||||
.on_input(|data| {
|
||||
Message::Settings(SettingsMessage::User(UserMessage::PasswordChanged(data)))
|
||||
}),
|
||||
text_input("Enter Password", &self.password)
|
||||
.secure(true)
|
||||
.on_input(|data| {
|
||||
Message::Settings(SettingsMessage::User(UserMessage::PasswordChanged(data)))
|
||||
}),
|
||||
row![
|
||||
button(text("Add User")).on_press_maybe(self.validate()),
|
||||
button(text("Cancel"))
|
||||
.on_press(Message::Settings(SettingsMessage::User(UserMessage::Clear))),
|
||||
]
|
||||
.spacing(10);
|
||||
|
||||
if let Some(err) = &self.error {
|
||||
inputs = inputs.push(text(err).style(|_| text::Style {
|
||||
color: Some(iced::Color::from_rgb(0.8, 0.0, 0.0)),
|
||||
}));
|
||||
}
|
||||
|
||||
col = col.push(inputs).push(
|
||||
row![
|
||||
button(text("Login")).on_press_maybe(self.validate()),
|
||||
button(text("Cancel"))
|
||||
.on_press(Message::Settings(SettingsMessage::User(UserMessage::Clear))),
|
||||
]
|
||||
.spacing(10),
|
||||
);
|
||||
} else {
|
||||
col = col.push(row![button(text("Logout")).on_press(Message::Logout)].spacing(10));
|
||||
}
|
||||
|
||||
col.spacing(10).padding([10, 0]).into()
|
||||
.spacing(10),
|
||||
]
|
||||
.spacing(10)
|
||||
.padding([10, 0])
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Option<Message> {
|
||||
@@ -211,7 +148,7 @@ pub struct ServerForm {
|
||||
}
|
||||
|
||||
impl ServerForm {
|
||||
pub fn update(&mut self, message: ServerMessage) -> Task<Message> {
|
||||
pub fn update(&mut self, message: ServerMessage) {
|
||||
match message {
|
||||
ServerMessage::NameChanged(data) => {
|
||||
self.name = data;
|
||||
@@ -220,36 +157,7 @@ impl ServerForm {
|
||||
self.url = data;
|
||||
}
|
||||
ServerMessage::Add => {
|
||||
// Handle adding server (saving config)
|
||||
let name = self.name.clone();
|
||||
let url_str = self.url.clone();
|
||||
|
||||
return Task::perform(
|
||||
async move {
|
||||
// Try to parse the URL
|
||||
let url_parsed = url::Url::parse(&url_str)
|
||||
.or_else(|_| url::Url::parse(&format!("http://{}", url_str)))
|
||||
.map_err(|e| format!("Invalid URL: {}", e))?;
|
||||
|
||||
// Create new config
|
||||
let config = api::JellyfinConfig {
|
||||
server_url: url_parsed.to_string().parse().unwrap(),
|
||||
device_id: "jello-iced".to_string(),
|
||||
device_name: name,
|
||||
client_name: "Jello".to_string(),
|
||||
version: "0.1.0".to_string(),
|
||||
};
|
||||
|
||||
// Save to config.toml
|
||||
let toml_str = toml::to_string(&config)
|
||||
.map_err(|e| format!("Failed to serialize config: {}", e))?;
|
||||
std::fs::write("config.toml", toml_str)
|
||||
.map_err(|e| format!("Failed to write config file: {}", e))?;
|
||||
|
||||
Ok(config)
|
||||
},
|
||||
Message::ConfigSaved,
|
||||
);
|
||||
// Handle adding server
|
||||
}
|
||||
ServerMessage::Clear => {
|
||||
self.name.clear();
|
||||
@@ -257,7 +165,6 @@ impl ServerForm {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
pub fn view(&self) -> Element<'_, Message> {
|
||||
iced::widget::column![
|
||||
@@ -288,17 +195,28 @@ impl ServerForm {
|
||||
}
|
||||
|
||||
mod screens {
|
||||
use iced_aw::Tabs;
|
||||
|
||||
use super::*;
|
||||
pub fn settings(state: &State) -> Element<'_, Message> {
|
||||
container(
|
||||
row([settings_list(state), settings_screen(state)])
|
||||
.spacing(20)
|
||||
.width(Length::Fixed(800.0))
|
||||
.height(Length::Fixed(600.0)),
|
||||
)
|
||||
.padding(20)
|
||||
.style(container::rounded_box)
|
||||
.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> {
|
||||
@@ -308,78 +226,65 @@ mod screens {
|
||||
SettingsScreen::Users => user(state),
|
||||
})
|
||||
.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()
|
||||
}
|
||||
|
||||
pub fn settings_list(state: &State) -> Element<'_, Message> {
|
||||
scrollable(
|
||||
column(
|
||||
[
|
||||
button(center_text("Main")).on_press(Message::Settings(
|
||||
SettingsMessage::Select(SettingsScreen::Main),
|
||||
)),
|
||||
button(center_text("Servers")).on_press(Message::Settings(
|
||||
SettingsMessage::Select(SettingsScreen::Servers),
|
||||
)),
|
||||
button(center_text("Users")).on_press(Message::Settings(
|
||||
SettingsMessage::Select(SettingsScreen::Users),
|
||||
)),
|
||||
]
|
||||
.map(|p| p.clip(true).width(Length::Fill).into()),
|
||||
)
|
||||
.width(Length::FillPortion(2))
|
||||
.spacing(10)
|
||||
.padding(10),
|
||||
column(
|
||||
[
|
||||
button(center_text("General")).on_press(Message::Settings(
|
||||
SettingsMessage::Select(SettingsScreen::Main),
|
||||
)),
|
||||
button(center_text("Servers")).on_press(Message::Settings(
|
||||
SettingsMessage::Select(SettingsScreen::Servers),
|
||||
)),
|
||||
button(center_text("Users")).on_press(Message::Settings(SettingsMessage::Select(
|
||||
SettingsScreen::Users,
|
||||
))),
|
||||
]
|
||||
.map(|p| p.clip(true).width(Length::Fill).into()),
|
||||
)
|
||||
.width(Length::FillPortion(2))
|
||||
.spacing(10)
|
||||
.padding(10)
|
||||
.pipe(scrollable)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn main(state: &State) -> Element<'_, Message> {
|
||||
// placeholder for now
|
||||
container(
|
||||
Column::new()
|
||||
.push(text("Main Settings"))
|
||||
.push(toggler(true).label("Foobar"))
|
||||
.spacing(20)
|
||||
.padding(20),
|
||||
)
|
||||
.into()
|
||||
Column::new()
|
||||
.push(text("Main Settings"))
|
||||
.push(toggler(true).label("HDR"))
|
||||
.spacing(20)
|
||||
.padding(20)
|
||||
.pipe(container)
|
||||
.into()
|
||||
}
|
||||
pub fn server(state: &State) -> Element<'_, Message> {
|
||||
container(
|
||||
Column::new()
|
||||
.push(text("Server Settings"))
|
||||
.push(state.settings.server_form.view())
|
||||
// .push(toggler(false).label("Enable Server"))
|
||||
.spacing(20)
|
||||
.padding(20),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
pub fn user(state: &State) -> Element<'_, Message> {
|
||||
let user_display = if let Some(user) = &state.user {
|
||||
iced::widget::column![
|
||||
text(format!(
|
||||
"Logged in as: {}",
|
||||
user.name.as_deref().unwrap_or("Unknown")
|
||||
)),
|
||||
// We could add an avatar here if we had image loading for it
|
||||
]
|
||||
.spacing(10)
|
||||
} else {
|
||||
iced::widget::column![].into()
|
||||
};
|
||||
|
||||
container(
|
||||
Column::new()
|
||||
.push(text("User Settings"))
|
||||
.push(user_display)
|
||||
.push(state.settings.login_form.view(state.is_authenticated))
|
||||
// .push(userlist(&state))
|
||||
.spacing(20)
|
||||
.padding(20),
|
||||
)
|
||||
.into()
|
||||
pub fn server(state: &State) -> Element<'_, Message> {
|
||||
Column::new()
|
||||
.push(text("Server Settings"))
|
||||
.push(state.settings.server_form.view())
|
||||
.spacing(20)
|
||||
.padding(20)
|
||||
.pipe(container)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn user(state: &State) -> Element<'_, Message> {
|
||||
Column::new()
|
||||
.push(text("User Settings"))
|
||||
.push(state.settings.login_form.view())
|
||||
.spacing(20)
|
||||
.padding(20)
|
||||
.pipe(container)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,100 +294,3 @@ pub fn center_text(content: &str) -> Element<'_, Message> {
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
// Message::ConfigSaved(result) => {
|
||||
// match result {
|
||||
// Ok(config) => {
|
||||
// tracing::info!("Configuration saved successfully.");
|
||||
// state.messages.push("Configuration saved.".to_string());
|
||||
//
|
||||
// // Re-initialize client with new config
|
||||
// // This invalidates the current session as the server might have changed
|
||||
// state.jellyfin_client = Some(api::JellyfinClient::new_with_config(config));
|
||||
// state.is_authenticated = false;
|
||||
// state.user = None;
|
||||
//
|
||||
// // Clear session file as it likely belongs to the old server
|
||||
// let _ = std::fs::remove_file(".session");
|
||||
//
|
||||
// // Reset cache
|
||||
// state.cache = ItemCache::default();
|
||||
// state.current = None;
|
||||
// state.history.clear();
|
||||
//
|
||||
// Task::none()
|
||||
// }
|
||||
// Err(e) => {
|
||||
// tracing::error!("Failed to save configuration: {}", e);
|
||||
// state.messages.push(format!("Failed to save config: {}", e));
|
||||
// Task::none()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// Message::LoadedClient(client, is_auth) => {
|
||||
// state.jellyfin_client = Some(client.clone());
|
||||
// state.is_authenticated = is_auth;
|
||||
// if is_auth {
|
||||
// // Fetch user if authenticated
|
||||
// Task::perform(
|
||||
// async move { client.me().await.map_err(|e| e.to_string()) },
|
||||
// Message::UserLoaded,
|
||||
// )
|
||||
// } else {
|
||||
// Task::done(Message::Refresh)
|
||||
// }
|
||||
// }
|
||||
// Message::UserLoaded(result) => {
|
||||
// match result {
|
||||
// Ok(user) => {
|
||||
// state.user = Some(user);
|
||||
// }
|
||||
// Err(e) => {
|
||||
// tracing::warn!("Failed to load user profile: {}", e);
|
||||
// }
|
||||
// }
|
||||
// Task::done(Message::Refresh)
|
||||
// }
|
||||
// Message::LoginResult(result) => {
|
||||
// match result {
|
||||
// Ok((client, auth)) => {
|
||||
// if let Some(token) = client.access_token() {
|
||||
// if let Err(e) = std::fs::write(".session", token) {
|
||||
// tracing::error!("Failed to save session token: {}", e);
|
||||
// }
|
||||
// }
|
||||
// // Fetch user here too since authentication just succeeded
|
||||
// state.jellyfin_client = Some(client.clone());
|
||||
// state.is_authenticated = true;
|
||||
// state.settings.login_form = settings::LoginForm::default();
|
||||
//
|
||||
// // We can use the auth.user if present, or fetch it.
|
||||
// if let Some(user_dto) = auth.user {
|
||||
// state.user = Some(user_dto);
|
||||
// Task::none()
|
||||
// } else {
|
||||
// Task::perform(
|
||||
// async move { client.me().await.map_err(|e| e.to_string()) },
|
||||
// Message::UserLoaded,
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// Err(e) => {
|
||||
// tracing::error!("Login failed: {}", e);
|
||||
// state.messages.push(format!("Login failed: {}", e));
|
||||
// // Pass the error to the settings/login form so it can be displayed
|
||||
// state
|
||||
// .settings
|
||||
// .login_form
|
||||
// .update(settings::UserMessage::Error(e));
|
||||
// Task::none()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// Message::Logout => {
|
||||
// state.jellyfin_client = None;
|
||||
// state.is_authenticated = false;
|
||||
// state.user = None;
|
||||
// let _ = std::fs::remove_file(".session");
|
||||
// Task::none()
|
||||
// }
|
||||
|
||||
Reference in New Issue
Block a user