Compare commits

..

1 Commits

Author SHA1 Message Date
c8c371230f feat(api): return (Self, AuthenticationResult) from authenticate
Some checks failed
build / checks-matrix (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
build / checks-build (push) Has been cancelled
2026-01-16 11:33:55 +05:30
51 changed files with 1474 additions and 764 deletions

1006
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,21 @@
[workspace] [workspace]
members = [ members = [
".", ".",
"api",
"typegen", "typegen",
"ui-gpui", "ui-gpui",
"ui-iced", "ui-iced",
"crates/api", "store",
"crates/gst", "jello-types",
"crates/iced-video", "gst",
"crates/store",
"examples/hdr-gstreamer-wgpu", "examples/hdr-gstreamer-wgpu",
"crates/iced-video",
] ]
[workspace.dependencies] [workspace.dependencies]
iced = { version = "0.14.0" } iced = { version = "0.14.0" }
gst = { version = "0.1.0", path = "crates/gst" } gst = { version = "0.1.0", path = "gst" }
iced_wgpu = { version = "0.14.0" }
iced-video = { version = "0.1.0", path = "crates/iced-video" } iced-video = { version = "0.1.0", path = "crates/iced-video" }
api = { version = "0.1.0", path = "crates/api" }
[patch.crates-io] [patch.crates-io]
iced_wgpu = { git = "https://github.com/uttarayan21/iced", branch = "0.14" } iced_wgpu = { git = "https://github.com/uttarayan21/iced", branch = "0.14" }
@@ -30,7 +31,7 @@ edition = "2024"
license = "MIT" license = "MIT"
[dependencies] [dependencies]
api = { version = "0.1.0", path = "crates/api" } api = { version = "0.1.0", path = "api" }
bytemuck = { version = "1.24.0", features = ["derive"] } bytemuck = { version = "1.24.0", features = ["derive"] }
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
clap-verbosity-flag = { version = "3.0.4", features = ["tracing"] } clap-verbosity-flag = { version = "3.0.4", features = ["tracing"] }

View File

@@ -34,10 +34,10 @@ impl JellyfinClient {
username: impl AsRef<str>, username: impl AsRef<str>,
password: impl AsRef<str>, password: impl AsRef<str>,
config: JellyfinConfig, config: JellyfinConfig,
) -> Result<Self> { ) -> Result<(Self, jellyfin::AuthenticationResult)> {
let url = format!("{}/Users/AuthenticateByName", config.server_url); let url = format!("{}/Users/AuthenticateByName", config.server_url);
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let token = client let auth_result = client
.post(url) .post(url)
.json(&jellyfin::AuthenticateUserByName { .json(&jellyfin::AuthenticateUserByName {
username: Some(username.as_ref().to_string()), username: Some(username.as_ref().to_string()),
@@ -47,10 +47,14 @@ impl JellyfinClient {
.await? .await?
.error_for_status()? .error_for_status()?
.json::<jellyfin::AuthenticationResult>() .json::<jellyfin::AuthenticationResult>()
.await? .await?;
let token = auth_result
.access_token .access_token
.as_ref()
.ok_or_else(|| std::io::Error::other("No field access_token in auth response"))?; .ok_or_else(|| std::io::Error::other("No field access_token in auth response"))?;
Self::pre_authenticated(token, config)
Ok((Self::pre_authenticated(token, config)?, auth_result))
} }
pub fn pre_authenticated(token: impl AsRef<str>, config: JellyfinConfig) -> Result<Self> { pub fn pre_authenticated(token: impl AsRef<str>, config: JellyfinConfig) -> Result<Self> {
@@ -80,6 +84,10 @@ 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<()> { pub async fn save_token(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
if let Some(token) = &self.access_token { if let Some(token) = &self.access_token {
tokio::fs::write(path, &**token).await tokio::fs::write(path, &**token).await
@@ -104,6 +112,19 @@ impl JellyfinClient {
self.access_token = Some(token.as_ref().into()); 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( pub fn request_builder(
&self, &self,
method: reqwest::Method, method: reqwest::Method,

View File

@@ -19,3 +19,11 @@ wgpu = { version = "27.0.1", features = ["vulkan"] }
[dev-dependencies] [dev-dependencies]
iced.workspace = true iced.workspace = true
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
[profile.dev]
debug = true
[profile.release]
debug = true
# [patch.crates-io]
# iced_wgpu = { git = "https://github.com/uttarayan21/iced", branch = "0.14" }

View File

@@ -1,11 +1,12 @@
use crate::{Error, Result, ResultExt}; use crate::{Error, Result, ResultExt};
use gst::{ use gst::{
Bus, Gst, Sink, Bus, Gst, MessageType, MessageView, Sink, Source,
app::AppSink, app::AppSink,
caps::{Caps, CapsType}, caps::{Caps, CapsType},
element::ElementExt, element::ElementExt,
pipeline::PipelineExt, pipeline::PipelineExt,
playback::{PlayFlags, Playbin3}, playback::{PlayFlags, Playbin3},
videoconvertscale::VideoConvert,
}; };
use std::sync::{Arc, Mutex, atomic::AtomicBool}; use std::sync::{Arc, Mutex, atomic::AtomicBool};

View File

@@ -1,10 +0,0 @@
use std::collections::BTreeMap;
use uuid::Uuid;
pub struct ApiKey {
inner: secrecy::SecretBox<String>,
}
pub struct SecretStore {
api_keys: BTreeMap<Uuid, ApiKey>,
}

View File

@@ -15,3 +15,6 @@ anyhow = "*"
pollster = "0.4.0" pollster = "0.4.0"
tracing = { version = "0.1.43", features = ["log"] } tracing = { version = "0.1.43", features = ["log"] }
tracing-subscriber = "0.3.22" tracing-subscriber = "0.3.22"
[profile.release]
debug = true

35
flake.lock generated
View File

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

View File

@@ -9,14 +9,7 @@
url = "github:nix-community/nix-github-actions"; url = "github:nix-community/nix-github-actions";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
crates-io-index = { crates-nix.url = "github:uttarayan21/crates.nix";
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";
@@ -186,38 +179,28 @@
devShells = rec { devShells = rec {
rust-shell = rust-shell =
pkgs.mkShell.override { pkgs.mkShell.override {
stdenv = pkgs.clangStdenv; stdenv =
# 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
bacon cargo-nextest
cargo-audit
cargo-deny cargo-deny
cargo-expand cargo-expand
cargo-hack bacon
cargo-make cargo-make
cargo-nextest cargo-hack
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
@@ -228,7 +211,7 @@
samply samply
cargo-flamegraph cargo-flamegraph
perf perf
# mold mold
]); ]);
}); });
default = rust-shell; default = rust-shell;

62
gst/.github/workflows/build.yaml vendored Normal file
View File

@@ -0,0 +1,62 @@
name: build
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
env:
CARGO_TERM_COLOR: always
jobs:
checks-matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- id: set-matrix
name: Generate Nix Matrix
run: |
set -Eeu
matrix="$(nix eval --json '.#githubActions.matrix')"
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
checks-build:
needs: checks-matrix
runs-on: ${{ matrix.os }}
strategy:
matrix: ${{fromJSON(needs.checks-matrix.outputs.matrix)}}
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- run: nix build -L '.#${{ matrix.attr }}'
codecov:
runs-on: ubuntu-latest
permissions:
id-token: "write"
contents: "read"
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Run codecov
run: nix build .#checks.x86_64-linux.hello-llvm-cov
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v4.0.1
with:
flags: unittests
name: codecov-hello
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
files: ./result
verbose: true

38
gst/.github/workflows/docs.yaml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: docs
on:
push:
branches: [ master ]
env:
CARGO_TERM_COLOR: always
jobs:
docs:
runs-on: ubuntu-latest
permissions:
id-token: "write"
contents: "read"
pages: "write"
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- uses: DeterminateSystems/flake-checker-action@main
- name: Generate docs
run: nix build .#checks.x86_64-linux.hello-docs
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: result/share/doc
- name: Deploy to gh-pages
id: deployment
uses: actions/deploy-pages@v4

8
jello-types/Cargo.toml Normal file
View File

@@ -0,0 +1,8 @@
[package]
name = "jello-types"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1.0.228", features = ["derive"] }
uuid = { version = "1.18.1", features = ["serde"] }

6
jello-types/src/lib.rs Normal file
View File

@@ -0,0 +1,6 @@
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct User {
id: uuid::Uuid,
name: Option<String>,
primary_image_tag: Option<String>,
}

View File

@@ -1,4 +1,4 @@
pub use error_stack::ResultExt; pub use error_stack::{Report, ResultExt};
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
#[error("An error occurred")] #[error("An error occurred")]
pub struct Error; pub struct Error;

View File

@@ -1,5 +1,6 @@
mod cli; mod cli;
mod errors; mod errors;
use api::JellyfinConfig;
use errors::*; use errors::*;
fn main() -> Result<()> { fn main() -> Result<()> {

View File

@@ -4,9 +4,10 @@ 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"
secrecy = "0.10.3" redb = { version = "3.1.0", features = ["uuid"] }
serde = "1.0.228" serde = "1.0.228"
tokio = { version = "1.48.0", features = ["rt"] } tokio = { version = "1.48.0", features = ["rt"] }
uuid = { version = "1.18.1", features = ["v4"] } uuid = "1.18.1"

10
store/src/lib.rs Normal file
View File

@@ -0,0 +1,10 @@
pub mod redb;
pub mod sqlite;
pub mod toml;
pub trait Store {
fn image(&self, id: &str) -> Option<Vec<u8>>;
fn save_image(&mut self, id: &str, data: &[u8]);
}
pub struct Settings {}

225
store/src/redb.rs Normal file
View File

@@ -0,0 +1,225 @@
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

@@ -5,7 +5,7 @@ edition = "2024"
license = "MIT" license = "MIT"
[dependencies] [dependencies]
api = { workspace = true } api = { version = "0.1.0", path = "../api" }
blurhash = "0.2.3" blurhash = "0.2.3"
bytes = "1.11.0" bytes = "1.11.0"
gpui_util = "0.2.2" gpui_util = "0.2.2"
@@ -21,10 +21,9 @@ 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.13" reqwest = "0.12.24"
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,7 +4,6 @@ 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;
@@ -26,8 +25,6 @@ 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();
@@ -144,6 +141,7 @@ struct State {
settings: settings::SettingsState, settings: settings::SettingsState,
is_authenticated: bool, is_authenticated: bool,
video: Option<Arc<VideoHandle<Message, Ready>>>, video: Option<Arc<VideoHandle<Message, Ready>>>,
user: Option<api::jellyfin::UserDto>,
} }
impl State { impl State {
@@ -158,14 +156,18 @@ 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,
user: None,
} }
} }
} }
#[derive(Debug, Clone)] #[derive(Clone, Debug)]
pub enum Message { pub enum Message {
Noop,
Settings(settings::SettingsMessage), Settings(settings::SettingsMessage),
Refresh, Refresh,
Search, Search,
@@ -173,11 +175,33 @@ 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,
Video(video::VideoMessage), 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> { fn update(state: &mut State, message: Message) -> Task<Message> {
match message { match message {
Message::Settings(msg) => settings::update(state, msg), Message::Settings(msg) => settings::update(state, msg),
@@ -241,6 +265,15 @@ 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()
@@ -251,6 +284,7 @@ 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 => {
@@ -270,33 +304,40 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
} }
} }
Message::Video(msg) => video::update(state, msg), Message::Video(msg) => video::update(state, msg),
Message::Noop => Task::none(),
} }
} }
fn view(state: &State) -> Element<'_, Message> { fn view(state: &State) -> Element<'_, Message> {
let content = home(state); let content = home(state);
match state.screen {
Screen::Settings => { if matches!(state.screen, Screen::Settings) {
let settings = settings::settings(state); stack![
let settings = container(settings) content,
.width(Length::FillPortion(4)) mouse_area(
.height(Length::FillPortion(4)) container(mouse_area(settings::settings(state)).on_press(Message::Refresh))
.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)
.width(Length::Fill) .height(Length::Fill)
.align_y(Alignment::Center)
.align_x(Alignment::Center) .align_x(Alignment::Center)
.style(|_| container::background(BACKGROUND_COLOR)) .align_y(Alignment::Center)
.padding(50) .style(|_theme| {
.pipe(mouse_area) container::Style {
.on_press(Message::Settings(settings::SettingsMessage::Close)); background: Some(
stack![content, settings].into() iced::Color {
a: 0.3,
..iced::Color::BLACK
} }
Screen::Home | _ => content, .into(),
),
..container::Style::default()
}
})
)
.on_press(Message::Settings(settings::SettingsMessage::Close)),
]
.into()
} else {
content
} }
} }
@@ -311,23 +352,25 @@ 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()
} }
} }
fn header(state: &State) -> Element<'_, Message> { fn header(state: &State) -> Element<'_, Message> {
row([ let mut left_content = row![
text( text(
state state
.jellyfin_client .jellyfin_client
@@ -335,10 +378,16 @@ fn header(state: &State) -> Element<'_, Message> {
.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)
.pipe(container) 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) .padding(10)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
@@ -347,6 +396,7 @@ 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")
@@ -356,8 +406,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)
@@ -373,13 +423,14 @@ 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)
@@ -441,7 +492,55 @@ fn card(item: &Item) -> Element<'_, Message> {
} }
fn init() -> (State, Task<Message>) { fn init() -> (State, Task<Message>) {
(State::new(), Task::done(Message::Refresh)) // 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)?;
// 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);
Ok((client, false))
}
}
},
|result: Result<_, api::JellyfinApiError>| match result {
Ok((client, is_auth)) => Message::LoadedClient(client, is_auth),
Err(e) => Message::Error(format!("Initialization failed: {}", e)),
},
), // .chain(Task::done(Message::Refresh)),
)
} }
pub fn ui() -> iced::Result { pub fn ui() -> iced::Result {

View File

@@ -10,36 +10,105 @@ pub fn update(state: &mut State, message: SettingsMessage) -> Task<Message> {
SettingsMessage::Open => { SettingsMessage::Open => {
tracing::trace!("Opening settings"); tracing::trace!("Opening settings");
state.screen = Screen::Settings; state.screen = Screen::Settings;
Task::none()
} }
SettingsMessage::Close => { SettingsMessage::Close => {
tracing::trace!("Closing settings"); tracing::trace!("Closing settings");
state.screen = Screen::Home; state.screen = Screen::Home;
Task::none()
} }
SettingsMessage::Select(screen) => { SettingsMessage::Select(screen) => {
tracing::trace!("Switching settings screen to {:?}", screen); tracing::trace!("Switching settings screen to {:?}", screen);
state.settings.screen = screen; state.settings.screen = screen;
} //
// SettingsMessage::User(user) => state.settings.login_form.update(user),
//
// SettingsMessage::Server(server) => state.settings.server_page.update(server),
}
Task::none() 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::Server(server) => state.settings.server_form.update(server),
}
}
pub fn empty() -> Element<'static, Message> { pub fn empty() -> Element<'static, Message> {
column([]).into() column([]).into()
} }
#[derive(Debug, Clone, Default)]
pub struct SettingsState {
pub login_form: LoginForm,
pub server_form: ServerForm,
pub screen: SettingsScreen,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum SettingsMessage { pub enum SettingsMessage {
Open, Open,
Close, Close,
Select(SettingsScreen), Select(SettingsScreen),
// User(UserMessage), User(UserMessage),
// Server(ServerMessage), Server(ServerMessage),
} }
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone)]
pub enum UserMessage {
Add,
UsernameChanged(String),
PasswordChanged(String),
// Edit(uuid::Uuid),
// Delete(uuid::Uuid),
Clear,
Error(String),
}
#[derive(Debug, Clone)]
pub enum ServerMessage {
Add,
NameChanged(String),
UrlChanged(String),
// Edit(uuid::Uuid),
// Delete(uuid::Uuid),
Clear,
}
#[derive(Debug, Clone, Default)]
pub enum SettingsScreen { pub enum SettingsScreen {
#[default] #[default]
Main, Main,
@@ -47,33 +116,188 @@ pub enum SettingsScreen {
Servers, Servers,
} }
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone)]
pub struct SettingsState { pub struct ServerItem {
pub screen: SettingsScreen, pub id: uuid::Uuid,
pub name: SharedString,
pub url: SharedString,
pub users: Vec<uuid::Uuid>,
}
#[derive(Debug, Clone)]
pub struct UserItem {
pub id: uuid::Uuid,
pub name: SharedString,
}
#[derive(Debug, Clone, Default)]
pub struct LoginForm {
pub username: String,
pub password: String,
pub error: Option<String>,
}
impl LoginForm {
pub fn update(&mut self, message: UserMessage) {
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
}
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)))
}),
text_input("Enter Password", &self.password)
.secure(true)
.on_input(|data| {
Message::Settings(SettingsMessage::User(UserMessage::PasswordChanged(data)))
}),
]
.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()
}
pub fn validate(&self) -> Option<Message> {
(!self.username.is_empty() && !self.password.is_empty())
.then(|| Message::Settings(SettingsMessage::User(UserMessage::Add)))
}
}
#[derive(Debug, Clone, Default)]
pub struct ServerForm {
name: String,
url: String,
}
impl ServerForm {
pub fn update(&mut self, message: ServerMessage) -> Task<Message> {
match message {
ServerMessage::NameChanged(data) => {
self.name = data;
}
ServerMessage::UrlChanged(data) => {
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,
);
}
ServerMessage::Clear => {
self.name.clear();
self.url.clear();
}
_ => {}
}
Task::none()
}
pub fn view(&self) -> Element<'_, Message> {
iced::widget::column![
text("Add New Server"),
text_input("Enter server name", &self.name).on_input(|data| {
Message::Settings(SettingsMessage::Server(ServerMessage::NameChanged(data)))
}),
text_input("Enter server URL", &self.url).on_input(|data| {
Message::Settings(SettingsMessage::Server(ServerMessage::UrlChanged(data)))
}),
row![
button(text("Add Server")).on_press_maybe(self.validate()),
button(text("Cancel")).on_press(Message::Settings(SettingsMessage::Server(
ServerMessage::Clear
))),
]
.spacing(10),
]
.spacing(10)
.padding([10, 0])
.into()
}
pub fn validate(&self) -> Option<Message> {
(!self.name.is_empty() && !self.url.is_empty())
.then(|| Message::Settings(SettingsMessage::Server(ServerMessage::Add)))
}
} }
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> {
Tabs::new(|f| Message::Settings(SettingsMessage::Select(f))) container(
.push( row([settings_list(state), settings_screen(state)])
SettingsScreen::Main, .spacing(20)
iced_aw::TabLabel::Text("General".into()), .width(Length::Fixed(800.0))
main(state), .height(Length::Fixed(600.0)),
) )
.push( .padding(20)
SettingsScreen::Servers, .style(container::rounded_box)
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() .into()
} }
@@ -84,65 +308,77 @@ 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("General")).on_press(Message::Settings( button(center_text("Main")).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(SettingsMessage::Select( button(center_text("Users")).on_press(Message::Settings(
SettingsScreen::Users, SettingsMessage::Select(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("HDR")) .push(toggler(true).label("Foobar"))
.push(toggler(true).label("Enable Notifications"))
.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(ServerPage::view(state)) .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> {
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() Column::new()
.push(text("User Settings")) .push(text("User Settings"))
// .push(LoginForm::view(&state.settings.login_form)) .push(user_display)
.push(state.settings.login_form.view(state.is_authenticated))
// .push(userlist(&state))
.spacing(20) .spacing(20)
.padding(20) .padding(20),
.pipe(container) )
.into() .into()
} }
} }
@@ -153,3 +389,100 @@ pub fn center_text(content: &str) -> Element<'_, Message> {
.width(Length::Fill) .width(Length::Fill)
.into() .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()
// }