Compare commits

...

55 Commits

Author SHA1 Message Date
d509fb7813 feat: Update cargo.lock 2026-01-29 00:45:46 +05:30
dcbb5a127b feat: Move settings to a tab based ui 2026-01-29 00:40:12 +05:30
e66c457b57 feat: Added BACKGROUND_COLOR to settings popup 2026-01-28 02:06:57 +05:30
76fc14c73b feat: Use a floating box for settings 2026-01-28 02:00:45 +05:30
5b4fbd5df6 feat(store): add SecretStore, ApiKey, remove Store trait 2026-01-26 21:00:56 +05:30
e7fd01c0af chore: Update cargo.lock 2026-01-20 21:52:46 +05:30
a040478069 chore: Update flake.lock 2026-01-20 21:52:46 +05:30
e5ef173473 fix(iced-video): Update the color matrices and subtract .5 from uv samples to
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
2026-01-15 17:25:09 +05:30
429371002b fix(iced-video): Write the conversion matrix buffer so the video actually shows up
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
2026-01-15 17:01:41 +05:30
335e8fdbef feat: move cuda to linux
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
2026-01-14 15:55:49 +05:30
9dac0b6c78 feat(iced-video): added video format to the video frame
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
2026-01-14 09:51:56 +05:30
uttarayan21
97a7a632d4 feat(iced-video): implement planar YUV texture support with HDR conversion matrices and update dependencies
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-04 23:02:47 +05:30
uttarayan21
29390140cd feat(settings): simplify form updates and temporarily disable server toggler 2025-12-27 00:13:54 +05:30
uttarayan21
97c2b3f14c feat(settings): implement user and server form handling with update functions and UI views 2025-12-27 00:04:42 +05:30
uttarayan21
2b2e8060e7 feat(ui-iced): implement settings screen with navigation and basic UI elements
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
2025-12-26 21:21:58 +05:30
uttarayan21
584495453f feat: Many more improvements to video player now with a subscription 2025-12-26 19:06:40 +05:30
uttarayan21
99853167df feat(config): enable unfree packages, add CUDA toolkit
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
2025-12-26 10:43:15 +05:30
uttarayan21
fc9555873b refactor: move PlayFlags defaults into Playbin3 and clean up unused prelude imports
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
2025-12-26 10:39:00 +05:30
uttarayan21
a7ffa69326 fix(iced-video): Fix the very high ram usage
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
feat(playback): add GstPlayFlags for playbin and playbin3
2025-12-26 10:29:31 +05:30
uttarayan21
4ed15c97f0 feat: Add keybinds to minimal example 2025-12-25 21:43:55 +05:30
uttarayan21
a2491695b3 fix(video): try to optimize memory leaks
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
2025-12-25 06:28:52 +05:30
uttarayan21
5a0bdae84b fix: Try to minimize frame latency 2025-12-25 05:48:51 +05:30
uttarayan21
5d0b795ba5 feat: Added readme and forgotten id.rs
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
2025-12-25 02:15:43 +05:30
uttarayan21
ebe2312272 feat: Get iced-video working 2025-12-25 02:14:56 +05:30
uttarayan21
3382aebb1f feat: Added PipelineExt trait for all Children of Pipelines
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
2025-12-23 01:33:54 +05:30
uttarayan21
8d46bd2b85 feat: Restructure the gst parent<->child relations 2025-12-23 01:09:01 +05:30
uttarayan21
043d1e99f0 feat: Modify gst crate to add lot of more granularity 2025-12-22 13:27:30 +05:30
uttarayan21
d42ef3b550 feat(gst): enhance GStreamer integration with new modules and improved API
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
This commit introduces significant enhancements to the GStreamer integration by:
- Adding new modules for bins, caps, elements, pads, and plugins
- Implementing a more ergonomic API with helper methods like play(), pause(), ready()
- Adding support for various GStreamer plugins including app, autodetect, playback, and videoconvertscale
- Improving error handling with better context attachment
- Updating dependencies to latest versions including gstreamer-video 0.24.4
- Refactoring existing code to use modern Rust patterns and features
2025-12-17 23:35:05 +05:30
uttarayan21
21cbaff610 feat(gst): implement Playbin3 wrapper with basic playback functionality
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
2025-12-17 14:08:17 +05:30
uttarayan21
a0bda88246 feat(gst): add glib dependency and update video texture handling 2025-12-17 14:07:53 +05:30
uttarayan21
ccae03d105 feat: enable proper detection of hdr texture
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
2025-12-16 19:14:20 +05:30
uttarayan21
232c0f4d53 chore: Update .gitignore file
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
2025-12-16 14:50:49 +05:30
uttarayan21
5cec7821d0 feat: Move perf to linux only packages and update Cargo.lock file
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
2025-12-16 14:50:00 +05:30
uttarayan21
c2fdedf05a feat(examples): update package name and add perf ignore rules
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
2025-12-16 02:26:54 +05:30
uttarayan21
7003002b69 chore: remove unused rust-analyzer target files and fix compilation errors in jello-test example 2025-12-16 02:25:36 +05:30
uttarayan21
c675c29be3 chore(gst): Remove flake files and configurations from gst 2025-12-16 02:25:09 +05:30
uttarayan21
7f9152e8fd feat(gst): Added gst a high level wrapper over gstreamer
chore(example): Added hdr-gstreamer-wgpu example
chore(license): Added MIT license to all crates
2025-12-16 02:23:30 +05:30
uttarayan21
6cc83ba655 chore: remove iced_video_player crate and its dependencies 2025-12-15 17:59:40 +05:30
uttarayan21
253d27c176 feat: Update iced_video_player to master 2025-12-13 03:40:12 +05:30
uttarayan21
c7afcd3f0d fix: remove debug statements from video playback initialization 2025-12-09 23:56:20 +05:30
uttarayan21
d75a2fb7e4 feat(ui): comment out gpui ui code and improve iced ui logic 2025-12-09 23:46:00 +05:30
uttarayan21
73fcf9bad1 feat: add jello-types crate and update dependencies with backtrace support 2025-12-09 23:28:51 +05:30
uttarayan21
05ae9ff570 feat(store): add database storage with redb and bson support
This commit introduces a new `store` crate that provides database functionality using redb for storage and bson for serialization. It includes tables for users, servers, and settings, along with async operations for getting, inserting, modifying, and removing data. The store supports UUID keys and integrates with the existing Jellyfin client authentication flow.

The changes also include:
- Adding new dependencies to Cargo.lock for bitvec, bson, deranged, funty, num-conv, powerfmt, radium, serde_bytes, simdutf8, time, and wyz
- Updating Cargo.toml to include the new store crate in workspace members
- Modifying ui-iced to use the new database initialization flow with config loading from TOML
- Adding a settings module to ui-iced with UI components for managing server and user configuration
- Implementing secret string handling for sensitive data like passwords
- Updating API client to support pre-authenticated clients with cached tokens
2025-11-26 16:15:41 +05:30
uttarayan21
ca1fd2e977 feat: Update the api crate 2025-11-25 18:48:13 +05:30
uttarayan21
77fe7b6bb4 feat: Update compilation for macos
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
2025-11-22 05:03:07 +05:30
uttarayan21
0fb5cb1d99 feat: Move stuff under linux
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
2025-11-22 04:42:37 +05:30
uttarayan21
61a2ea1733 feat: Added stuff
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
2025-11-22 04:39:42 +05:30
uttarayan21
b1cfc19b96 feat: Added iced_video_player
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
2025-11-20 21:59:47 +05:30
uttarayan21
f41625e0ed feat: Added stuff
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
2025-11-19 17:01:14 +05:30
uttarayan21
29674df85e feat: Use fluid grid
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
2025-11-19 02:29:04 +05:30
uttarayan21
442a7e49b2 feat(ui): enhance BlurHash and navigation functionality
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
- Modify BlurHash struct to accept iced::Length for dimension
- Add Back and Home navigation messages
- Implement scrollable container and button interactions
2025-11-19 02:16:47 +05:30
uttarayan21
a1c36e4fb2 feat: Initial working prototype
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
2025-11-19 01:39:20 +05:30
uttarayan21
3222c26bb6 feat: Update to latest iced 2025-11-18 23:54:27 +05:30
uttarayan21
a6ef6ba9c0 feat: Update flake inputs and remove flycheck files from rust-analyzer
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
2025-11-18 16:50:57 +05:30
uttarayan21
e50e08dc57 feat: Added iced-ui and updated typegen for docs
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
2025-11-18 16:45:43 +05:30
78 changed files with 11099 additions and 15236 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@
.session
api/config.toml
api/items.json
config.toml

3375
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,28 @@
[workspace]
members = [".", "api", "typegen", "ui-gpui", "ui-iced"]
members = [
".",
"api",
"typegen",
"ui-gpui",
"ui-iced",
"store",
"jello-types",
"gst",
"examples/hdr-gstreamer-wgpu",
"crates/iced-video",
]
[workspace.dependencies]
iced = { version = "0.14.0" }
gst = { version = "0.1.0", path = "gst" }
iced_wgpu = { version = "0.14.0" }
iced-video = { version = "0.1.0", path = "crates/iced-video" }
[patch.crates-io]
iced_wgpu = { git = "https://github.com/uttarayan21/iced", branch = "0.14" }
iced_core = { git = "https://github.com/uttarayan21/iced", branch = "0.14" }
iced_renderer = { git = "https://github.com/uttarayan21/iced", branch = "0.14" }
iced_futures = { git = "https://github.com/uttarayan21/iced", branch = "0.14" }
iced = { git = "https://github.com/uttarayan21/iced", branch = "0.14" }
[package]
name = "jello"
@@ -9,15 +32,23 @@ license = "MIT"
[dependencies]
api = { version = "0.1.0", path = "api" }
blurhash = "0.2.3"
bytemuck = { version = "1.24.0", features = ["derive"] }
clap = { version = "4.5", features = ["derive"] }
clap-verbosity-flag = { version = "3.0.4", features = ["tracing"] }
clap_complete = "4.5"
color-backtrace = "0.7.2"
dotenvy = "0.15.7"
error-stack = "0.6"
gpui = { version = "0.2.2", default-features = false, features = ["wayland"] }
image = "0.25.9"
tap = "1.0.1"
thiserror = "2.0"
tokio = { version = "1.43.1", features = ["macros", "rt-multi-thread"] }
toml = "0.9.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
ui-gpui = { version = "0.1.0", path = "ui-gpui", optional = true }
ui-iced = { version = "0.1.0", path = "ui-iced", optional = true }
[features]
default = ["iced"]
iced = ["dep:ui-iced"]
gpui = ["dep:ui-gpui"]

112
README.md Normal file
View File

@@ -0,0 +1,112 @@
# Jello
A WIP video client for jellyfin.
(Planned) Features
1. Integrate with jellyfin
2. HDR video playback
3. Audio Track selection
4. Chapter selection
Libraries and frameworks used for this
1. iced -> primary gui toolkit
2. gstreamer -> primary video + audio decoding library
3. wgpu -> rendering the video from gstreamer in iced
### HDR
I'll try to document all my findings about HDR here.
I'm making this project to mainly learn about videos, color-spaces and gpu programming. And so very obviously I'm bound to make mistakes in either the code or the fundamental understanding of a concept. Please don't take anything in this text as absolute.
```rust
let window = ... // use winnit to get a window handle, check the example in this repo
let instance = wgpu::Instance::default();
let surface = instance.create_surface(window).unwrap();
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::default(),
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await
.context("Failed to request wgpu adapter")?;
let caps = surface.get_capabilities();
println!("{:#?}", caps.formats);
```
This should print out all the texture formats that can be used by your current hardware
Among these the formats that support hdr (afaik) are
```
wgpu::TextureFormat::Rgba16Float
wgpu::TextureFormat::Rgba32Float
wgpu::TextureFormat::Rgb10a2Unorm
wgpu::TextureFormat::Rgb10a2Uint // (unsure)
```
My display supports Rgb10a2Unorm so I'll be going forward with that texture format.
`Rgb10a2Unorm` is still the same size as a `Rgba8Unorm` but data is in a different representation in each of them
`Rgb10a2Unorm`:
R, G, B => 10 bits each (2^10 = 1024 [0..=1023])
A => 2 bits (2^2 = 4 [0..=3])
Whereas in a normal pixel
`Rgba8Unorm`
R, G, B, A => 8 bits each (2^8 = 256 [0..=255])
For displaying videos the alpha components is not really used (I don't know of any) so we can use re-allocate 6 bits from the alpha channel and put them in the r,g and b components.
In the shader the components get uniformly normalized from [0..=1023] integer to [0..=1] in float so we can compute them properly
Videos however are generally not stored in this format or any rgb format in general because it is not as efficient for (lossy) compression as YUV formats.
Right now I don't want to deal with yuv formats so I'll use gstreamer caps to convert the video into `Rgba10a2` format
## Pixel formats and Planes
Dated: Sun Jan 4 09:09:16 AM IST 2026
| value | count | quantile | percentage | frequency |
| --- | --- | --- | --- | --- |
| yuv420p | 1815 | 0.5067001675041876 | 50.67% | ************************************************** |
| yuv420p10le | 1572 | 0.4388609715242881 | 43.89% | ******************************************* |
| yuvj420p | 171 | 0.04773869346733668 | 4.77% | **** |
| rgba | 14 | 0.003908431044109436 | 0.39% | |
| yuvj444p | 10 | 0.0027917364600781687 | 0.28% | |
For all of my media collection these are the pixel formats for all the videos
### RGBA
Pretty self evident
8 channels for each of R, G, B and A
Hopefully shouldn't be too hard to make a function or possibly a lut that takes data from rgba and maps it to Rgb10a2Unorm
```mermaid
packet
title RGBA
+8: "R"
+8: "G"
+8: "B"
+8: "A"
```
### YUV
[All YUV formats](https://learn.microsoft.com/en-us/windows/win32/medfound/recommended-8-bit-yuv-formats-for-video-rendering#surface-definitions)
[10 and 16 bit yuv formats](https://learn.microsoft.com/en-us/windows/win32/medfound/10-bit-and-16-bit-yuv-video-formats)
Y -> Luminance
U,V -> Chrominance
p -> Planar
sp -> semi planar
j -> full range
planar formats have each of the channels in a contiguous array one after another
in semi-planar formats the y channel is seperate and uv channels are interleaved
## Chroma Subsampling

View File

@@ -2,9 +2,12 @@
name = "api"
version = "0.1.0"
edition = "2024"
license = "MIT"
[dependencies]
bytes = "1.11.0"
iref = { version = "3.2.2", features = ["serde"] }
jiff = { version = "0.2.16", features = ["serde"] }
reqwest = { version = "0.12.24", features = ["json"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
@@ -12,6 +15,9 @@ tap = "1.0.1"
thiserror = "2.0.17"
tokio = { version = "1.48.0", features = ["fs"] }
toml = "0.9.8"
tracing = "0.1.41"
url = "2.5.7"
uuid = { version = "1.18.1", features = ["serde"] }
[dev-dependencies]
tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] }

View File

@@ -2,15 +2,31 @@ use api::*;
#[tokio::main]
pub async fn main() {
let config = std::fs::read_to_string("config.toml").expect("Config.toml");
let config: JellyfinConfig =
toml::from_str(&config).expect("Failed to parse config.toml");
let config: JellyfinConfig = toml::from_str(&config).expect("Failed to parse config.toml");
let mut jellyfin = JellyfinClient::new(config);
jellyfin.authenticate_with_cached_token(".session").await.expect("Auth");
let items = jellyfin.raw_items().await.expect("Items");
let mut jellyfin = JellyfinClient::new_with_config(config);
jellyfin
.authenticate_with_cached_token(".session")
.await
.expect("Auth");
let items = jellyfin.items(None).await.expect("Items");
std::fs::write(
"items.json",
serde_json::to_string_pretty(&items).expect("Serialize items"),
);
for item in items {
println!("{}: {:?}", item.id, item.name);
let items = jellyfin.items(item.id).await.expect("Items");
std::fs::write(
format!("items_{:?}.json", item.name),
serde_json::to_string_pretty(&items).expect("Serialize items"),
);
// for item in items {
// println!(" {}: {:?}", item.id, item.name);
// std::fs::write(
// format!("item_{}.json", item.id),
// serde_json::to_string_pretty(&item).expect("Serialize item"),
// );
// }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
pub mod jellyfin;
use std::sync::Arc;
use ::tap::*;
use reqwest::Method;
use reqwest::{Method, header::InvalidHeaderValue};
use serde::{Deserialize, Serialize};
#[derive(thiserror::Error, Debug)]
@@ -12,6 +14,10 @@ pub enum JellyfinApiError {
SerdeError(#[from] serde_json::Error),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Unknown Jellyfin API error")]
InvalidHeader(#[from] InvalidHeaderValue),
#[error("Unknown Jellyfin API error")]
Unknown,
}
type Result<T, E = JellyfinApiError> = std::result::Result<T, E>;
@@ -19,22 +25,64 @@ type Result<T, E = JellyfinApiError> = std::result::Result<T, E>;
#[derive(Debug, Clone)]
pub struct JellyfinClient {
client: reqwest::Client,
access_token: Option<String>,
config: JellyfinConfig,
access_token: Option<Arc<str>>,
pub config: Arc<JellyfinConfig>,
}
impl JellyfinClient {
pub fn new(config: JellyfinConfig) -> Self {
pub async fn authenticate(
username: impl AsRef<str>,
password: impl AsRef<str>,
config: JellyfinConfig,
) -> Result<Self> {
let url = format!("{}/Users/AuthenticateByName", config.server_url);
let client = reqwest::Client::new();
let token = client
.post(url)
.json(&jellyfin::AuthenticateUserByName {
username: Some(username.as_ref().to_string()),
pw: Some(password.as_ref().to_string()),
})
.send()
.await?
.error_for_status()?
.json::<jellyfin::AuthenticationResult>()
.await?
.access_token
.ok_or_else(|| std::io::Error::other("No field access_token in auth response"))?;
Self::pre_authenticated(token, config)
}
pub fn pre_authenticated(token: impl AsRef<str>, config: JellyfinConfig) -> Result<Self> {
let auth_header = core::iter::once((
reqwest::header::HeaderName::from_static("x-emby-authorization"),
reqwest::header::HeaderValue::from_str(&format!(
"MediaBrowser Client=\"{}\", Device=\"{}\", DeviceId=\"{}\", Version=\"{}\"",
config.client_name, config.device_name, config.device_id, config.version
))?,
))
.collect();
let client = reqwest::Client::builder()
.default_headers(auth_header)
.build()?;
Ok(Self {
client,
access_token: Some(token.as_ref().to_string().into()),
config: Arc::new(config),
})
}
pub fn new_with_config(config: JellyfinConfig) -> Self {
JellyfinClient {
client: reqwest::Client::new(),
access_token: None,
config,
config: Arc::new(config),
}
}
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
tokio::fs::write(path, &**token).await
} else {
Err(std::io::Error::new(
std::io::ErrorKind::Other,
@@ -43,10 +91,17 @@ impl JellyfinClient {
}
}
pub async fn load_token(&mut self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
pub async fn load_token(
&mut self,
path: impl AsRef<std::path::Path>,
) -> std::io::Result<String> {
let token = tokio::fs::read_to_string(path).await?;
self.access_token = Some(token);
Ok(())
self.access_token = Some(token.clone().into());
Ok(token)
}
pub fn set_token(&mut self, token: impl AsRef<str>) {
self.access_token = Some(token.as_ref().into());
}
pub fn request_builder(
@@ -59,7 +114,7 @@ impl JellyfinClient {
.header("X-Emby-Authorization", format!("MediaBrowser Client=\"Jello\", Device=\"Jello\", DeviceId=\"{}\", Version=\"1.0.0\"", self.config.device_id))
.pipe(|builder| {
if let Some(token) = &self.access_token {
builder.header("X-MediaBrowser-Token", token)
builder.header("X-MediaBrowser-Token", &**token)
} else {
builder
}
@@ -108,32 +163,6 @@ impl JellyfinClient {
Ok(out)
}
pub async fn authenticate(&mut self) -> Result<jellyfin::AuthenticationResult> {
let auth_result: jellyfin::AuthenticationResult = self
.post(
"Users/AuthenticateByName",
&jellyfin::AuthenticateUserByName {
username: Some(self.config.username.clone()),
pw: Some(self.config.password.clone()),
},
)
.await?;
self.access_token = auth_result.access_token.clone();
Ok(auth_result)
}
pub async fn authenticate_with_cached_token(
&mut self,
path: impl AsRef<std::path::Path>,
) -> Result<()> {
let path = path.as_ref();
if !self.load_token(path).await.is_ok() {
self.authenticate().await?;
self.save_token(path).await?;
}
Ok(())
}
pub async fn raw_items(&self) -> Result<jellyfin::BaseItemDtoQueryResult> {
let text = &self
.request_builder(Method::GET, "Items")
@@ -148,7 +177,7 @@ impl JellyfinClient {
pub async fn items(
&self,
root: impl Into<Option<String>>,
root: impl Into<Option<uuid::Uuid>>,
) -> Result<Vec<jellyfin::BaseItemDto>> {
let text = &self
.request_builder(Method::GET, "Items")
@@ -161,49 +190,81 @@ impl JellyfinClient {
let out: jellyfin::BaseItemDtoQueryResult = serde_json::from_str(&text)?;
Ok(out.items)
}
}
// pub trait Item {
// fn id(&self) -> &str;
// fn name(&self) -> &str;
// fn type_(&self) -> jellyfin::BaseItemKind;
// fn media_type(&self) -> &str;
// }
pub async fn search(&self, query: impl AsRef<str>) -> Result<Vec<jellyfin::BaseItemDto>> {
let text = &self
.request_builder(Method::GET, "Items/Search")
.query(&[("searchTerm", query.as_ref()), ("recursive", "true")])
.send()
.await?
.error_for_status()?
.text()
.await?;
let out: jellyfin::BaseItemDtoQueryResult = serde_json::from_str(&text)?;
Ok(out.items)
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JellyfinConfig {
pub username: String,
pub password: String,
pub server_url: iref::IriBuf,
pub device_id: String,
}
pub async fn thumbnail(
&self,
item: uuid::Uuid,
image_type: jellyfin::ImageType,
) -> Result<bytes::Bytes> {
let uri = format!(
"Items/{}/Images/{}",
item,
serde_json::to_string(&image_type).expect("Failed to serialize image type")
);
let bytes = self
.request_builder(Method::GET, uri)
.send()
.await?
.error_for_status()?
.bytes()
.await?;
Ok(bytes)
}
impl JellyfinConfig {
pub fn new(
username: String,
password: String,
server_url: impl AsRef<str>,
device_id: String,
) -> Self {
JellyfinConfig {
username,
password,
server_url: iref::IriBuf::new(server_url.as_ref().into())
.expect("Failed to parse server URL"),
device_id,
}
pub async fn playback_info(&self, item: uuid::Uuid) -> Result<jellyfin::PlaybackInfoDto> {
let uri = format!("Items/{}/PlaybackInfo", item);
let text = &self
.request_builder(Method::GET, uri)
.send()
.await?
.error_for_status()?
.text()
.await?;
let out: jellyfin::PlaybackInfoDto = serde_json::from_str(&text)?;
Ok(out)
}
pub async fn user_data(&self, item: uuid::Uuid) -> Result<jellyfin::UserItemDataDto> {
let uri = format!("UserItems/{}/UserData", item);
let text = &self
.request_builder(Method::GET, uri)
.send()
.await?
.error_for_status()?
.text()
.await?;
let out: jellyfin::UserItemDataDto = serde_json::from_str(&text)?;
Ok(out)
}
pub fn stream_url(&self, item: uuid::Uuid) -> Result<url::Url> {
let stream_url = format!(
"{}/Videos/{}/stream?static=true",
self.config.server_url.as_str(),
item,
);
Ok(url::Url::parse(&stream_url).expect("Failed to parse stream URL"))
}
}
#[test]
fn test_client_authenticate() {
let config = JellyfinConfig {
username: "servius".to_string(),
password: "nfz6yqr_NZD1nxk!faj".to_string(),
server_url: iref::IriBuf::new("https://jellyfin.tsuba.darksailor.dev".into()).unwrap(),
device_id: "testdeviceid".to_string(),
};
let mut client = JellyfinClient::new(config);
let auth_result = tokio_test::block_on(client.authenticate());
assert!(auth_result.is_ok());
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JellyfinConfig {
pub server_url: iref::IriBuf,
pub device_id: String,
pub device_name: String,
pub client_name: String,
pub version: String,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
[package]
name = "iced-video"
version = "0.1.0"
edition = "2024"
[dependencies]
bytemuck = "1.24.0"
error-stack = "0.6.0"
futures-lite = "2.6.1"
gst.workspace = true
iced_core = "0.14.0"
iced_futures = "0.14.0"
iced_renderer = { version = "0.14.0", features = ["iced_wgpu"] }
iced_wgpu = { version = "0.14.0" }
thiserror = "2.0.17"
tracing = "0.1.43"
wgpu = { version = "27.0.1", features = ["vulkan"] }
[dev-dependencies]
iced.workspace = true
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

@@ -0,0 +1,178 @@
use iced_video::{Video, VideoHandle};
pub fn main() -> iced::Result {
use tracing_subscriber::prelude::*;
tracing_subscriber::registry()
.with(
tracing_subscriber::fmt::layer()
.with_thread_ids(true)
.with_file(true),
)
.with(tracing_subscriber::EnvFilter::from_default_env())
.init();
iced::application(State::new, update, view)
.subscription(|state| {
// Foo
match &state.video {
Some(video) => video.subscription_with(state, keyboard_event),
None => keyboard_event(state),
}
})
.run()
}
fn keyboard_event(_state: &State) -> iced::Subscription<Message> {
use iced::keyboard::{Key, key::Named};
iced::keyboard::listen().map(move |event| match event {
iced::keyboard::Event::KeyPressed { key, .. } => {
let key = key.as_ref();
match key {
Key::Named(Named::Escape) | Key::Character("q") => Message::Quit,
Key::Character("f") => Message::Fullscreen,
Key::Named(Named::Space) => Message::Toggle,
_ => Message::Noop,
}
}
_ => Message::Noop,
})
}
#[derive(Debug, Clone)]
pub struct State {
video: Option<VideoHandle<Message>>,
fullscreen: bool,
}
impl State {
pub fn new() -> (Self, iced::Task<Message>) {
(
Self {
video: None,
fullscreen: false,
},
iced::Task::done(Message::Load),
)
}
}
#[derive(Debug, Clone)]
pub enum Message {
Play,
Pause,
Toggle,
Noop,
Load,
Fullscreen,
OnLoad(VideoHandle<Message>),
OnError(String),
NewFrame,
Eos,
Quit,
}
pub fn update(state: &mut State, message: Message) -> iced::Task<Message> {
match message {
Message::NewFrame => {
iced::Task::none()
}
Message::Eos => {
iced::Task::done(Message::Pause)
}
Message::Load => {
iced::Task::perform(
VideoHandle::load(
"https://jellyfin.tsuba.darksailor.dev/Items/6010382cf25273e624d305907010d773/Download?api_key=036c140222464878862231ef66a2bc9c",
),
|result| match result {
Ok(video) => Message::OnLoad(video),
Err(err) => Message::OnError(format!("Error loading video: {:?}", err)),
},
).chain(iced::Task::done(Message::Play))
}
Message::OnError(err) => {
eprintln!("Error: {}", err);
iced::Task::none()
}
Message::OnLoad(video) => {
state.video = Some(video.on_new_frame(Message::NewFrame).on_end_of_stream(Message::Eos));
iced::Task::none()
}
Message::Fullscreen => {
state.fullscreen = !state.fullscreen;
let fullscreen = state.fullscreen;
let mode = if fullscreen {
iced::window::Mode::Fullscreen
} else {
iced::window::Mode::Windowed
};
iced::window::oldest().and_then(move |id| iced::window::set_mode::<Message>(id, mode))
}
Message::Play => {
state
.video
.as_ref()
.unwrap()
.source()
.play()
.expect("Failed to play video");
iced::Task::none()
}
Message::Pause => {
state
.video
.as_ref()
.unwrap()
.source()
.pause()
.expect("Failed to pause video");
iced::Task::none()
}
Message::Toggle => {
state
.video
.as_ref()
.unwrap()
.source()
.toggle()
.expect("Failed to stop video");
iced::Task::none()
}
Message::Quit => {
state
.video
.as_ref()
.unwrap()
.source()
.stop()
.expect("Failed to stop video");
std::process::exit(0);
}
Message::Noop => iced::Task::none(),
}
}
pub fn view<'a>(state: &'a State) -> iced::Element<'a, Message> {
if let None = &state.video {
return iced::widget::Column::new()
.push(iced::widget::Text::new("Press any key to load video"))
.align_x(iced::Alignment::Center)
.into();
}
let video_widget = Video::new(&state.video.as_ref().unwrap())
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.content_fit(iced::ContentFit::Contain);
iced::widget::Column::new()
.push(video_widget)
.push(
iced::widget::Row::new()
.push(iced::widget::Button::new("Play").on_press(Message::Play))
.push(iced::widget::Button::new("Pause").on_press(Message::Pause))
.spacing(5)
.padding(10)
.align_y(iced::Alignment::Center),
)
.align_x(iced::Alignment::Center)
.into()
}

View File

@@ -0,0 +1,8 @@
info:
RUST_LOG=info,wgpu_core=warn,wgpu_hal=warn cargo run --release --example minimal
# GST_DEBUG=5 RUST_LOG="" cargo run --release --example minimal
flame:
cargo flamegraph run --release --example minimal
heaptrack:
cargo build --release --example minimal
RUST_LOG="info,wgpu_hal=info" heaptrack $CARGO_TARGET_DIR/release/examples/minimal

View File

@@ -0,0 +1,55 @@
use std::borrow;
use std::sync::atomic::{self, AtomicUsize};
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
/// The identifier of a generic widget.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Id(Internal);
impl Id {
/// Creates a new [`Id`] from a static `str`.
pub const fn new(id: &'static str) -> Self {
Self(Internal::Custom(borrow::Cow::Borrowed(id)))
}
/// Creates a unique [`Id`].
///
/// This function produces a different [`Id`] every time it is called.
pub fn unique() -> Self {
let id = NEXT_ID.fetch_add(1, atomic::Ordering::Relaxed);
Self(Internal::Unique(id))
}
}
impl From<&'static str> for Id {
fn from(value: &'static str) -> Self {
Self::new(value)
}
}
impl From<String> for Id {
fn from(value: String) -> Self {
Self(Internal::Custom(borrow::Cow::Owned(value)))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
enum Internal {
Unique(usize),
Custom(borrow::Cow<'static, str>),
}
#[cfg(test)]
mod tests {
use super::Id;
#[test]
fn unique_generates_different_ids() {
let a = Id::unique();
let b = Id::unique();
assert_ne!(a, b);
}
}

View File

@@ -0,0 +1,164 @@
pub mod id;
pub mod primitive;
pub mod source;
pub mod widget;
pub use widget::Video;
use error_stack::{Report, ResultExt};
use gst::plugins::app::AppSink;
use gst::plugins::playback::Playbin3;
use gst::plugins::videoconvertscale::VideoConvert;
#[derive(Debug, thiserror::Error)]
#[error("Iced Video Error")]
pub struct Error;
pub type Result<T, E = Report<Error>> = core::result::Result<T, E>;
use std::sync::{Arc, Mutex, atomic::AtomicBool};
mod seal {
pub trait Sealed {}
impl Sealed for super::Unknown {}
impl Sealed for super::Ready {}
}
pub trait State: seal::Sealed {
fn is_ready() -> bool {
false
}
}
#[derive(Debug, Clone)]
pub struct Unknown;
#[derive(Debug, Clone)]
pub struct Ready;
impl State for Unknown {}
impl State for Ready {
fn is_ready() -> bool {
true
}
}
/// This is the video handle that is used to control the video playback.
/// This should be keps in the application state.
#[derive(Debug, Clone)]
pub struct VideoHandle<Message, S: State = Unknown> {
id: id::Id,
pub source: source::VideoSource,
frame_ready: Arc<AtomicBool>,
on_new_frame: Option<Box<Message>>,
on_end_of_stream: Option<Box<Message>>,
on_about_to_finish: Option<Box<Message>>,
__marker: core::marker::PhantomData<S>,
}
impl<Message: Send + Sync + Clone> VideoHandle<Message, Unknown> {
pub fn new(url: impl AsRef<str>) -> Result<Self> {
let source = source::VideoSource::new(url)?;
let frame_ready = Arc::clone(&source.ready);
Ok(Self {
id: id::Id::unique(),
source: source,
on_new_frame: None,
on_end_of_stream: None,
on_about_to_finish: None,
frame_ready,
__marker: core::marker::PhantomData,
})
}
/// Creates a new video handle and waits for the metadata to be loaded.
pub async fn load(url: impl AsRef<str>) -> Result<VideoHandle<Message, Ready>> {
let handle = VideoHandle::new(url)?;
handle.wait().await
}
}
impl<Message: Send + Sync + Clone, S: State> VideoHandle<Message, S> {
pub fn id(&self) -> &id::Id {
&self.id
}
pub fn source(&self) -> &source::VideoSource {
&self.source
}
pub async fn wait(self) -> Result<VideoHandle<Message, Ready>> {
self.source.wait().await?;
Ok(self.state::<Ready>())
}
fn state<S2: State>(self) -> VideoHandle<Message, S2> {
VideoHandle {
id: self.id,
source: self.source,
on_new_frame: self.on_new_frame,
on_end_of_stream: self.on_end_of_stream,
on_about_to_finish: self.on_about_to_finish,
frame_ready: self.frame_ready,
__marker: core::marker::PhantomData,
}
}
// pub fn subscription(&self) -> iced_futures::subscription::Subscription<Message> {
// let sub = widget::VideoSubscription {
// id: self.id.clone(),
// on_end_of_stream: self.on_end_of_stream.clone(),
// on_new_frame: self.on_new_frame.clone(),
// on_about_to_finish: self.on_about_to_finish.clone(),
// bus: self.source.bus.clone(),
// };
// iced_futures::subscription::from_recipe(sub)
// }
//
// pub fn subscription_with<State>(
// &self,
// state: &State,
// f: impl FnOnce(&State) -> iced_futures::subscription::Subscription<Message> + 'static,
// ) -> iced_futures::subscription::Subscription<Message>
// where
// State: Send + Sync + 'static,
// {
// let sub = self.subscription();
// iced_futures::subscription::Subscription::batch([sub, f(state)])
// }
pub fn on_new_frame(self, message: Message) -> Self {
Self {
on_new_frame: Some(Box::new(message)),
..self
}
}
pub fn on_end_of_stream(self, message: Message) -> Self {
Self {
on_end_of_stream: Some(Box::new(message)),
..self
}
}
pub fn on_about_to_finish(self, message: Message) -> Self {
Self {
on_about_to_finish: Some(Box::new(message)),
..self
}
}
pub fn play(&self) {
self.source.play();
}
pub fn pause(&self) {
self.source.pause();
}
pub fn stop(&self) {
self.source.stop();
}
}
impl<Message: Send + Sync + Clone> VideoHandle<Message, Ready> {
pub fn format(&self) -> Result<gst::VideoFormat> {
self.source
.format()
.change_context(Error)
.attach("Failed to get video format")
}
}

View File

@@ -0,0 +1,574 @@
use crate::id;
use gst::videoconvertscale::VideoFormat;
use iced_wgpu::primitive::Pipeline;
use iced_wgpu::wgpu;
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex, atomic::AtomicBool};
#[derive(Clone, Copy, Debug, bytemuck::Zeroable, bytemuck::Pod)]
#[repr(transparent)]
pub struct ConversionMatrix {
matrix: [Vec3f; 3],
}
#[derive(Clone, Copy, Debug, bytemuck::Zeroable, bytemuck::Pod)]
#[repr(C, align(16))]
pub struct Vec3f {
data: [f32; 3],
__padding: u32,
}
impl From<[f32; 3]> for Vec3f {
fn from(value: [f32; 3]) -> Self {
Vec3f {
data: [value[0], value[1], value[2]],
__padding: 0,
}
}
}
impl Vec3f {
pub fn new(x: f32, y: f32, z: f32) -> Self {
Vec3f {
data: [x, y, z],
__padding: 0,
}
}
pub const fn from(data: [f32; 3]) -> Self {
Vec3f {
data: [data[0], data[1], data[2]],
__padding: 0,
}
}
}
// impl ConversionMatrix {
// pub fn desc() -> wgpu::VertexBufferLayout<'static> {
// wgpu::VertexBufferLayout {
// array_stride: core::mem::size_of::<ConversionMatrix>() as wgpu::BufferAddress,
// step_mode: wgpu::VertexStepMode::Vertex,
// attributes: &[
// wgpu::VertexAttribute {
// offset: 0,
// shader_location: 0,
// format: wgpu::VertexFormat::Float32x4,
// },
// wgpu::VertexAttribute {
// offset: 16,
// shader_location: 1,
// format: wgpu::VertexFormat::Float32x4,
// },
// wgpu::VertexAttribute {
// offset: 32,
// shader_location: 2,
// format: wgpu::VertexFormat::Float32x4,
// },
// wgpu::VertexAttribute {
// offset: 48,
// shader_location: 3,
// format: wgpu::VertexFormat::Float32x4,
// },
// ],
// }
// }
// }
pub const BT2020_TO_RGB: ConversionMatrix = ConversionMatrix {
matrix: [
Vec3f::from([1.0, 0.0, 1.4746]),
Vec3f::from([1.0, -0.16455, -0.5714]),
Vec3f::from([1.0, 1.8814, 0.0]),
],
};
pub const BT709_TO_RGB: ConversionMatrix = ConversionMatrix {
matrix: [
Vec3f::from([1.0, 0.0, 1.5748]),
Vec3f::from([1.0, -0.1873, -0.4681]),
Vec3f::from([1.0, 1.8556, 0.0]),
],
};
#[derive(Debug)]
pub struct VideoFrame {
pub id: id::Id,
pub size: wgpu::Extent3d,
pub ready: Arc<AtomicBool>,
pub frame: Arc<Mutex<gst::Sample>>,
pub format: VideoFormat,
}
impl iced_wgpu::Primitive for VideoFrame {
type Pipeline = VideoPipeline;
fn prepare(
&self,
pipeline: &mut Self::Pipeline,
device: &wgpu::Device,
queue: &wgpu::Queue,
bounds: &iced_wgpu::core::Rectangle,
viewport: &iced_wgpu::graphics::Viewport,
) {
let video = pipeline.videos.entry(self.id.clone()).or_insert_with(|| {
let texture = VideoTexture::new(
"iced-video-texture",
self.size,
device,
pipeline.format,
self.format,
);
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("iced-video-texture-bind-group"),
layout: &pipeline.bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&texture.y_texture()),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&texture.uv_texture()),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&pipeline.sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Buffer(
texture
.conversion_matrix_buffer()
.as_entire_buffer_binding(),
),
},
],
});
let matrix = if matches!(self.format, VideoFormat::P01010le | VideoFormat::P016Le) {
BT2020_TO_RGB
} else {
BT709_TO_RGB
};
texture.write_conversion_matrix(&matrix, queue);
VideoFrameData {
id: self.id.clone(),
texture,
bind_group,
conversion_matrix: matrix,
ready: Arc::clone(&self.ready),
}
});
if self.size != video.texture.size() {
let new_texture = video
.texture
.resize("iced-video-texture-resized", self.size, device);
new_texture.write_conversion_matrix(&video.conversion_matrix, queue);
let new_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("iced-video-texture-bind-group"),
layout: &pipeline.bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&new_texture.y_texture()),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&new_texture.uv_texture()),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&pipeline.sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Buffer(
video
.texture
.conversion_matrix_buffer()
.as_entire_buffer_binding(),
),
},
],
});
video.texture = new_texture;
video.bind_group = new_bind_group;
}
if video.ready.load(std::sync::atomic::Ordering::SeqCst) {
let frame = self.frame.lock().expect("BUG: Mutex poisoned");
let buffer = frame
.buffer()
.expect("BUG: Failed to get frame data from gst::Sample");
let data = buffer
.map_readable()
.expect("BUG: Failed to map gst::Buffer readable");
video.texture.write_texture(&data, queue);
drop(data);
video
.ready
.store(false, std::sync::atomic::Ordering::SeqCst);
}
}
fn render(
&self,
pipeline: &Self::Pipeline,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
bounds: &iced_wgpu::core::Rectangle<u32>,
) {
let Some(video) = pipeline.videos.get(&self.id) else {
return;
};
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("iced-video-render-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
render_pass.set_pipeline(&pipeline.pipeline);
render_pass.set_bind_group(0, &video.bind_group, &[]);
render_pass.set_scissor_rect(
bounds.x as _,
bounds.y as _,
bounds.width as _,
bounds.height as _,
);
render_pass.draw(0..3, 0..1);
// self.ready
// .store(false, std::sync::atomic::Ordering::Relaxed);
}
}
/// NV12 or P010 are only supported in DX12 and Vulkan backends.
/// While we can use vulkan with moltenvk on macos, I'd much rather use metal directly
/// Right now only supports interleaved UV formats.
/// For planar formats we would need 3 textures.
/// Also NV12 and P010 textures are not COPY_DST capable
/// This assumes 4:2:0 chroma subsampling (for now).
/// So for 4 Y samples there is 1 U and 1 V sample.
/// This means that the UV texture is half the width and half the height of the Y texture.
#[derive(Debug)]
pub struct VideoTexture {
y: wgpu::Texture,
uv: wgpu::Texture,
size: wgpu::Extent3d,
video_format: VideoFormat,
surface_format: wgpu::TextureFormat,
conversion_matrix_buffer: wgpu::Buffer,
}
impl VideoTexture {
pub fn size(&self) -> wgpu::Extent3d {
self.size
}
pub fn new(
label: &str,
size: wgpu::Extent3d,
device: &wgpu::Device,
surface_format: wgpu::TextureFormat,
video_format: VideoFormat,
) -> Self {
let surface_hdr = surface_format.is_wide();
let video_hdr = matches!(video_format, VideoFormat::P01010le | VideoFormat::P016Le);
if surface_hdr && !video_hdr {
tracing::warn!("Surface texture is HDR but video format is SDR");
} else if !surface_hdr && video_hdr {
tracing::warn!("Video format is HDR but surface does not support HDR");
}
let y_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(&format!("{}-y", label)),
size: wgpu::Extent3d {
width: size.width,
height: size.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R16Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let uv_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(&format!("{}-uv", label)),
size: wgpu::Extent3d {
width: size.width / 2,
height: size.height / 2,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rg16Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("iced-video-conversion-matrix-buffer"),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
size: core::mem::size_of::<ConversionMatrix>() as wgpu::BufferAddress,
mapped_at_creation: false,
});
VideoTexture {
y: y_texture,
uv: uv_texture,
size,
surface_format,
video_format,
conversion_matrix_buffer: buffer,
}
}
// This return the surface texture format, not the video pixel format
pub fn format(&self) -> wgpu::TextureFormat {
self.surface_format
}
pub fn y_texture(&self) -> wgpu::TextureView {
self.y.create_view(&wgpu::TextureViewDescriptor::default())
}
pub fn uv_texture(&self) -> wgpu::TextureView {
self.uv.create_view(&wgpu::TextureViewDescriptor::default())
}
pub fn resize(&self, name: &str, new_size: wgpu::Extent3d, device: &wgpu::Device) -> Self {
VideoTexture::new(name, new_size, device, self.format(), self.pixel_format())
}
pub fn pixel_format(&self) -> VideoFormat {
self.video_format
}
/// This assumes that the data is laid out correctly for the texture format.
pub fn write_texture(&self, data: &[u8], queue: &wgpu::Queue) {
let Self { y, uv, .. } = self;
let y_size = y.size();
let uv_size = uv.size();
let y_data_size = (y_size.width * y_size.height * 2) as usize;
let uv_data_size = (y_data_size / 2) as usize; // UV is interleaved
let y_data = &data[0..y_data_size];
let uv_data = &data[y_data_size..y_data_size + uv_data_size];
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: y,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
y_data,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(y_size.width * 2),
rows_per_image: None,
},
y_size,
);
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: uv,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
uv_data,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(uv_size.width * 4),
rows_per_image: None,
},
uv_size,
);
}
pub fn write_conversion_matrix(&self, matrix: &ConversionMatrix, queue: &wgpu::Queue) {
queue.write_buffer(
&self.conversion_matrix_buffer,
0,
bytemuck::bytes_of(matrix),
);
}
pub fn conversion_matrix_buffer(&self) -> &wgpu::Buffer {
&self.conversion_matrix_buffer
}
}
#[derive(Debug)]
pub struct VideoFrameData {
id: id::Id,
texture: VideoTexture,
bind_group: wgpu::BindGroup,
conversion_matrix: ConversionMatrix,
ready: Arc<AtomicBool>,
}
impl VideoFrameData {
pub fn is_hdr(&self) -> bool {
self.texture.format().is_wide()
}
}
#[derive(Debug)]
pub struct VideoPipeline {
pipeline: wgpu::RenderPipeline,
bind_group_layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
format: wgpu::TextureFormat,
videos: BTreeMap<id::Id, VideoFrameData>,
}
pub trait WideTextureFormatExt {
fn is_wide(&self) -> bool;
}
impl WideTextureFormatExt for wgpu::TextureFormat {
fn is_wide(&self) -> bool {
matches!(
self,
wgpu::TextureFormat::Rgba16Float
| wgpu::TextureFormat::Rgba32Float
| wgpu::TextureFormat::Rgb10a2Unorm
| wgpu::TextureFormat::Rgb10a2Uint
| wgpu::TextureFormat::P010
)
}
}
impl Pipeline for VideoPipeline {
fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self
where
Self: Sized,
{
if format.is_wide() {
tracing::info!("HDR texture format detected: {:?}", format);
}
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("iced-video-texture-bind-group-layout"),
entries: &[
// y
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
multisampled: false,
view_dimension: wgpu::TextureViewDimension::D2,
sample_type: wgpu::TextureSampleType::Float { filterable: true },
},
count: None,
},
// uv
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
multisampled: false,
view_dimension: wgpu::TextureViewDimension::D2,
sample_type: wgpu::TextureSampleType::Float { filterable: true },
},
count: None,
},
// sampler
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
// conversion matrix
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let shader_passthrough =
device.create_shader_module(wgpu::include_wgsl!("shaders/passthrough.wgsl"));
let render_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("iced-video-render-pipeline-layout"),
bind_group_layouts: &[&bind_group_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("iced-video-render-pipeline"),
layout: Some(&render_pipeline_layout),
vertex: wgpu::VertexState {
module: &shader_passthrough,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader_passthrough,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("iced-video-sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
Self {
pipeline,
bind_group_layout,
sampler,
format,
videos: BTreeMap::new(),
}
}
}

View File

@@ -0,0 +1,30 @@
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) tex_coords: vec2<f32>,
}
@vertex
fn vs_main(
@builtin(vertex_index) in_vertex_index: u32,
) -> VertexOutput {
var out: VertexOutput;
let uv = vec2<f32>(f32((in_vertex_index << 1u) & 2u), f32(in_vertex_index & 2u));
out.clip_position = vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
out.clip_position.y = -out.clip_position.y;
out.tex_coords = uv;
return out;
}
@group(0) @binding(0) var y_texture: texture_2d<f32>;
@group(0) @binding(1) var uv_texture: texture_2d<f32>;
@group(0) @binding(2) var texture_sampler: sampler;
@group(0) @binding(3) var<uniform> rgb_primaries: mat3x3<f32>;
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
let y = textureSample(y_texture, texture_sampler, input.tex_coords).r;
let uv = textureSample(uv_texture, texture_sampler, input.tex_coords).rg;
let yuv = vec3f(y, uv.x - 0.5, uv.y - 0.5);
return vec4f(yuv * rgb_primaries, 1.0);
}

View File

@@ -0,0 +1,173 @@
use crate::{Error, Result, ResultExt};
use gst::{
Bus, Gst, MessageType, MessageView, Sink, Source,
app::AppSink,
caps::{Caps, CapsType},
element::ElementExt,
pipeline::PipelineExt,
playback::{PlayFlags, Playbin3},
videoconvertscale::VideoConvert,
};
use std::sync::{Arc, Mutex, atomic::AtomicBool};
#[derive(Debug, Clone)]
pub struct VideoSource {
pub(crate) playbin: Playbin3,
pub(crate) appsink: AppSink,
pub(crate) bus: Bus,
pub(crate) ready: Arc<AtomicBool>,
pub(crate) frame: Arc<Mutex<gst::Sample>>,
pub(crate) size: std::sync::OnceLock<(i32, i32)>,
}
impl VideoSource {
/// Creates a new video source from the given URL.
/// Since this doesn't have to parse the pipeline manually, we aren't sanitizing the URL for
/// now.
pub fn new(url: impl AsRef<str>) -> Result<Self> {
Gst::new();
let mut appsink = AppSink::new("iced-video-sink").change_context(Error)?;
appsink
.drop(true)
.sync(true)
// .async_(true)
.emit_signals(true);
let playbin = Playbin3::new("iced-video")
.change_context(Error)?
.with_uri(url.as_ref())
.with_buffer_duration(core::time::Duration::from_secs(2))
.with_buffer_size(4096 * 4096 * 4 * 3)
.with_ring_buffer_max_size(4096 * 4096 * 4 * 3)
.with_flags(Playbin3::default_flags() | PlayFlags::DOWNLOAD)
.with_video_sink(&appsink);
let bus = playbin.bus().change_context(Error)?;
playbin.pause().change_context(Error)?;
let ready = Arc::new(AtomicBool::new(false));
let frame = Arc::new(Mutex::new(gst::Sample::new()));
appsink.on_new_sample({
let ready = Arc::clone(&ready);
let frame = Arc::clone(&frame);
move |appsink| {
let Ok(sample) = appsink.pull_sample() else {
tracing::error!("Failed to pull video sample from appsink despite being notified of new frame");
return Ok(());
};
{
let mut guard = frame.lock().expect("BUG: Mutex poisoned");
core::mem::replace(&mut *guard, sample);
ready.store(true, std::sync::atomic::Ordering::Relaxed);
}
Ok(())
}
});
Ok(Self {
playbin,
appsink,
bus,
ready,
frame,
size: std::sync::OnceLock::new(),
})
}
pub async fn wait(&self) -> Result<()> {
use futures_lite::StreamExt;
// self.bus_stream()
// .for_each(|msg: gst::Message| {
// use gst::gstreamer::prelude::*;
// match msg.view() {
// MessageView::Eos(_) => {
// tracing::info!("Video reached end of stream");
// }
// MessageView::Error(err) => {
// tracing::error!(
// "Video Error from {:?}: {} ({:?})",
// err.src().map(|s| s.path_string()),
// err.error(),
// err.debug()
// );
// }
// view => tracing::info!("Video Message: {:#?}", view),
// }
// })
// .await;
self.playbin
.wait_for_states(&[gst::State::Paused, gst::State::Playing])
.await
.change_context(Error)
.attach("Failed to wait for video initialisation")?;
Ok(())
}
pub fn format(&self) -> Result<gst::VideoFormat> {
let caps = self
.appsink
.sink("sink")
.current_caps()
.change_context(Error)?;
let format = caps
.format()
.ok_or(Error)
.attach("Failed to get video caps structure")?;
Ok(format)
}
pub fn bus_stream(&self) -> impl futures_lite::Stream<Item = gst::Message> {
self.bus.stream()
}
pub fn is_playing(&self) -> Result<bool> {
let state = self.playbin.state(None).change_context(Error)?;
Ok(state == gst::State::Playing)
}
pub fn toggle(&self) -> Result<()> {
if self.is_playing()? {
self.pause()?;
} else {
self.play()?;
}
Ok(())
}
pub fn play(&self) -> Result<()> {
self.playbin
.play()
.change_context(Error)
.attach("Failed to play video")
}
pub fn pause(&self) -> Result<()> {
self.playbin
.pause()
.change_context(Error)
.attach("Failed to pause video")
}
pub fn stop(&self) -> Result<()> {
self.playbin
.stop()
.change_context(Error)
.attach("Failed to stop video")
}
pub fn size(&self) -> Result<(i32, i32)> {
if let Some(size) = self.size.get() {
return Ok(*size);
}
let caps = self
.appsink
.sink("sink")
.current_caps()
.change_context(Error)?;
let out = caps
.width()
.and_then(|width| caps.height().map(|height| (width, height)))
.ok_or(Error)
.attach("Failed to get width, height")?;
self.size.set(out);
Ok(out)
}
}

View File

@@ -0,0 +1,258 @@
use super::*;
use iced::Length;
use iced_core as iced;
use iced_wgpu::primitive::Renderer as PrimitiveRenderer;
use std::marker::PhantomData;
/// This is the Video widget that displays a video.
/// This should be used in the view function.
pub struct Video<'a, Message, Theme = iced::Theme, Renderer = iced_wgpu::Renderer>
where
Renderer: PrimitiveRenderer,
{
id: id::Id,
handle: &'a VideoHandle<Message, Ready>,
video_format: gst::VideoFormat,
content_fit: iced::ContentFit,
width: iced::Length,
height: iced::Length,
looping: bool,
__marker: PhantomData<(Renderer, Theme)>,
}
impl<'a, Message, Theme, Renderer> Video<'a, Message, Theme, Renderer>
where
Renderer: PrimitiveRenderer,
Message: Clone + Send + Sync,
{
pub fn new(handle: &'a VideoHandle<Message, Ready>) -> Self {
Self {
id: handle.id.clone(),
handle: &handle,
video_format: handle
.format()
.expect("Failed to get video format during widget creation"),
content_fit: iced::ContentFit::Contain,
width: Length::Shrink,
height: Length::Shrink,
looping: false,
__marker: PhantomData,
}
}
}
impl<'a, Message, Theme, Renderer> Video<'a, Message, Theme, Renderer>
where
Renderer: PrimitiveRenderer,
{
pub fn width(mut self, width: Length) -> Self {
self.width = width;
self
}
pub fn height(mut self, height: Length) -> Self {
self.height = height;
self
}
pub fn content_fit(mut self, fit: iced::ContentFit) -> Self {
self.content_fit = fit;
self
}
// pub fn on_end_of_stream(mut self, message: Message) -> Self {
// self.on_end_of_stream = Some(message);
// self
// }
//
// pub fn on_new_frame(mut self, message: Message) -> Self {
// self.on_new_frame = Some(message);
// self
// }
pub fn looping(mut self, looping: bool) -> Self {
self.looping = looping;
self
}
}
impl<Message, Theme, Renderer> iced::Widget<Message, Theme, Renderer>
for Video<'_, Message, Theme, Renderer>
where
Message: Clone + Send + Sync,
Renderer: PrimitiveRenderer,
{
fn size(&self) -> iced::Size<Length> {
iced::Size {
width: self.width,
height: self.height,
}
}
// The video player should take max space by default
fn layout(
&mut self,
_tree: &mut iced::widget::Tree,
_renderer: &Renderer,
limits: &iced::layout::Limits,
) -> iced::layout::Node {
iced::layout::Node::new(limits.max())
}
fn draw(
&self,
tree: &iced::widget::Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &iced::renderer::Style,
layout: iced::Layout<'_>,
cursor: iced::mouse::Cursor,
viewport: &iced::Rectangle,
) {
if let Ok((width, height)) = self.handle.source.size() {
let video_size = iced::Size {
width: width as f32,
height: height as f32,
};
let bounds = layout.bounds();
let adjusted_fit = self.content_fit.fit(video_size, bounds.size());
let scale = iced::Vector::new(
adjusted_fit.width / video_size.width,
adjusted_fit.height / video_size.height,
);
let final_size = video_size * scale;
let position = match self.content_fit {
iced::ContentFit::None => iced::Point::new(
bounds.x + (video_size.width - adjusted_fit.width) / 2.0,
bounds.y + (video_size.height - adjusted_fit.height) / 2.0,
),
_ => iced::Point::new(
bounds.center_x() - final_size.width / 2.0,
bounds.center_y() - final_size.height / 2.0,
),
};
let drawing_bounds = iced::Rectangle::new(position, final_size);
let render = |renderer: &mut Renderer| {
renderer.draw_primitive(
drawing_bounds,
primitive::VideoFrame {
id: self.id.clone(),
size: iced_wgpu::wgpu::Extent3d {
width: width as u32,
height: height as u32,
depth_or_array_layers: 1,
},
ready: Arc::clone(&self.handle.frame_ready),
frame: Arc::clone(&self.handle.source.frame),
format: self.video_format,
},
);
};
if adjusted_fit.width > bounds.width || adjusted_fit.height > bounds.height {
renderer.with_layer(bounds, render);
} else {
render(renderer);
}
}
}
fn update(
&mut self,
_tree: &mut iced_core::widget::Tree,
event: &iced::Event,
_layout: iced_core::Layout<'_>,
_cursor: iced_core::mouse::Cursor,
_renderer: &Renderer,
_clipboard: &mut dyn iced_core::Clipboard,
shell: &mut iced_core::Shell<'_, Message>,
_viewport: &iced::Rectangle,
) {
if let iced::Event::Window(iced::window::Event::RedrawRequested(when)) = event {
if self
.handle
.frame_ready
.load(std::sync::atomic::Ordering::SeqCst)
{
shell.request_redraw();
} else {
shell.request_redraw_at(iced::window::RedrawRequest::At(
iced_core::time::Instant::now() + core::time::Duration::from_millis(16)
- when.elapsed(),
));
}
}
}
}
impl<'a, Message, Theme, Renderer> From<Video<'a, Message, Theme, Renderer>>
for iced::Element<'a, Message, Theme, Renderer>
where
Message: Send + Sync + 'a + Clone,
Theme: 'a,
Renderer: 'a + iced_wgpu::primitive::Renderer,
{
fn from(video: Video<'a, Message, Theme, Renderer>) -> Self {
Self::new(video)
}
}
#[derive(Debug, Clone)]
pub struct VideoSubscription<Message> {
pub(crate) id: id::Id,
pub(crate) on_end_of_stream: Option<Box<Message>>,
pub(crate) on_new_frame: Option<Box<Message>>,
pub(crate) on_about_to_finish: Option<Box<Message>>,
// on_subtitle_text: Option<Box<dyn Fn(Option<String>) -> Message>>,
// on_error: Option<Box<dyn Fn(&glib::Error) -> Message>>,
pub(crate) bus: gst::Bus,
}
impl<Message> VideoSubscription<Message> where Message: Clone {}
impl<Message> iced_futures::subscription::Recipe for VideoSubscription<Message>
where
Message: Clone + Send + Sync + 'static,
{
type Output = Message;
fn hash(&self, state: &mut iced_futures::subscription::Hasher) {
use std::hash::Hash;
self.id.hash(state);
}
fn stream(
self: Box<Self>,
_input: core::pin::Pin<
Box<dyn iced_futures::futures::Stream<Item = iced_futures::subscription::Event> + Send>,
>,
) -> core::pin::Pin<Box<dyn iced_futures::futures::Stream<Item = Self::Output> + Send>> {
// use iced_futures::futures::StreamExt;
use futures_lite::stream::StreamExt;
Box::pin(
self.bus
.filtered_stream(&[gst::MessageType::Eos, gst::MessageType::Element])
.filter_map({
let eos = self.on_end_of_stream.clone();
let frame = self.on_new_frame.clone();
move |message: gst::Message| match message.view() {
gst::MessageView::Eos(_) => eos.clone().map(|m| *m),
gst::MessageView::Element(element_msg) => {
let structure = element_msg.structure();
if let Some(structure) = structure {
if structure.name() == "GstVideoFrameReady" {
frame.clone().map(|m| *m)
} else {
None
}
} else {
None
}
}
_ => None,
}
}),
)
}
}

View File

@@ -92,7 +92,15 @@ allow = [
"MIT",
"Apache-2.0",
"Unicode-3.0",
#"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"Apache-2.0 WITH LLVM-exception",
"Zlib",
"ISC",
"NCSA",
"CC0-1.0",
"BSL-1.0",
# "LGPL",
]
# The confidence threshold for detecting a license from license text.
# The higher the value, the more closely the license text must be to the

View File

@@ -0,0 +1,2 @@
perf*
target/

View File

@@ -0,0 +1,20 @@
[package]
name = "hdr-gstreamer-wgpu"
version = "0.1.0"
edition = "2024"
[dependencies]
# gst = { workspace = true }
wgpu = "27"
gstreamer = { version = "0.24.4", features = ["v1_26"] }
gstreamer-app = { version = "0.24.4", features = ["v1_26"] }
gstreamer-base = { version = "0.24.4", features = ["v1_26"] }
gstreamer-video = { version = "0.24.4", features = ["v1_26"] }
winit = { version = "*", features = ["wayland"] }
anyhow = "*"
pollster = "0.4.0"
tracing = { version = "0.1.43", features = ["log"] }
tracing-subscriber = "0.3.22"
[profile.release]
debug = true

View File

@@ -0,0 +1,592 @@
use std::sync::Arc;
use gstreamer as gst;
use gstreamer_app as gst_app;
use anyhow::{Context, Result};
use winit::{
application::ApplicationHandler,
event::*,
event_loop::{ActiveEventLoop, EventLoop},
keyboard::*,
window::Window,
};
pub struct App {
state: Option<State>,
}
impl App {
pub fn new() -> Self {
Self { state: None }
}
}
pub trait HdrTextureFormatExt {
fn is_hdr_format(&self) -> bool;
}
impl HdrTextureFormatExt for wgpu::TextureFormat {
fn is_hdr_format(&self) -> bool {
matches!(
self,
wgpu::TextureFormat::Rgba16Float
| wgpu::TextureFormat::Rgba32Float
| wgpu::TextureFormat::Rgb10a2Unorm
)
}
}
pub struct State {
window: Arc<Window>,
gst: Video,
surface: wgpu::Surface<'static>,
video_texture: wgpu::Texture,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
pipeline: wgpu::RenderPipeline,
bind_group: wgpu::BindGroup,
is_surface_initialized: bool,
}
impl State {
async fn new(window: Arc<Window>) -> Result<State> {
let instance = wgpu::Instance::default();
let surface = instance
.create_surface(window.clone())
.context("Failed to create wgpu surface")?;
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await
.context("Failed to request wgpu adapter")?;
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: None,
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
memory_hints: wgpu::MemoryHints::default(),
..Default::default()
})
.await
.context("Failed to request wgpu device")?;
let surface_caps = surface.get_capabilities(&adapter);
tracing::info!("Caps: {:#?}", &surface_caps);
let surface_format = surface_caps
.formats
.iter()
.rev() // float one comes first
.find(|f| f.is_hdr_format())
.expect("HDR format not supported")
.clone();
tracing::info!("Using surface format: {:?}", surface_format);
let size = window.inner_size();
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
width: size.width,
height: size.height,
present_mode: surface_caps.present_modes[0],
alpha_mode: surface_caps.alpha_modes[0],
view_formats: vec![],
desired_maximum_frame_latency: 2, // calculate upto 5 frames ahead
};
surface.configure(&device, &config);
let shader = device.create_shader_module(wgpu::include_wgsl!("shader.wgsl"));
let texture_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("texture_bind_group_layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
multisampled: false,
view_dimension: wgpu::TextureViewDimension::D2,
sample_type: wgpu::TextureSampleType::Float { filterable: true },
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let render_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Jello Render Pipeline Layout"),
bind_group_layouts: &[&texture_bind_group_layout],
push_constant_ranges: &[],
});
let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Jello Render Pipeline"),
layout: Some(&render_pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
targets: &[Some(wgpu::ColorTargetState {
format: surface_format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview: None,
cache: None,
});
let texture_size = wgpu::Extent3d {
width: size.width,
height: size.height,
depth_or_array_layers: 1,
};
let video_texture = device.create_texture(&wgpu::TextureDescriptor {
size: texture_size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: surface_format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
label: Some("Jello Video Texture"),
view_formats: &[],
});
// TODO: Use a better sampler
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("texture_sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
layout: &texture_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(
&video_texture.create_view(&wgpu::TextureViewDescriptor::default()),
),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
label: Some("Jello Texture Bind Group"),
});
let gst = Video::new().context("Failed to create Video")?;
std::thread::sleep(std::time::Duration::from_secs(10));
// surface.configure(&device, &config);
Ok(Self {
window,
gst,
surface,
video_texture,
device,
queue,
config,
is_surface_initialized: true,
bind_group,
pipeline: render_pipeline,
})
}
// async fn next_frame(&mut self)
fn resize(&mut self, width: u32, height: u32) {
if width > 0 && height > 0 {
self.config.width = width;
self.config.height = height;
self.surface.configure(&self.device, &self.config);
self.is_surface_initialized = true;
}
}
fn render(&mut self) -> Result<(), wgpu::SurfaceError> {
if !self.is_surface_initialized {
return Ok(());
}
self.gst.poll();
self.copy_next_frame_to_texture()
.inspect_err(|e| {
tracing::error!("Failed to copy video frame to texture: {e:?}");
})
.map_err(|_| wgpu::SurfaceError::Lost)?;
let output = match self.surface.get_current_texture() {
Ok(output) => output,
Err(wgpu::SurfaceError::Lost) => {
self.surface.configure(&self.device, &self.config);
return Ok(());
}
Err(e) => return Err(e),
};
let view = output
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Jello Render Encoder"),
});
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Jello Render Pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.1,
g: 0.2,
b: 0.3,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
timestamp_writes: None,
});
render_pass.set_pipeline(&self.pipeline);
render_pass.set_bind_group(0, &self.bind_group, &[]);
render_pass.draw(0..3, 0..1);
drop(render_pass);
self.queue.submit(std::iter::once(encoder.finish()));
output.present();
self.window.request_redraw();
Ok(())
}
pub fn copy_next_frame_to_texture(&mut self) -> Result<()> {
let frame = self
.gst
.appsink
.try_pull_sample(gst::ClockTime::NONE)
.context("Failed to pull sample from appsink")?;
let caps = frame.caps().context("Failed to get caps from sample")?;
let size = caps
.structure(0)
.context("Failed to get structure from caps")?;
let width = size
.get::<i32>("width")
.context("Failed to get width from caps")? as u32;
let height = size
.get::<i32>("height")
.context("Failed to get height from caps")? as u32;
let texture_size = self.video_texture.size();
if texture_size.width != width || texture_size.height != height {
tracing::info!(
"Resizing video texture from {}x{} to {}x{}",
texture_size.width,
texture_size.height,
width,
height
);
self.video_texture = self.device.create_texture(&wgpu::TextureDescriptor {
size: wgpu::Extent3d {
width: width as u32,
height: height as u32,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.config.format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
label: Some("Jello Video Texture"),
view_formats: &[],
});
let texture_bind_group_layout =
self.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("texture_bind_group_layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
multisampled: false,
view_dimension: wgpu::TextureViewDimension::D2,
sample_type: wgpu::TextureSampleType::Float {
filterable: true,
},
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("texture_sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
self.bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
layout: &texture_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(
&self
.video_texture
.create_view(&wgpu::TextureViewDescriptor::default()),
),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
label: Some("Jello Texture Bind Group"),
});
}
let texture = &self.video_texture;
let buffer = frame.buffer().context("Failed to get buffer from sample")?;
let map = buffer
.map_readable()
.context("Failed to map buffer readable")?;
self.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&map,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4 * width as u32),
rows_per_image: Some(height as u32),
},
texture.size(),
);
// drop(map);
// drop(frame);
Ok(())
}
}
impl ApplicationHandler<State> for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
#[allow(unused_mut)]
let mut window_attributes = Window::default_attributes();
let window = Arc::new(event_loop.create_window(window_attributes).unwrap());
// let monitor = event_loop
// .primary_monitor()
// .or_else(|| window.current_monitor());
// window.set_fullscreen(None);
// window.set_fullscreen(Some(winit::window::Fullscreen::Borderless(monitor)));
self.state = Some(pollster::block_on(State::new(window)).expect("Failed to block"));
}
fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: State) {
self.state = Some(event);
}
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
let state = match &mut self.state {
Some(state) => state,
None => return,
};
state.window.request_redraw();
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: winit::window::WindowId,
event: WindowEvent,
) {
let state = match &mut self.state {
Some(canvas) => canvas,
None => return,
};
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => {
tracing::info!("Window resized to {size:?}");
state.resize(size.width, size.height)
}
WindowEvent::RedrawRequested => {
// if state.gst.poll() {
// event_loop.exit();
// return;
// }
match state.render() {
Ok(_) => {}
// Reconfigure the surface if lost
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
let size = state.window.inner_size();
tracing::info!("Reconfiguring surface to {size:?}");
state.resize(size.width, size.height);
}
// The system is out of memory, we should probably quit
Err(wgpu::SurfaceError::OutOfMemory) => event_loop.exit(),
// All other errors (Outdated, Timeout) should be resolved by the next frame
Err(e) => {
tracing::error!("Failed to render frame: {e:?}");
}
}
}
// WindowEvent::AboutToWait => {
// state.window.request_redraw();
// }
WindowEvent::KeyboardInput {
event:
KeyEvent {
physical_key: PhysicalKey::Code(code),
state,
..
},
..
} => match (code, state.is_pressed()) {
(KeyCode::Escape, true) => event_loop.exit(),
(KeyCode::KeyQ, true) => event_loop.exit(),
_ => {}
},
_ => {}
}
}
}
pub fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
let event_loop = EventLoop::with_user_event().build()?;
let mut app = App::new();
event_loop.run_app(&mut app)?;
Ok(())
}
pub struct Video {
pipeline: gst::Pipeline,
bus: gst::Bus,
appsink: gst_app::AppSink,
}
impl Video {
pub fn new() -> Result<Self> {
gst::init()?;
use gst::prelude::*;
let pipeline = gst::parse::launch(
r##"playbin3 uri=https://jellyfin.tsuba.darksailor.dev/Items/6010382cf25273e624d305907010d773/Download?api_key=036c140222464878862231ef66a2bc9c video-sink="videoconvert ! video/x-raw,format=RGB10A2_LE ! appsink sync=true drop=true name=appsink""##
).context("Failed to parse gst pipeline")?;
let pipeline = pipeline
.downcast::<gst::Pipeline>()
.map_err(|_| anyhow::anyhow!("Failed to downcast gst element to Pipeline"))?;
let video_sink = pipeline.property::<gst::Bin>("video-sink");
let appsink = video_sink
.by_name("appsink")
.context("Failed to get appsink from video-sink")?
.downcast::<gst_app::AppSink>()
.map_err(|_| {
anyhow::anyhow!("Failed to downcast video-sink appsink to gst_app::AppSink")
})?;
// appsink.set_property("max-buffers", 2u32);
// appsink.set_property("emit-signals", true);
// appsink.set_callbacks(
// gst_app::AppSinkCallbacks::builder()
// .new_sample(|_appsink| Ok(gst::FlowSuccess::Ok))
// .build(),
// );
let bus = pipeline.bus().context("Failed to get gst pipeline bus")?;
pipeline.set_state(gst::State::Playing)?;
pipeline
.state(gst::ClockTime::from_seconds(5))
.0
.context("Failed to wait for pipeline")?;
Ok(Self {
pipeline,
bus,
appsink,
})
}
pub fn poll(&mut self) -> bool {
use gst::prelude::*;
for msg in self.bus.iter_timed(gst::ClockTime::ZERO) {
use gst::MessageView;
match msg.view() {
MessageView::Eos(..) => {
tracing::info!("End of stream");
self.pipeline.set_state(gst::State::Null).ok();
return true;
}
MessageView::Error(err) => {
tracing::error!(
"Error from {:?}: {} ({:?})",
err.src().map(|s| s.path_string()),
err.error(),
err.debug()
);
self.pipeline.set_state(gst::State::Null).ok();
return true;
}
_ => {}
}
}
false
}
}

View File

@@ -0,0 +1,31 @@
// Vertex shader
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) tex_coords: vec2<f32>,
};
@vertex
fn vs_main(
@builtin(vertex_index) in_vertex_index: u32,
) -> VertexOutput {
var out: VertexOutput;
let uv = vec2<f32>(f32((in_vertex_index << 1u) & 2u), f32(in_vertex_index & 2u));
out.clip_position = vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
out.clip_position.y = -out.clip_position.y;
out.tex_coords = uv;
return out;
}
// Fragment shader
@group(0) @binding(0)
var t_diffuse: texture_2d<f32>;
@group(0) @binding(1)
var s_diffuse: sampler;
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return textureSample(t_diffuse, s_diffuse, in.tex_coords);
}

41
flake.lock generated
View File

@@ -3,11 +3,11 @@
"advisory-db": {
"flake": false,
"locked": {
"lastModified": 1762774274,
"narHash": "sha256-tigj2sBL6S7zmjpt5JdXtvtGrClvja+/LAnmpU6+MV4=",
"lastModified": 1768679419,
"narHash": "sha256-l9rM4lXBeS2mIAJsJjVfl0UABx3S3zg5tul7bv+bn50=",
"owner": "rustsec",
"repo": "advisory-db",
"rev": "df17e8c0d170b71c0a4cca3f165c30030a526060",
"rev": "c700e1cd023ca87343cbd9217d50d47023e9adc7",
"type": "github"
},
"original": {
@@ -18,11 +18,11 @@
},
"crane": {
"locked": {
"lastModified": 1762538466,
"narHash": "sha256-8zrIPl6J+wLm9MH5ksHcW7BUHo7jSNOu0/hA0ohOOaM=",
"lastModified": 1768873933,
"narHash": "sha256-CfyzdaeLNGkyAHp3kT5vjvXhA1pVVK7nyDziYxCPsNk=",
"owner": "ipetkov",
"repo": "crane",
"rev": "0cea393fffb39575c46b7a0318386467272182fe",
"rev": "0bda7e7d005ccb5522a76d11ccfbf562b71953ca",
"type": "github"
},
"original": {
@@ -34,10 +34,10 @@
"crates-io-index": {
"flake": false,
"locked": {
"lastModified": 1759304910,
"narHash": "sha256-nf7smRifEW3u/d+dATK06T0RtKV8xURkug/8MI4AXlQ=",
"lastModified": 1769614137,
"narHash": "sha256-3Td8fiv6iFVxeS0hYq3xdd10ZvUkC9INMAiQx/mECas=",
"ref": "refs/heads/master",
"rev": "5f523323e8e03c9b94f2f9ed09a291e50dec2f40",
"rev": "c7e7d6394bc95555d6acd5c6783855f47d64c90d",
"shallow": true,
"type": "git",
"url": "https://github.com/rust-lang/crates.io-index"
@@ -50,14 +50,16 @@
},
"crates-nix": {
"inputs": {
"crates-io-index": "crates-io-index"
"crates-io-index": [
"crates-io-index"
]
},
"locked": {
"lastModified": 1759305425,
"narHash": "sha256-Za25XBr6UjZyguuXbctheUNT/6wPhbLs8YSXNTqyXaw=",
"lastModified": 1763364255,
"narHash": "sha256-uHP6YjKDWVaWqcmb/LOxEdZlkVWBvspXqx4+LC1zJsE=",
"owner": "uttarayan21",
"repo": "crates.nix",
"rev": "8879158206af5508a2c740d732bca92ae799c2b5",
"rev": "13d09f3b04e31d2ab3cfbe47540cb425eaf9b750",
"type": "github"
},
"original": {
@@ -106,11 +108,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1762844143,
"narHash": "sha256-SlybxLZ1/e4T2lb1czEtWVzDCVSTvk9WLwGhmxFmBxI=",
"lastModified": 1768564909,
"narHash": "sha256-Kell/SpJYVkHWMvnhqJz/8DqQg2b6PguxVWOuadbHCc=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "9da7f1cf7f8a6e2a7cb3001b048546c92a8258b4",
"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": 1763001554,
"narHash": "sha256-wsfhRTuxu6f06RMmP4JWcq3wWRlmYtQaJZ6b3f+EJ94=",
"lastModified": 1768877311,
"narHash": "sha256-abSDl0cNr0B+YCsIDpO1SjXD9JMxE4s8EFnhLEFVovI=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "315d97eb753cee8e1aa039a5e622b84d32a454bb",
"rev": "59e4ab96304585fde3890025fd59bd2717985cc1",
"type": "github"
},
"original": {

View File

@@ -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";
@@ -35,6 +42,7 @@
system: let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
overlays = [
rust-overlay.overlays.default
];
@@ -43,7 +51,7 @@
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
name = cargoToml.package.name;
toolchain = pkgs.rust-bin.nightly.latest.default;
toolchain = pkgs.rust-bin.stable.latest.default;
toolchainWithLLvmTools = toolchain.override {
extensions = ["rust-src" "llvm-tools"];
};
@@ -56,7 +64,7 @@
src = let
filterBySuffix = path: exts: lib.any (ext: lib.hasSuffix ext path) exts;
sourceFilters = path: type: (craneLib.filterCargoSources path type) || filterBySuffix path [".c" ".h" ".hpp" ".cpp" ".cc"];
sourceFilters = path: type: (craneLib.filterCargoSources path type) || filterBySuffix path [".c" ".h" ".hpp" ".cpp" ".cc" "wgsl"];
in
lib.cleanSourceWith {
filter = sourceFilters;
@@ -70,22 +78,56 @@
nativeBuildInputs = with pkgs; [
pkg-config
];
# LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [pkgs.wayland];
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath buildInputs;
# SYSTEM_DEPS_LINK = "static";
# PKG_CONFIG_ALL_STATIC = "1";
buildInputs = with pkgs;
[
vulkan-loader
gst_all_1.gst-libav
gst_all_1.gst-plugins-base
gst_all_1.gst-plugins-good
gst_all_1.gst-plugins-bad
gst_all_1.gst-plugins-ugly
gst_all_1.gst-plugins-rs
gst_all_1.gstreamer
glib
glib-networking
wrapGAppsHook4
# bzip2_1_1
# libsysprof-capture
# pcre2
# libunwind
# elfutils
# zstd
openssl
vulkan-loader
]
++ (lib.optionals pkgs.stdenv.isLinux [
gst_all_1.gstreamermm
gst_all_1.gst-vaapi
cudatoolkit
# util-linux
# libselinux
# libsepol
alsa-lib-with-plugins
libxkbcommon
udev
wayland
wayland-protocols
# xorg.libX11
# xorg.libXi
# xorg.libXrandr
])
++ (lib.optionals pkgs.stdenv.isDarwin [
libiconv
apple-sdk_13
apple-sdk_26
]);
};
cargoArtifacts = craneLib.buildPackage commonArgs;
@@ -141,35 +183,55 @@
default = pkg;
};
devShells = {
default =
devShells = rec {
rust-shell =
pkgs.mkShell.override {
stdenv = pkgs.clangStdenv;
# stdenv =
# if pkgs.stdenv.isLinux
# then (pkgs.stdenvAdapters.useMoldLinker pkgs.clangStdenv)
# else pkgs.clangStdenv;
} (commonArgs
# 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
(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_13
apple-sdk_26
])
++ (lib.optionals pkgs.stdenv.isLinux [
mold
ffmpeg
heaptrack
samply
cargo-flamegraph
perf
# mold
]);
});
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

3
gst/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/result
/target
.direnv

1040
gst/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

24
gst/Cargo.toml Normal file
View File

@@ -0,0 +1,24 @@
[package]
name = "gst"
version = "0.1.0"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
error-stack = "0.6"
futures = "0.3.31"
futures-lite = "2.6.1"
glib = "0.21.5"
glib-sys = "0.21.5"
gstreamer = { version = "0.24.4", features = ["v1_26"] }
gstreamer-app = { version = "0.24.4", features = ["v1_26"] }
gstreamer-video = { version = "0.24.4", features = ["v1_26"] }
gstreamer-base = { version = "0.24.4", features = ["v1_26"] }
thiserror = "2.0"
tracing = { version = "0.1", features = ["log"] }
bitflags = "2.10.0"
[dev-dependencies]
smol = "2.0.2"
tracing-subscriber = "0.3.22"

38
gst/src/bin.rs Normal file
View File

@@ -0,0 +1,38 @@
use crate::priv_prelude::*;
wrap_gst!(Bin);
parent_child!(Element, Bin);
impl Bin {
pub fn new(name: impl AsRef<str>) -> Self {
let bin = gstreamer::Bin::with_name(name.as_ref());
Bin { inner: bin }
}
pub fn add(&mut self, element: &impl ChildOf<Element>) -> Result<&mut Self> {
self.inner
.add(&element.upcast_ref().inner)
.change_context(Error)
.attach("Failed to add element to bin")?;
Ok(self)
}
pub fn add_many<'a, E: ChildOf<Element> + 'a>(
&mut self,
elements: impl IntoIterator<Item = &'a E>,
) -> Result<&mut Self> {
self.inner
.add_many(elements.into_iter().map(|e| &e.upcast_ref().inner))
.change_context(Error)
.attach("Failed to add elements to bin")?;
Ok(self)
}
pub fn add_pad(&mut self, pad: &Pad) -> Result<&mut Self> {
self.inner
.add_pad(&pad.inner)
.change_context(Error)
.attach("Failed to add pad to bin")?;
Ok(self)
}
}

27
gst/src/bus.rs Normal file
View File

@@ -0,0 +1,27 @@
use crate::priv_prelude::*;
wrap_gst!(Bus);
impl Bus {
pub fn iter_timed(
&self,
timeout: impl Into<Option<core::time::Duration>>,
) -> gstreamer::bus::Iter<'_> {
let clocktime = match timeout.into() {
Some(dur) => gstreamer::ClockTime::try_from(dur).ok(),
None => gstreamer::ClockTime::NONE,
};
self.inner.iter_timed(clocktime)
}
pub fn stream(&self) -> gstreamer::bus::BusStream {
self.inner.stream()
}
pub fn filtered_stream<'a>(
&self,
msg_types: &'a [gstreamer::MessageType],
) -> impl futures::stream::FusedStream<Item = gstreamer::Message> + Unpin + Send + 'a {
self.inner.stream_filtered(msg_types)
}
}

78
gst/src/caps.rs Normal file
View File

@@ -0,0 +1,78 @@
use gstreamer::Fraction;
#[derive(Debug, Clone)]
#[repr(transparent)]
pub struct Caps {
pub(crate) inner: gstreamer::caps::Caps,
}
impl Caps {
pub fn builder(cs: CapsType) -> CapsBuilder {
CapsBuilder::new(cs)
}
}
pub struct CapsBuilder {
inner: gstreamer::caps::Builder<gstreamer::caps::NoFeature>,
}
impl CapsBuilder {
pub fn field<V: Into<glib::Value> + Send>(mut self, name: impl AsRef<str>, value: V) -> Self {
self.inner = self.inner.field(name.as_ref(), value);
self
}
pub fn build(self) -> Caps {
Caps {
inner: self.inner.build(),
}
}
}
pub enum CapsType {
Video,
Audio,
Text,
}
impl CapsType {
pub fn as_str(&self) -> &str {
match self {
CapsType::Video => "video/x-raw",
CapsType::Audio => "audio/x-raw",
CapsType::Text => "text/x-raw",
}
}
}
impl CapsBuilder {
pub fn new(cs: CapsType) -> Self {
CapsBuilder {
inner: gstreamer::Caps::builder(cs.as_str()),
}
}
}
impl Caps {
pub fn format(&self) -> Option<gstreamer_video::VideoFormat> {
self.inner
.structure(0)
.and_then(|s| s.get::<&str>("format").ok())
.map(|s| gstreamer_video::VideoFormat::from_string(s))
}
pub fn width(&self) -> Option<i32> {
self.inner
.structure(0)
.and_then(|s| s.get::<i32>("width").ok())
}
pub fn height(&self) -> Option<i32> {
self.inner
.structure(0)
.and_then(|s| s.get::<i32>("height").ok())
}
pub fn framerate(&self) -> Option<gstreamer::Fraction> {
self.inner
.structure(0)
.and_then(|s| s.get::<Fraction>("framerate").ok())
}
}

133
gst/src/element.rs Normal file
View File

@@ -0,0 +1,133 @@
use crate::priv_prelude::*;
use crate::wrap_gst;
wrap_gst!(Element, gstreamer::Element);
// pub trait IsElement {
// fn upcast_ref(&self) -> &Element;
// fn into_element(self) -> Element;
// fn pad(&self, name: &str) -> Option<Pad> {
// use gstreamer::prelude::*;
// self.upcast_ref().inner.static_pad(name).map(Pad::from)
// }
// }
// impl IsElement for Element {
// fn upcast_ref(&self) -> &Element {
// self
// }
//
// fn into_element(self) -> Element {
// self
// }
// }
impl Element {
pub fn pad(&self, name: impl AsRef<str>) -> Option<Pad> {
use gstreamer::prelude::*;
self.inner.static_pad(name.as_ref()).map(Pad::from)
}
pub fn bus(&self) -> Result<Bus> {
use gstreamer::prelude::*;
self.inner
.bus()
.map(Bus::from)
.ok_or(Error)
.attach_with(|| format!("Failed to get bus from Element: {}", self.inner.name()))
}
}
pub trait Sink: ChildOf<Element> {
fn sink(&self, name: impl AsRef<str>) -> Pad {
self.upcast_ref()
.pad(name)
.expect("Sink element has no sink pad")
}
}
pub trait Source: ChildOf<Element> {
fn source(&self, name: impl AsRef<str>) -> Pad {
self.upcast_ref()
.pad(name)
.expect("Source element has no src pad")
}
fn link<S: Sink>(&self, sink: &S) -> Result<Bin>
where
Self: Sized,
{
use gstreamer::prelude::*;
if let Ok(bin) = self.upcast_ref().inner.clone().downcast::<gstreamer::Bin>() {
bin.add(&sink.upcast_ref().inner)
.change_context(Error)
.attach("Failed to add sink to bin")?;
self.upcast_ref()
.inner
.link(&sink.upcast_ref().inner)
.change_context(Error)
.attach("Failed to link elements")?;
Ok(Bin::from(bin))
} else {
let bin = gstreamer::Bin::builder()
.name(format!(
"{}-link-{}",
self.upcast_ref().inner.name(),
sink.upcast_ref().inner.name()
))
.build();
bin.add(&self.upcast_ref().inner)
.change_context(Error)
.attach("Failed to add source to bin")?;
bin.add(&sink.upcast_ref().inner)
.change_context(Error)
.attach("Failed to add sink to bin")?;
self.upcast_ref()
.inner
.link(&sink.upcast_ref().inner)
.change_context(Error)
.attach("Failed to link elements")?;
if let Some(sink_pad) = self.upcast_ref().pad("sink") {
let ghost_pad = Pad::ghost(&sink_pad)?;
bin.add_pad(&ghost_pad.inner)
.change_context(Error)
.attach("Failed to add src pad to bin")?;
ghost_pad.activate(true)?;
}
Ok(From::from(bin))
}
}
// fn link_pad<S: Sink>(&self, sink: &S, src_pad_name: &str, sink_pad_name: &str) -> Result<()> {
// use gstreamer::prelude::*;
// let src_pad = self
// .upcast_ref()
// .pad(src_pad_name)
// .ok_or(Error)
// .attach("Source pad not found")?;
// let sink_pad = sink
// .upcast_ref()
// .pad(sink_pad_name)
// .ok_or(Error)
// .attach("Sink pad not found")?;
// src_pad
// .inner
// .link(&sink_pad.inner)
// .change_context(Error)
// .attach("Failed to link source pad to sink pad")?;
// Ok(())
// }
}
pub trait ElementExt: ChildOf<Element> + Sync {
#[track_caller]
fn bus(&self) -> Result<Bus> {
self.upcast_ref().bus()
}
#[track_caller]
fn pad(&self, name: impl AsRef<str>) -> Option<Pad> {
self.upcast_ref().pad(name)
}
}
impl<T: ChildOf<Element> + Sync> ElementExt for T {}

7
gst/src/errors.rs Normal file
View File

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

64
gst/src/lib.rs Normal file
View File

@@ -0,0 +1,64 @@
pub mod bin;
pub mod bus;
pub mod caps;
pub mod element;
pub mod errors;
pub mod pad;
pub mod pipeline;
pub mod plugins;
#[macro_use]
pub mod wrapper;
pub mod sample;
pub use bin::*;
pub use bus::*;
pub use caps::*;
pub use element::*;
pub use gstreamer;
#[doc(inline)]
pub use gstreamer::{Message, MessageType, MessageView, State};
pub use gstreamer_video::VideoFormat;
pub use pad::*;
pub use pipeline::*;
pub use plugins::*;
pub use sample::*;
pub(crate) mod priv_prelude {
pub use crate::errors::*;
pub use crate::wrapper::*;
pub use crate::*;
pub use gstreamer::prelude::ElementExt as _;
pub use gstreamer::prelude::*;
#[track_caller]
pub fn duration_to_clocktime(
timeout: impl Into<Option<core::time::Duration>>,
) -> Result<Option<gstreamer::ClockTime>> {
match timeout.into() {
Some(dur) => {
let clocktime = gstreamer::ClockTime::try_from(dur)
.change_context(Error)
.attach("Failed to convert duration to ClockTime")?;
Ok(Some(clocktime))
}
None => Ok(gstreamer::ClockTime::NONE),
}
}
}
use std::sync::Arc;
static GST: std::sync::LazyLock<std::sync::Arc<Gst>> = std::sync::LazyLock::new(|| {
gstreamer::init().expect("Failed to initialize GStreamer");
std::sync::Arc::new(Gst {
__private: core::marker::PhantomData,
})
});
pub struct Gst {
__private: core::marker::PhantomData<()>,
}
impl Gst {
pub fn new() -> Arc<Self> {
Arc::clone(&GST)
}
}

45
gst/src/pad.rs Normal file
View File

@@ -0,0 +1,45 @@
use crate::priv_prelude::*;
wrap_gst!(Pad, gstreamer::Pad);
impl Pad {
#[track_caller]
pub fn ghost(target: &Pad) -> Result<Pad> {
let ghost_pad = gstreamer::GhostPad::with_target(&target.inner)
.change_context(Error)
.attach("Failed to create ghost pad")?;
Ok(Pad {
inner: ghost_pad.upcast(),
})
}
#[track_caller]
pub fn link(&self, peer: &Pad) -> Result<()> {
use gstreamer::prelude::*;
self.inner
.link(&peer.inner)
.change_context(Error)
.attach("Failed to link pads")?;
Ok(())
}
#[track_caller]
pub fn current_caps(&self) -> Result<Caps> {
let caps = self
.inner
.current_caps()
.ok_or(Error)
.attach("Failed to get pad caps")?;
Ok(Caps { inner: caps })
}
#[track_caller]
pub fn activate(&self, activate: bool) -> Result<()> {
use gstreamer::prelude::*;
self.inner
.set_active(activate)
.change_context(Error)
.attach("Failed to set_active pad")?;
Ok(())
}
}

211
gst/src/pipeline.rs Normal file
View File

@@ -0,0 +1,211 @@
use crate::priv_prelude::*;
wrap_gst!(Pipeline);
parent_child!(Element, Pipeline);
parent_child!(Bin, Pipeline);
impl Drop for Pipeline {
fn drop(&mut self) {
let _ = self.inner.set_state(gstreamer::State::Null);
}
}
impl Pipeline {
#[track_caller]
pub fn bus(&self) -> Result<Bus> {
let bus = self
.inner
.bus()
.ok_or(Error)
.attach("Failed to get bus from pipeline")?;
Ok(Bus::from_gst(bus))
}
/// Get the state
pub fn state(
&self,
timeout: impl Into<Option<core::time::Duration>>,
) -> Result<gstreamer::State> {
let (result, current, _pending) = self.inner.state(duration_to_clocktime(timeout)?);
result.change_context(Error).attach("Failed to get state")?;
Ok(current)
}
pub fn play(&self) -> Result<()> {
self.inner
.set_state(gstreamer::State::Playing)
.change_context(Error)
.attach("Failed to set pipeline to Playing state")?;
Ok(())
}
pub fn pause(&self) -> Result<()> {
self.inner
.set_state(gstreamer::State::Paused)
.change_context(Error)
.attach("Failed to set pipeline to Paused state")?;
Ok(())
}
pub fn ready(&self) -> Result<()> {
self.inner
.set_state(gstreamer::State::Ready)
.change_context(Error)
.attach("Failed to set pipeline to Ready state")?;
Ok(())
}
pub fn stop(&self) -> Result<()> {
self.inner
.set_state(gstreamer::State::Null)
.change_context(Error)
.attach("Failed to set pipeline to Null state")?;
Ok(())
}
pub fn set_state(&self, state: gstreamer::State) -> Result<gstreamer::StateChangeSuccess> {
let result = self
.inner
.set_state(state)
.change_context(Error)
.attach("Failed to set pipeline state")?;
Ok(result)
}
pub async fn wait_for(&self, state: gstreamer::State) -> Result<()> {
let current_state = self.state(core::time::Duration::ZERO)?;
if current_state == state {
Ok(())
} else {
// use futures::stream::StreamExt;
use futures_lite::stream::StreamExt as _;
self.bus()?
.filtered_stream(&[MessageType::StateChanged])
.find(|message: &gstreamer::Message| {
let view = message.view();
if let gstreamer::MessageView::StateChanged(changed) = view {
changed.current() == state
&& changed.src().is_some_and(|s| s == &self.inner)
} else {
false
}
})
.await;
Ok(())
}
}
pub async fn wait_for_states(&self, states: impl AsRef<[gstreamer::State]>) -> Result<()> {
let current_state = self.state(core::time::Duration::ZERO)?;
let states = states.as_ref();
if states.contains(&current_state) {
Ok(())
} else {
use futures_lite::stream::StreamExt as _;
self.bus()?
.filtered_stream(&[MessageType::StateChanged])
.find(|message: &gstreamer::Message| {
let view = message.view();
if let gstreamer::MessageView::StateChanged(changed) = view {
states.contains(&changed.current())
&& changed.src().is_some_and(|s| s == &self.inner)
} else {
false
}
})
.await;
Ok(())
}
}
pub async fn wait_for_message<'a, F2>(
&self,
filter: Option<&'a [gstreamer::MessageType]>,
filter_fn: F2,
) -> Result<gstreamer::Message>
where
F2: Fn(&gstreamer::Message) -> bool + Send + 'a,
{
use futures_lite::stream::StreamExt as _;
match filter {
Some(filter) => {
let message = self.bus()?.filtered_stream(filter).find(filter_fn).await;
match message {
Some(msg) => Ok(msg),
None => {
Err(Error).attach("Failed to find message matching the provided filter")
}
}
}
None => {
let message = self.bus()?.stream().find(filter_fn).await;
match message {
Some(msg) => Ok(msg),
None => {
Err(Error).attach("Failed to find message matching the provided filter")
}
}
}
}
}
}
pub trait PipelineExt: ChildOf<Pipeline> + Sync {
// #[track_caller]
// fn bus(&self) -> Result<Bus> {
// self.upcast_ref().bus()
// }
#[track_caller]
fn play(&self) -> Result<()> {
self.upcast_ref().play()
}
#[track_caller]
fn pause(&self) -> Result<()> {
self.upcast_ref().pause()
}
#[track_caller]
fn ready(&self) -> Result<()> {
self.upcast_ref().ready()
}
#[track_caller]
fn stop(&self) -> Result<()> {
self.upcast_ref().stop()
}
#[track_caller]
fn set_state(&self, state: gstreamer::State) -> Result<gstreamer::StateChangeSuccess> {
self.upcast_ref().set_state(state)
}
#[track_caller]
fn state(&self, timeout: impl Into<Option<core::time::Duration>>) -> Result<gstreamer::State> {
self.upcast_ref().state(timeout)
}
fn wait_for(
&self,
state: gstreamer::State,
) -> impl std::future::Future<Output = Result<()>> + Send {
self.upcast_ref().wait_for(state)
}
fn wait_for_states(
&self,
states: impl AsRef<[gstreamer::State]> + Send,
) -> impl std::future::Future<Output = Result<()>> + Send {
self.upcast_ref().wait_for_states(states)
}
fn wait_for_message<'a, F2>(
&self,
filter: Option<&'a [gstreamer::MessageType]>,
filter_fn: F2,
) -> impl std::future::Future<Output = Result<gstreamer::Message>> + Send
where
F2: Fn(&gstreamer::Message) -> bool + Send + 'a,
{
self.upcast_ref().wait_for_message(filter, filter_fn)
}
}
impl<T: ChildOf<Pipeline> + Sync> PipelineExt for T {}

4
gst/src/plugins.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod app;
pub mod autodetect;
pub mod playback;
pub mod videoconvertscale;

2
gst/src/plugins/app.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod appsink;
pub use appsink::*;

View File

@@ -0,0 +1,278 @@
use crate::priv_prelude::*;
#[doc(inline)]
pub use gstreamer_app::AppSinkCallbacks;
wrap_gst!(AppSink, gstreamer::Element);
parent_child!(Element, AppSink);
pub struct AppSinkBuilder {
inner: AppSink,
callbacks: Option<gstreamer_app::app_sink::AppSinkCallbacksBuilder>,
}
impl AppSinkBuilder {
pub fn on_new_sample<F>(mut self, mut f: F) -> Self
where
F: FnMut(&AppSink) -> Result<(), gstreamer::FlowError> + Send + 'static,
{
let mut callbacks_builder = self
.callbacks
.take()
.unwrap_or_else(gstreamer_app::app_sink::AppSinkCallbacks::builder);
callbacks_builder = callbacks_builder.new_sample(move |appsink| {
use glib::object::Cast;
let element = appsink.upcast_ref::<gstreamer::Element>();
let appsink = AppSink::from_gst_ref(element);
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(appsink)))
.unwrap_or(Err(gstreamer::FlowError::Error))
.map(|_| gstreamer::FlowSuccess::Ok)
});
self.callbacks = Some(callbacks_builder);
self
}
pub fn on_new_preroll<F>(mut self, mut f: F) -> Self
where
F: FnMut(&AppSink) -> Result<(), gstreamer::FlowError> + Send + 'static,
{
let mut callbacks_builder = self
.callbacks
.take()
.unwrap_or_else(gstreamer_app::app_sink::AppSinkCallbacks::builder);
callbacks_builder = callbacks_builder.new_preroll(move |appsink| {
use glib::object::Cast;
let element = appsink.upcast_ref::<gstreamer::Element>();
let appsink = AppSink::from_gst_ref(element);
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(appsink)))
.unwrap_or(Err(gstreamer::FlowError::Error))
.map(|_| gstreamer::FlowSuccess::Ok)
});
self.callbacks = Some(callbacks_builder);
self
}
pub fn build(self) -> AppSink {
let AppSinkBuilder { inner, callbacks } = self;
if let Some(callbacks) = callbacks {
inner.appsink().set_callbacks(callbacks.build());
}
inner
}
}
impl Sink for AppSink {}
impl AppSink {
pub fn builder(name: impl AsRef<str>) -> AppSinkBuilder {
let inner = AppSink::new(name).expect("Failed to create AppSink");
AppSinkBuilder {
inner,
callbacks: None,
}
}
fn appsink(&self) -> &gstreamer_app::AppSink {
self.inner
.downcast_ref::<gstreamer_app::AppSink>()
.expect("Failed to downcast to AppSink")
}
pub fn new(name: impl AsRef<str>) -> Result<Self> {
let inner = gstreamer::ElementFactory::make("appsink")
.name(name.as_ref())
.build()
.change_context(Error)
.attach("Failed to create appsink element")?;
Ok(AppSink { inner })
}
pub fn emit_signals(&mut self, emit: bool) -> &mut Self {
self.inner.set_property("emit-signals", emit);
self
}
pub fn async_(&mut self, async_: bool) -> &mut Self {
self.inner.set_property("async", async_);
self
}
pub fn sync(&mut self, sync: bool) -> &mut Self {
self.inner.set_property("sync", sync);
self
}
pub fn drop(&mut self, drop: bool) -> &mut Self {
self.inner.set_property("drop", drop);
self
}
pub fn caps(&mut self, caps: Caps) -> &mut Self {
self.inner.set_property("caps", caps.inner);
self
}
pub fn callbacks(&mut self, callbacks: gstreamer_app::AppSinkCallbacks) -> &mut Self {
self.appsink().set_callbacks(callbacks);
self
}
pub fn on_new_sample<F>(&mut self, mut f: F) -> &mut Self
where
F: FnMut(&AppSink) -> Result<(), gstreamer::FlowError> + Send + 'static,
{
self.emit_signals(true).callbacks(
AppSinkCallbacks::builder()
.new_sample(move |appsink| {
use glib::object::Cast;
let element = appsink.upcast_ref::<gstreamer::Element>();
let appsink = AppSink::from_gst_ref(element);
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(appsink)))
.unwrap_or(Err(gstreamer::FlowError::Error))
.map(|_| gstreamer::FlowSuccess::Ok)
})
.build(),
)
}
pub fn pull_sample(&self) -> Result<Sample> {
self.appsink()
.pull_sample()
.change_context(Error)
.attach("Failed to pull sample from AppSink")
.map(Sample::from)
}
pub fn try_pull_sample(
&self,
timeout: impl Into<Option<core::time::Duration>>,
) -> Result<Option<Sample>> {
Ok(self
.appsink()
.try_pull_sample(duration_to_clocktime(timeout)?)
.map(From::from))
}
pub fn pull_preroll(&self) -> Result<Sample> {
self.appsink()
.pull_preroll()
.change_context(Error)
.attach("Failed to pull preroll sample from AppSink")
.map(Sample::from)
}
pub fn try_pull_preroll(
&self,
timeout: impl Into<Option<core::time::Duration>>,
) -> Result<Option<Sample>> {
Ok(self
.appsink()
.try_pull_preroll(duration_to_clocktime(timeout)?)
.map(From::from))
}
}
#[test]
fn test_appsink() {
use gstreamer::prelude::*;
use tracing_subscriber::prelude::*;
tracing_subscriber::registry()
.with(
tracing_subscriber::fmt::layer()
.with_thread_ids(true)
.with_file(true),
)
.init();
tracing::info!("Linking videoconvert to appsink");
Gst::new();
let playbin3 = playback::Playbin3::new("pppppppppppppppppppppppppppppp").unwrap().with_uri("https://jellyfin.tsuba.darksailor.dev/Items/6010382cf25273e624d305907010d773/Download?api_key=036c140222464878862231ef66a2bc9c");
let video_convert = plugins::videoconvertscale::VideoConvert::new("vcvcvcvcvcvcvcvcvcvcvcvcvc")
.expect("Create videoconvert");
let mut appsink = app::AppSink::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").expect("Create appsink");
appsink.caps(
Caps::builder(CapsType::Video)
.field("format", "RGB")
.build(),
);
let video_sink = video_convert
.link(&appsink)
.expect("Link videoconvert to appsink");
let playbin3 = playbin3.with_video_sink(&video_sink);
playbin3.play().expect("Play video");
let bus = playbin3.bus().unwrap();
for msg in bus.iter_timed(None) {
match msg.view() {
gstreamer::MessageView::Eos(..) => {
tracing::info!("End of stream reached");
break;
}
gstreamer::MessageView::Error(err) => {
tracing::error!(
"Error from {:?}: {} ({:?})",
err.src().map(|s| s.path_string()),
err.error(),
err.debug()
);
break;
}
gstreamer::MessageView::StateChanged(state) => {
eprintln!(
"State changed from {:?} to \x1b[33m{:?}\x1b[0m for {:?}",
state.old(),
state.current(),
state.src().map(|s| s.path_string())
);
}
_ => {}
}
// tracing::info!("{:#?}", &msg.view());
}
// std::thread::sleep(std::time::Duration::from_secs(5));
}
#[test]
fn test_appsink_metadata() {
use tracing_subscriber::prelude::*;
tracing_subscriber::registry()
.with(
tracing_subscriber::fmt::layer()
.with_thread_ids(true)
.with_file(true),
)
.init();
crate::Gst::new();
let url = "https://jellyfin.tsuba.darksailor.dev/Items/6010382cf25273e624d305907010d773/Download?api_key=036c140222464878862231ef66a2bc9c";
let videoconvert = crate::plugins::videoconvertscale::VideoConvert::new("iced-video-convert")
// .unwrap();
// .with_output_format(gst::plugins::videoconvertscale::VideoFormat::Rgba)
.unwrap();
let appsink = crate::plugins::app::AppSink::new("iced-video-sink")
.unwrap()
.with_async(true)
.with_sync(true);
let video_sink = videoconvert.link(&appsink).unwrap();
let playbin = crate::plugins::playback::Playbin3::new("iced-video")
.unwrap()
.with_uri(url)
.with_video_sink(&video_sink);
playbin.pause().unwrap();
smol::block_on(async {
playbin.wait_for(gstreamer::State::Paused).await.unwrap();
});
// std::thread::sleep(core::time::Duration::from_secs(1));
let pad = appsink.pad("sink").unwrap();
let caps = pad.current_caps().unwrap();
let format = caps.format();
let height = caps.height();
let width = caps.width();
let framerate = caps.framerate();
dbg!(&format, height, width, framerate);
dbg!(&caps);
}

View File

@@ -0,0 +1,2 @@
pub mod autovideosink;
pub use autovideosink::*;

View File

@@ -0,0 +1,18 @@
use crate::priv_prelude::*;
wrap_gst!(AutoVideoSink, gstreamer::Element);
parent_child!(Element, AutoVideoSink);
parent_child!(Bin, AutoVideoSink, downcast);
impl Sink for AutoVideoSink {}
impl AutoVideoSink {
pub fn new(name: impl AsRef<str>) -> Result<Self> {
let element = gstreamer::ElementFactory::make("autovideosink")
.name(name.as_ref())
.build()
.change_context(Error)
.attach("Failed to create autovideosink element")?;
Ok(AutoVideoSink { inner: element })
}
}

View File

@@ -0,0 +1,71 @@
pub mod playbin3;
pub use playbin3::*;
pub mod playbin;
pub use playbin::*;
bitflags::bitflags! {
/// Extra flags to configure the behaviour of the sinks.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PlayFlags: u32 {
/// Render the video stream
const VIDEO = (1 << 0);
/// Render the audio stream
const AUDIO = (1 << 1);
/// Render subtitles
const TEXT = (1 << 2);
/// Render visualisation when no video is present
const VIS = (1 << 3);
/// Use software volume
const SOFT_VOLUME = (1 << 4);
/// Only use native audio formats
const NATIVE_AUDIO = (1 << 5);
/// Only use native video formats
const NATIVE_VIDEO = (1 << 6);
/// Attempt progressive download buffering
const DOWNLOAD = (1 << 7);
/// Buffer demuxed/parsed data
const BUFFERING = (1 << 8);
/// Deinterlace video if necessary
const DEINTERLACE = (1 << 9);
/// Use software color balance
const SOFT_COLORBALANCE = (1 << 10);
/// Force audio/video filter(s) to be applied
const FORCE_FILTERS = (1 << 11);
/// Force only software-based decoders (no effect for playbin3)
const FORCE_SW_DECODERS = (1 << 12);
}
}
const _: () = {
use glib::types::StaticType;
impl glib::types::StaticType for PlayFlags {
#[inline]
#[doc(alias = "gst_play_flags_get_type")]
fn static_type() -> glib::Type {
glib::Type::from_name("GstPlayFlags").expect("GstPlayFlags type not found")
}
}
impl glib::value::ToValue for PlayFlags {
#[inline]
fn to_value(&self) -> glib::Value {
let value = self.bits().to_value();
value
.transform_with_type(Self::static_type())
.expect("Failed to transform PlayFlags(u32) to GstPlayFlags")
}
#[inline]
fn value_type(&self) -> glib::Type {
Self::static_type()
}
}
impl From<PlayFlags> for glib::Value {
#[inline]
fn from(v: PlayFlags) -> Self {
// skip_assert_initialized!();
glib::value::ToValue::to_value(&v)
}
}
};

View File

@@ -0,0 +1,82 @@
use crate::priv_prelude::*;
wrap_gst!(Playbin, gstreamer::Element);
parent_child!(Element, Playbin);
parent_child!(Pipeline, Playbin, downcast);
parent_child!(Bin, Playbin, downcast);
impl Drop for Playbin {
fn drop(&mut self) {
self.set_state(gstreamer::State::Null).ok();
}
}
impl Playbin {
pub fn new(name: impl AsRef<str>) -> Result<Self> {
gstreamer::ElementFactory::make("playbin3")
.name(name.as_ref())
.build()
.map(|element| Playbin { inner: element })
.change_context(Error)
}
pub fn with_uri(self, uri: impl AsRef<str>) -> Self {
self.inner.set_property("uri", uri.as_ref());
self
}
pub fn with_buffer_duration(self, duration: impl Into<Option<core::time::Duration>>) -> Self {
let duration = match duration.into() {
Some(dur) => dur.as_secs() as i64,
None => -1,
};
self.inner.set_property("buffer-duration", duration);
self
}
pub fn with_buffer_size(self, size: impl Into<Option<u32>>) -> Self {
let size = match size.into() {
Some(size) => size as i32,
None => -1,
};
self.inner.set_property("buffer-size", size);
self
}
/// Sets the maximum size of the ring buffer in bytes.
pub fn with_ring_buffer_max_size(self, size: u64) -> Self {
self.inner.set_property("ring-buffer-max-size", size);
self
}
pub fn with_video_sink(self, video_sink: &impl ChildOf<Element>) -> Self {
self.inner
.set_property("video-sink", &video_sink.upcast_ref().inner);
self
}
pub fn with_text_sink(self, text_sink: &impl ChildOf<Element>) -> Self {
self.inner
.set_property("text-sink", &text_sink.upcast_ref().inner);
self
}
pub fn with_audio_sink(self, audio_sink: &impl ChildOf<Element>) -> Self {
self.inner
.set_property("audio-sink", &audio_sink.upcast_ref().inner);
self
}
pub fn set_volume(&self, volume: f64) {
self.inner.set_property("volume", volume.clamp(1.0, 100.0))
}
pub fn get_volume(&self) -> f64 {
self.inner.property::<f64>("volume")
}
pub fn with_flags(self, flags: playback::PlayFlags) -> Self {
self.inner.set_property("flags", flags);
self
}
}

View File

@@ -0,0 +1,95 @@
use crate::priv_prelude::*;
use playback::PlayFlags;
wrap_gst!(Playbin3, gstreamer::Element);
parent_child!(Element, Playbin3);
parent_child!(Pipeline, Playbin3, downcast);
parent_child!(Bin, Playbin3, downcast);
impl Drop for Playbin3 {
fn drop(&mut self) {
self.set_state(gstreamer::State::Null).ok();
}
}
impl Playbin3 {
pub fn new(name: impl AsRef<str>) -> Result<Self> {
gstreamer::ElementFactory::make("playbin3")
.name(name.as_ref())
.build()
.map(|element| Playbin3 { inner: element })
.change_context(Error)
}
pub fn with_uri(self, uri: impl AsRef<str>) -> Self {
self.inner.set_property("uri", uri.as_ref());
self
}
pub fn with_buffer_duration(self, duration: impl Into<Option<core::time::Duration>>) -> Self {
let duration = match duration.into() {
Some(dur) => dur.as_secs() as i64,
None => -1,
};
self.inner.set_property("buffer-duration", duration);
self
}
pub fn with_buffer_size(self, size: impl Into<Option<u32>>) -> Self {
let size = match size.into() {
Some(size) => size as i32,
None => -1,
};
self.inner.set_property("buffer-size", size);
self
}
/// Sets the maximum size of the ring buffer in bytes.
pub fn with_ring_buffer_max_size(self, size: u64) -> Self {
self.inner.set_property("ring-buffer-max-size", size);
self
}
pub fn with_video_sink(self, video_sink: &impl ChildOf<Element>) -> Self {
self.inner
.set_property("video-sink", &video_sink.upcast_ref().inner);
self
}
pub fn with_text_sink(self, text_sink: &impl ChildOf<Element>) -> Self {
self.inner
.set_property("text-sink", &text_sink.upcast_ref().inner);
self
}
pub fn with_audio_sink(self, audio_sink: &impl ChildOf<Element>) -> Self {
self.inner
.set_property("audio-sink", &audio_sink.upcast_ref().inner);
self
}
pub fn set_volume(&self, volume: f64) {
self.inner.set_property("volume", volume.clamp(1.0, 100.0))
}
pub fn get_volume(&self) -> f64 {
self.inner.property::<f64>("volume")
}
pub fn with_flags(self, flags: playback::PlayFlags) -> Self {
self.inner.set_property("flags", flags);
self
}
}
impl Playbin3 {
pub fn default_flags() -> PlayFlags {
PlayFlags::SOFT_COLORBALANCE
| PlayFlags::DEINTERLACE
| PlayFlags::BUFFERING
| PlayFlags::SOFT_VOLUME
| PlayFlags::TEXT
| PlayFlags::AUDIO
| PlayFlags::VIDEO
}
}

View File

@@ -0,0 +1,2 @@
pub mod videoconvert;
pub use videoconvert::*;

View File

@@ -0,0 +1,36 @@
use crate::priv_prelude::*;
#[doc(inline)]
pub use gstreamer_video::VideoFormat;
wrap_gst!(VideoConvert, gstreamer::Element);
parent_child!(Element, VideoConvert);
impl Sink for VideoConvert {}
impl Source for VideoConvert {}
impl VideoConvert {
pub fn new(name: impl AsRef<str>) -> Result<Self> {
let element = gstreamer::ElementFactory::make("videoconvert")
.name(name.as_ref())
.build()
.change_context(Error)
.attach("Failed to create videoconvert element")?;
Ok(VideoConvert { inner: element })
}
// pub fn with_caps(mut self, caps: &gstreamer::Caps) -> Self {
// use gstreamer::prelude::*;
// self.inner.set_property("caps", caps);
// self
// }
pub fn with_output_format(self, format: VideoFormat) -> Result<Self> {
use gstreamer::prelude::*;
let caps = Caps::builder(CapsType::Video)
.field("format", format.to_str())
.build();
self.inner.set_property("caps", &caps.inner);
// .change_context(Error)
// .attach("Failed to set output format on videoconvert")?;
Ok(self)
}
}

37
gst/src/sample.rs Normal file
View File

@@ -0,0 +1,37 @@
impl From<gstreamer::Sample> for Sample {
fn from(inner: gstreamer::Sample) -> Self {
Sample { inner }
}
}
#[repr(transparent)]
#[derive(Debug, Clone)]
pub struct Sample {
pub inner: gstreamer::Sample,
}
use gstreamer::BufferRef;
impl Sample {
#[doc(alias = "empty")]
pub fn new() -> Self {
Self {
inner: gstreamer::Sample::builder().build(),
}
}
pub fn buffer(&self) -> Option<&BufferRef> {
self.inner.buffer()
}
pub fn caps(&self) -> Option<&gstreamer::CapsRef> {
self.inner.caps()
}
pub fn info(&self) -> Option<&gstreamer::StructureRef> {
self.inner.info()
}
// pub fn set_buffer(&mut self) {
// self.inner.set_buffer(None);
// }
}

2
gst/src/wgpu.rs Normal file
View File

@@ -0,0 +1,2 @@

145
gst/src/wrapper.rs Normal file
View File

@@ -0,0 +1,145 @@
pub trait GstWrapper {
type GstType: glib::prelude::ObjectType;
fn from_gst(gst: Self::GstType) -> Self;
// fn into_gst(self) -> Self::GstType;
fn as_gst_ref(&self) -> &Self::GstType;
fn from_gst_ref(gst: &Self::GstType) -> &Self;
}
#[macro_export]
macro_rules! wrap_gst {
($name:ident) => {
$crate::wrap_gst!($name, gstreamer::$name);
};
($name:ident, $inner:ty) => {
$crate::wrap_gst!(core $name, $inner);
$crate::wrap_gst!($name, $inner, into_inner);
};
($name:ident, $inner:ty, skip_inner) => {
$crate::wrap_gst!(core $name, $inner);
};
(core $name:ident, $inner:ty) => {
#[derive(Debug, Clone)]
#[repr(transparent)]
pub struct $name {
pub(crate) inner: $inner,
}
// impl From<$name> for $inner {
// fn from(wrapper: $name) -> Self {
// wrapper.into_inner()
// }
// }
impl $name {
pub fn into_inner(self) -> $inner {
self.inner.clone()
}
}
impl $crate::wrapper::GstWrapper for $name {
type GstType = $inner;
fn from_gst(gst: Self::GstType) -> Self {
Self { inner: gst }
}
// fn into_gst(self) -> Self::GstType {
// self.inner.clone()
// }
fn as_gst_ref(&self) -> &Self::GstType {
&self.inner
}
fn from_gst_ref(gst: &Self::GstType) -> &Self {
unsafe { &*(gst as *const Self::GstType as *const Self) }
}
}
impl ChildOf<$name> for $name {
fn upcast_ref(&self) -> &$name {
self
}
}
};
($name:ident, $inner:ty, into_inner) => {
impl From<$inner> for $name {
fn from(inner: $inner) -> Self {
Self { inner }
}
}
};
}
/// A trait for types that can be upcasted to type T.
pub trait ChildOf<T> {
fn upcast_ref(&self) -> &T;
}
#[macro_export]
macro_rules! parent_child {
($parent:ty, $child:ty) => {
impl ChildOf<$parent> for $child
where
$child: GstWrapper,
$parent: GstWrapper,
{
fn upcast_ref(&self) -> &$parent {
let upcasted = self.inner.upcast_ref::<<$parent as GstWrapper>::GstType>();
unsafe { &*(upcasted as *const <$parent as GstWrapper>::GstType as *const $parent) }
}
}
};
($parent:ty, $child:ty, downcast) => {
impl ChildOf<$parent> for $child
where
$child: GstWrapper,
$parent: GstWrapper,
{
fn upcast_ref(&self) -> &$parent {
let downcasted = self
.inner
.downcast_ref::<<$parent as GstWrapper>::GstType>()
.expect(
format!(
"BUG: Failed to downcast GStreamer type from child {} to parent {}",
stringify!($child),
stringify!($parent)
)
.as_str(),
);
unsafe {
&*(downcasted as *const <$parent as GstWrapper>::GstType as *const $parent)
}
}
}
}; // ($parent:ty, $child:ty, deref) => {
// $crate::parent_child!($parent, $child);
// $crate::parent_child!($parent, $child, __deref);
// };
//
// ($parent:ty, $child:ty, downcast, deref) => {
// $crate::parent_child!($parent, $child, downcast);
// $crate::parent_child!($parent, $child, __deref);
// };
// ($parent:ty, $child:ty, deref, downcast) => {
// $crate::parent_child!($parent, $child, downcast);
// $crate::parent_child!($parent, $child, __deref);
// };
//
// ($parent:ty, $child:ty, __deref) => {
// impl core::ops::Deref for $child
// where
// $child: GstWrapper,
// $parent: GstWrapper,
// {
// type Target = $parent;
//
// fn deref(&self) -> &Self::Target {
// self.upcast_ref()
// }
// }
// };
}

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,6 +1,18 @@
jello:
cargo r -r -- -vv
# iced-video:
# cd crates/iced-video && cargo run --release --example minimal
typegen:
@echo "Generating jellyfin type definitions..."
cd typegen && cargo run
cp typegen/jellyfin.rs api/src/jellyfin.rs
rm typegen/jellyfin.rs
hdrtest:
GST_DEBUG=3 gst-launch-1.0 playbin3 uri=https://jellyfin.tsuba.darksailor.dev/Items/6010382cf25273e624d305907010d773/Download?api_key=036c140222464878862231ef66a2bc9c video-sink="videoconvert ! video/x-raw,format=(string)RGB10A2_LE ! fakesink"
codec:
GST_DEBUG=3 gst-discoverer-1.0 https://jellyfin.tsuba.darksailor.dev/Items/6010382cf25273e624d305907010d773/Download?api_key=036c140222464878862231ef66a2bc9c
ffprobe:
ffprobe -v error -show_format -show_streams "https://jellyfin.tsuba.darksailor.dev/Items/6010382cf25273e624d305907010d773/Download?api_key=036c140222464878862231ef66a2bc9c" | grep pix_fmt

View File

@@ -1,36 +1,38 @@
#[derive(Debug, clap::Parser)]
pub struct Cli {
#[clap(subcommand)]
pub cmd: SubCommand,
// #[clap(subcommand)]
// pub cmd: SubCommand,
#[command(flatten)]
pub verbosity: clap_verbosity_flag::Verbosity,
}
#[derive(Debug, clap::Subcommand)]
pub enum SubCommand {
#[clap(name = "add")]
Add(Add),
#[clap(name = "list")]
List(List),
#[clap(name = "completions")]
Completions { shell: clap_complete::Shell },
}
#[derive(Debug, clap::Args)]
pub struct Add {
#[clap(short, long)]
pub name: String,
}
#[derive(Debug, clap::Args)]
pub struct List {}
impl Cli {
pub fn completions(shell: clap_complete::Shell) {
let mut command = <Cli as clap::CommandFactory>::command();
clap_complete::generate(
shell,
&mut command,
env!("CARGO_BIN_NAME"),
&mut std::io::stdout(),
);
}
}
// #[derive(Debug, clap::Subcommand)]
// pub enum SubCommand {
// #[clap(name = "add")]
// Add(Add),
// #[clap(name = "list")]
// List(List),
// #[clap(name = "completions")]
// Completions { shell: clap_complete::Shell },
// }
//
// #[derive(Debug, clap::Args)]
// pub struct Add {
// #[clap(short, long)]
// pub name: String,
// }
//
// #[derive(Debug, clap::Args)]
// pub struct List {}
//
// impl Cli {
// pub fn completions(shell: clap_complete::Shell) {
// let mut command = <Cli as clap::CommandFactory>::command();
// clap_complete::generate(
// shell,
// &mut command,
// env!("CARGO_BIN_NAME"),
// &mut std::io::stdout(),
// );
// }
// }

View File

@@ -1,29 +1,16 @@
mod cli;
mod errors;
mod ui;
use api::{JellyfinClient, JellyfinConfig};
use api::JellyfinConfig;
use errors::*;
#[tokio::main]
pub async fn main() -> Result<()> {
dotenvy::dotenv()
.change_context(Error)
.inspect_err(|err| {
eprintln!("Failed to load .env file: {}", err);
})
.ok();
let config = JellyfinConfig::new(
std::env::var("JELLYFIN_USERNAME").change_context(Error)?,
std::env::var("JELLYFIN_PASSWORD").change_context(Error)?,
std::env::var("JELLYFIN_SERVER_URL").change_context(Error)?,
"jello".to_string(),
);
let mut jellyfin = api::JellyfinClient::new(config);
jellyfin
.authenticate_with_cached_token(".session")
.await
.change_context(Error)?;
ui::ui(jellyfin);
fn main() -> Result<()> {
color_backtrace::install();
let args = <cli::Cli as clap::Parser>::parse();
tracing_subscriber::fmt()
.with_max_level(args.verbosity)
.with_file(true)
.with_line_number(true)
.init();
ui_iced::ui().change_context(Error)?;
Ok(())
}

265
src/ui.rs
View File

@@ -1,265 +0,0 @@
// mod movies;
// mod player;
// mod series;
use ::tap::*;
use std::{collections::BTreeMap, sync::Arc};
use gpui::{
App, Application, Bounds, ClickEvent, Context, ImageId, ImageSource, RenderImage, Resource,
SharedString, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size,
};
#[derive(Clone, Debug)]
pub struct AppState {
pub title: SharedString,
pub items: BTreeMap<SharedString, Item>,
pub item_ids: BTreeMap<usize, SharedString>,
pub current_item: Option<SharedString>,
pub errors: Vec<String>,
pub jellyfin_client: api::JellyfinClient,
}
#[derive(Clone, Debug)]
pub struct Item {
pub id: SharedString,
pub name: SharedString,
pub item_type: SharedString,
pub media_type: SharedString,
}
impl Render for AppState {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.size_full()
.justify_center()
.text_color(rgb(0xffffff))
.child(Self::header())
.child(Self::body(self, window, cx))
.child(Self::footer())
}
}
actions!(jello_actions, [OpenItem, OnLoadItem, MouseDownEvent]);
impl AppState {
fn new(title: impl AsRef<str>, jellyfin_client: api::JellyfinClient) -> Self {
AppState {
title: SharedString::new(title.as_ref()),
items: BTreeMap::new(),
item_ids: BTreeMap::new(),
current_item: None,
errors: Vec::new(),
jellyfin_client,
}
}
// fn on_mouse_down(
// &mut self,
// event: &MouseDownEvent,
// window: &mut Window,
// cx: &mut Context<Self>,
// ) {
// // Handle mouse down event
// }
fn load_item(id: usize) -> impl Fn(&mut Self, &ClickEvent, &mut Window, &mut Context<Self>) {
move |state: &mut Self, event: &ClickEvent, window: &mut Window, cx: &mut Context<Self>| {
let item_id = id;
cx.spawn(async move |entity, app| {
tracing::info!("Loading item with id: {}", item_id);
});
}
}
fn hover_item(id: usize) -> impl Fn(&mut Self, &bool, &mut Window, &mut Context<Self>) {
move |state: &mut Self, item: &bool, window: &mut Window, cx: &mut Context<Self>| {
dbg!("Hovering over item: {:?}", id);
}
}
fn header() -> impl IntoElement {
div()
.flex()
.flex_row()
.w_full()
.justify_end()
.h_20()
.border_10()
.bg(rgb(0x333333))
.child(Self::button("Refresh"))
}
fn footer() -> impl IntoElement {
div().flex().flex_row().w_full().h_20().bg(rgb(0x333333))
}
fn body(&mut self, window: &mut Window, cx: &mut Context<AppState>) -> impl IntoElement {
div()
.flex()
.flex_row()
.size_full()
.child(Self::content(self, window, cx))
.child(Self::sidebar(self, window, cx))
}
fn button(label: &str) -> impl IntoElement {
div()
.flex()
.justify_center()
.items_center()
.bg(rgb(0xff00ff))
.text_color(rgb(0xffffff))
.border_5()
.rounded_lg()
.child(label.to_string())
}
fn content(&mut self, window: &mut Window, cx: &mut Context<AppState>) -> impl IntoElement {
div()
.debug_below()
.w_3_4()
// .flex()
// .flex_wrap()
.bg(rgb(0x111111))
.justify_start()
.items_start()
.overflow_hidden()
.child(
div()
.size_full()
.flex()
.flex_wrap()
.justify_start()
.items_start()
.content_start()
.gap_y_10()
.gap_x_10()
.border_t_10()
.p_5()
.child(Self::card(cx, 1))
.child(Self::card(cx, 2))
.child(Self::card(cx, 3))
.child(Self::card(cx, 4))
.child(Self::card(cx, 5))
.child(Self::card(cx, 6))
.child(Self::card(cx, 7))
.child(Self::card(cx, 8))
.child(Self::card(cx, 9)),
)
}
fn sidebar(&mut self, window: &mut Window, cx: &mut Context<AppState>) -> impl IntoElement {
div()
.flex()
.flex_col()
.w_1_4()
.min_w_1_6()
.bg(rgb(0x222222))
.child(div().size_full().bg(gpui::yellow()))
}
fn card(cx: &mut Context<AppState>, number: usize) -> impl IntoElement {
div()
.id(number)
.on_click(cx.listener(Self::load_item(number)))
.on_hover(cx.listener(Self::hover_item(number)))
.flex()
.flex_col()
.w_48()
.h_64()
.p_10()
.bg(rgb(0xff00ff))
.rounded_lg()
}
}
pub fn ui(jellyfin_client: api::JellyfinClient) {
Application::new().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(500.0), px(500.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_, cx| cx.new(|_| AppState::new("Jello Media Browser", jellyfin_client)),
)
.expect("Failed to open window");
})
}
#[derive(Clone, Debug)]
pub struct Card {
pub id: usize,
pub title: SharedString,
pub description: SharedString,
pub image: SharedString,
pub image_blurhash: BlurHash,
pub media_type: SharedString,
pub loading: bool,
}
impl Render for Card {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.id(self.id)
.flex()
.flex_col()
.w_48()
.h_64()
.p_10()
.bg(rgb(0xff00ff))
.rounded_lg()
.pipe(|card| {
if self.loading {
card.child(self.image_blurhash.clone())
} else {
card.child(gpui::img(self.image.clone()))
}
})
}
}
#[derive(Clone, Debug)]
pub struct BlurHash {
pub id: ImageId,
pub data: Arc<RenderImage>,
}
impl BlurHash {
pub fn new(
data: impl AsRef<str>,
width: u32,
height: u32,
punch: f32,
) -> Result<Self, error_stack::Report<crate::Error>> {
use error_stack::ResultExt;
let decoded =
blurhash::decode(data.as_ref(), width, height, punch).change_context(crate::Error)?;
let buffer = image::RgbaImage::from_raw(width, height, decoded)
.ok_or(crate::Error)
.attach("Failed to convert")?;
let frame = image::Frame::new(buffer);
let render_image = RenderImage::new([frame]);
Ok(Self {
id: render_image.id,
data: Arc::from(render_image),
})
}
}
impl Render for BlurHash {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
gpui::img(ImageSource::Render(self.data.clone()))
}
}
impl IntoElement for BlurHash {
type Element = gpui::Img;
fn into_element(self) -> Self::Element {
gpui::img(ImageSource::Render(self.data.clone()))
}
}

View File

View File

14
store/Cargo.toml Normal file
View File

@@ -0,0 +1,14 @@
[package]
name = "store"
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 = { version = "1.18.1", features = ["v4"] }

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

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

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,
// })
// }
// }

1
store/src/sqlite.rs Normal file
View File

@@ -0,0 +1 @@

1
store/src/toml.rs Normal file
View File

@@ -0,0 +1 @@

View File

@@ -11,4 +11,4 @@ proc-macro2 = "1.0.103"
quote = "1.0.41"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
syn = { version = "2.0.108", features = ["full", "parsing"] }
syn = { version = "2.0.108", features = ["extra-traits", "full", "parsing"] }

View File

@@ -3,7 +3,7 @@ use indexmap::IndexMap;
const KEYWORDS: &[&str] = &[
"type", "match", "enum", "struct", "fn", "mod", "pub", "use", "crate", "self", "super", "as",
"in", "let", "mut", "ref", "static", "trait", "where",
"in", "let", "mut", "ref", "static", "trait", "where", "box",
];
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
@@ -15,6 +15,7 @@ pub struct JellyfinOpenapi {
pub struct Components {
schemas: indexmap::IndexMap<String, Schema>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
pub struct Schema {
#[serde(rename = "type")]
@@ -46,12 +47,15 @@ pub struct Property {
nullable: Option<bool>,
format: Option<String>,
items: Option<Box<Property>>,
properties: Option<indexmap::IndexMap<String, Property>>,
#[serde(rename = "additionalProperties")]
additional_properties: Option<Box<Property>>,
#[serde(rename = "enum")]
_enum: Option<Vec<String>>,
#[serde(rename = "allOf")]
all_of: Option<Vec<RefName>>,
#[serde(rename = "oneOf")]
one_of: Option<Vec<RefName>>,
description: Option<String>,
#[serde(rename = "$ref")]
_ref: Option<String>,
@@ -60,14 +64,22 @@ pub struct Property {
impl Property {
pub fn to_string(&self) -> String {
let out = match self._type {
Some(Types::String) => "String".to_string(),
Some(Types::String) => match self.format.as_deref() {
Some("uuid") => "uuid::Uuid".to_string(),
Some("date-time") => "jiff::Timestamp".to_string(),
_ => "String".to_string(),
},
Some(Types::Integer) => match self.format.as_deref() {
Some("int32") => "i32".to_string(),
Some("int64") => "i64".to_string(),
_ => "i32".to_string(),
},
Some(Types::Boolean) => "bool".to_string(),
Some(Types::Number) => "f64".to_string(),
Some(Types::Number) => match self.format.as_deref() {
Some("double") => "f64".to_string(),
Some("float") => "f32".to_string(),
_ => "f64".to_string(),
},
Some(Types::Array) => {
if let Some(ref items) = self.items {
format!("Vec<{}>", items.to_string())
@@ -81,6 +93,8 @@ impl Property {
"std::collections::HashMap<String, {}>",
properties.to_string()
)
// } else if let Some(props) = &self.properties {
// todo!()
} else {
"std::collections::HashMap<String, serde_json::Value>".to_string()
}
@@ -98,13 +112,18 @@ impl Property {
};
if let Some(true) = self.nullable {
format!("Option<{}>", out)
} else if self.nullable.is_none() && self._type == Some(Types::Object) {
format!("Option<{}>", out)
} else {
out
}
}
pub fn description(&self) -> Option<String> {
self.description.clone()
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Types {
Object,
@@ -133,47 +152,10 @@ fn main() {
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let syn_structs: Vec<syn::ItemStruct> = structs
let syn_structs: Vec<syn::Item> = structs
.iter()
.map(|(key, value)| {
let fields = value
.properties
.as_ref()
.expect("Possible properties")
.iter()
.map(|(name, _type)| {
let og_name = name.clone();
let name = modify_keyword(&name.to_snake_case());
let _type = _type.to_string();
let _type = if _type.contains(key) {
_type.replace(&format!("<{}>", key), format!("<Box<{}>>", key).as_str())
} else {
_type
};
syn::Field {
attrs: syn::parse_quote! {
#[serde(rename = #og_name)]
},
mutability: syn::FieldMutability::None,
vis: syn::Visibility::Public(syn::Token![pub](
proc_macro2::Span::call_site(),
)),
ident: Some(syn::Ident::new(&name, proc_macro2::Span::call_site())),
colon_token: Some(syn::token::Colon(proc_macro2::Span::call_site())),
ty: syn::parse_str(&_type).expect("Failed to parse type"),
}
})
.collect::<Vec<syn::Field>>();
let key = modify_keyword(key);
let key = syn::Ident::new(&key.to_pascal_case(), proc_macro2::Span::call_site());
let tokens = quote::quote! {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct #key {
#(#fields),*
}
};
syn::parse2(tokens).expect("Failed to parse struct")
})
.map(|(key, value)| generate_struct(key, value))
.flatten()
.collect();
let syn_enums = enums
@@ -196,11 +178,22 @@ fn main() {
.collect::<Vec<syn::Variant>>();
let key = modify_keyword(key);
let key = syn::Ident::new(&key.to_pascal_case(), proc_macro2::Span::call_site());
let tokens = quote::quote! {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum #key {
#(#variants),*
let desc = value.description.clone();
let tokens = if let Some(desc) = desc {
let desc = format!(" {}", desc);
quote::quote! {
#[doc = #desc]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum #key {
#(#variants),*
}
}
} else {
quote::quote! {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum #key {
#(#variants),*
}
}
};
syn::parse2(tokens).expect("Failed to parse enum")
@@ -212,7 +205,6 @@ fn main() {
attrs: vec![],
items: syn_structs
.into_iter()
.map(syn::Item::Struct)
.chain(syn_enums.into_iter().map(syn::Item::Enum))
.collect(),
};
@@ -227,3 +219,104 @@ fn modify_keyword(name: &str) -> String {
name.to_string()
}
}
fn generate_struct(key: impl AsRef<str>, value: &Schema) -> Vec<syn::Item> {
let key = key.as_ref();
let extra_structs = value
.properties
.as_ref()
.expect("Possible properties")
.iter()
.filter_map(|(name, property)| {
if property._type == Some(Types::Object) && property.properties.is_some() {
Some(generate_struct(
&format!("{}_{}", key, name),
&Schema {
_type: Types::Object,
properties: property.properties.clone(),
one_of: None,
_enum: None,
description: property.description.clone(),
},
))
} else {
None
}
})
.flatten()
.collect::<Vec<syn::Item>>();
let fields = value
.properties
.as_ref()
.expect("Possible properties")
.iter()
.map(|(name, property)| {
let nested_struct =
property._type == Some(Types::Object) && property.properties.is_some();
let og_name = name.clone();
let name = modify_keyword(&name.to_snake_case());
let _type_desc = property.description();
let _type_desc = if let Some(desc) = &_type_desc {
Some(format!(" {}", desc))
} else {
None
};
let _type = if !nested_struct {
property.to_string()
} else {
format!("{}_{}", key, og_name).to_pascal_case()
};
let _type = if _type.contains(key) {
_type.replace(&format!("<{}>", key), format!("<Box<{}>>", key).as_str())
} else {
_type
};
syn::Field {
attrs: if let Some(desc) = _type_desc {
syn::parse_quote! {
#[doc = #desc]
#[serde(rename = #og_name)]
}
} else {
syn::parse_quote! {
#[serde(rename = #og_name)]
}
},
mutability: syn::FieldMutability::None,
vis: syn::Visibility::Public(syn::Token![pub](proc_macro2::Span::call_site())),
ident: Some(syn::Ident::new(&name, proc_macro2::Span::call_site())),
colon_token: Some(syn::token::Colon(proc_macro2::Span::call_site())),
ty: syn::parse_str(&_type).expect("Failed to parse type"),
}
})
.collect::<Vec<syn::Field>>();
let key = modify_keyword(key);
let desc = value.description.clone();
let key = syn::Ident::new(&key.to_pascal_case(), proc_macro2::Span::call_site());
let tokens = if let Some(desc) = desc {
let desc = format!(" {}", desc);
quote::quote! {
#[doc = #desc]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct #key {
#(#fields),*
}
}
} else {
quote::quote! {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct #key {
#(#fields),*
}
}
};
let mut out = syn::parse2::<syn::File>(tokens)
.expect("Failed to parse struct")
.items;
out.extend(extra_structs);
out
}

View File

@@ -4,3 +4,6 @@ version = "0.1.0"
edition = "2024"
[dependencies]
gpui = { version = "0.2.2", default-features = false, features = ["wayland"] }
tap = "1.0.1"
blurhash = "0.2.3"

View File

@@ -1,14 +1,262 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
// use ::tap::*;
//
// use std::{collections::BTreeMap, sync::Arc};
//
// use gpui::{
// App, Application, Bounds, ClickEvent, Context, ImageId, ImageSource, RenderImage, Resource,
// SharedString, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size,
// };
//
// #[derive(Clone, Debug)]
// pub struct AppState {
// pub title: SharedString,
// pub items: BTreeMap<SharedString, Item>,
// pub item_ids: BTreeMap<usize, SharedString>,
// pub current_item: Option<SharedString>,
// pub errors: Vec<String>,
// pub jellyfin_client: api::JellyfinClient,
// }
//
// #[derive(Clone, Debug)]
// pub struct Item {
// pub id: SharedString,
// pub name: SharedString,
// pub item_type: SharedString,
// pub media_type: SharedString,
// }
//
// impl Render for AppState {
// fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// div()
// .flex()
// .flex_col()
// .size_full()
// .justify_center()
// .text_color(rgb(0xffffff))
// .child(Self::header())
// .child(Self::body(self, window, cx))
// .child(Self::footer())
// }
// }
//
// actions!(jello_actions, [OpenItem, OnLoadItem, MouseDownEvent]);
//
// impl AppState {
// fn new(title: impl AsRef<str>, jellyfin_client: api::JellyfinClient) -> Self {
// AppState {
// title: SharedString::new(title.as_ref()),
// items: BTreeMap::new(),
// item_ids: BTreeMap::new(),
// current_item: None,
// errors: Vec::new(),
// jellyfin_client,
// }
// }
//
// // fn on_mouse_down(
// // &mut self,
// // event: &MouseDownEvent,
// // window: &mut Window,
// // cx: &mut Context<Self>,
// // ) {
// // // Handle mouse down event
// // }
//
// fn load_item(id: usize) -> impl Fn(&mut Self, &ClickEvent, &mut Window, &mut Context<Self>) {
// move |state: &mut Self, event: &ClickEvent, window: &mut Window, cx: &mut Context<Self>| {
// let item_id = id;
// cx.spawn(async move |entity, app| {
// tracing::info!("Loading item with id: {}", item_id);
// });
// }
// }
//
// fn hover_item(id: usize) -> impl Fn(&mut Self, &bool, &mut Window, &mut Context<Self>) {
// move |state: &mut Self, item: &bool, window: &mut Window, cx: &mut Context<Self>| {
// dbg!("Hovering over item: {:?}", id);
// }
// }
//
// fn header() -> impl IntoElement {
// div()
// .flex()
// .flex_row()
// .w_full()
// .justify_end()
// .h_20()
// .border_10()
// .bg(rgb(0x333333))
// .child(Self::button("Refresh"))
// }
//
// fn footer() -> impl IntoElement {
// div().flex().flex_row().w_full().h_20().bg(rgb(0x333333))
// }
//
// fn body(&mut self, window: &mut Window, cx: &mut Context<AppState>) -> impl IntoElement {
// div()
// .flex()
// .flex_row()
// .size_full()
// .child(Self::content(self, window, cx))
// .child(Self::sidebar(self, window, cx))
// }
//
// fn button(label: &str) -> impl IntoElement {
// div()
// .flex()
// .justify_center()
// .items_center()
// .bg(rgb(0xff00ff))
// .text_color(rgb(0xffffff))
// .border_5()
// .rounded_lg()
// .child(label.to_string())
// }
//
// fn content(&mut self, window: &mut Window, cx: &mut Context<AppState>) -> impl IntoElement {
// div()
// .debug_below()
// .w_3_4()
// // .flex()
// // .flex_wrap()
// .bg(rgb(0x111111))
// .justify_start()
// .items_start()
// .overflow_hidden()
// .child(
// div()
// .size_full()
// .flex()
// .flex_wrap()
// .justify_start()
// .items_start()
// .content_start()
// .gap_y_10()
// .gap_x_10()
// .border_t_10()
// .p_5()
// .child(Self::card(cx, 1))
// .child(Self::card(cx, 2))
// .child(Self::card(cx, 3))
// .child(Self::card(cx, 4))
// .child(Self::card(cx, 5))
// .child(Self::card(cx, 6))
// .child(Self::card(cx, 7))
// .child(Self::card(cx, 8))
// .child(Self::card(cx, 9)),
// )
// }
//
// fn sidebar(&mut self, window: &mut Window, cx: &mut Context<AppState>) -> impl IntoElement {
// div()
// .flex()
// .flex_col()
// .w_1_4()
// .min_w_1_6()
// .bg(rgb(0x222222))
// .child(div().size_full().bg(gpui::yellow()))
// }
//
// fn card(cx: &mut Context<AppState>, number: usize) -> impl IntoElement {
// div()
// .id(number)
// .on_click(cx.listener(Self::load_item(number)))
// .on_hover(cx.listener(Self::hover_item(number)))
// .flex()
// .flex_col()
// .w_48()
// .h_64()
// .p_10()
// .bg(rgb(0xff00ff))
// .rounded_lg()
// }
// }
//
// pub fn ui(jellyfin_client: api::JellyfinClient) {
// Application::new().run(|cx: &mut App| {
// let bounds = Bounds::centered(None, size(px(500.0), px(500.0)), cx);
// cx.open_window(
// WindowOptions {
// window_bounds: Some(WindowBounds::Windowed(bounds)),
// ..Default::default()
// },
// |_, cx| cx.new(|_| AppState::new("Jello Media Browser", jellyfin_client)),
// )
// .expect("Failed to open window");
// })
// }
//
// #[derive(Clone, Debug)]
// pub struct Card {
// pub id: usize,
// pub title: SharedString,
// pub description: SharedString,
// pub image: SharedString,
// pub image_blurhash: BlurHash,
// pub media_type: SharedString,
// pub loading: bool,
// }
//
// impl Render for Card {
// fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
// div()
// .id(self.id)
// .flex()
// .flex_col()
// .w_48()
// .h_64()
// .p_10()
// .bg(rgb(0xff00ff))
// .rounded_lg()
// .pipe(|card| {
// if self.loading {
// card.child(self.image_blurhash.clone())
// } else {
// card.child(gpui::img(self.image.clone()))
// }
// })
// }
// }
//
// #[derive(Clone, Debug)]
// pub struct BlurHash {
// pub id: ImageId,
// pub data: Arc<RenderImage>,
// }
//
// impl BlurHash {
// pub fn new(
// data: impl AsRef<str>,
// width: u32,
// height: u32,
// punch: f32,
// ) -> Result<Self, error_stack::Report<crate::Error>> {
// use error_stack::ResultExt;
// let decoded =
// blurhash::decode(data.as_ref(), width, height, punch).change_context(crate::Error)?;
// let buffer = image::RgbaImage::from_raw(width, height, decoded)
// .ok_or(crate::Error)
// .attach("Failed to convert")?;
// let frame = image::Frame::new(buffer);
// let render_image = RenderImage::new([frame]);
// Ok(Self {
// id: render_image.id,
// data: Arc::from(render_image),
// })
// }
// }
//
// impl Render for BlurHash {
// fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
// gpui::img(ImageSource::Render(self.data.clone()))
// }
// }
//
// impl IntoElement for BlurHash {
// type Element = gpui::Img;
//
// fn into_element(self) -> Self::Element {
// gpui::img(ImageSource::Render(self.data.clone()))
// }
// }

View File

@@ -2,6 +2,31 @@
name = "ui-iced"
version = "0.1.0"
edition = "2024"
license = "MIT"
[dependencies]
iced = { version = "0.13.1", features = ["canvas", "image", "tokio"] }
api = { version = "0.1.0", path = "../api" }
blurhash = "0.2.3"
bytes = "1.11.0"
gpui_util = "0.2.2"
iced = { workspace = true, features = [
"advanced",
"canvas",
"image",
"sipper",
"tokio",
"debug",
"hot",
], default-features = true }
iced-video = { workspace = true }
iced_aw = "0.13.0"
iced_wgpu = "0.14.0"
iced_winit = "0.14.0"
reqwest = "0.13"
tap = "1.0.1"
toml = "0.9.8"
tracing = "0.1.41"
url = "2.5.7"
uuid = "1.18.1"

130
ui-iced/src/blur_hash.rs Normal file
View File

@@ -0,0 +1,130 @@
use iced::{Element, Length, advanced::Widget};
use std::sync::Arc;
use crate::shared_string::SharedString;
#[derive(Clone)]
pub struct BlurHash {
hash: SharedString,
handle: Arc<iced::advanced::image::Handle>,
width: iced::Length,
height: iced::Length,
punch: f32,
}
impl core::fmt::Debug for BlurHash {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("BlurHash")
.field("hash", &self.hash)
.field("width", &self.width)
.field("height", &self.height)
.field("punch", &self.punch)
.finish()
}
}
impl BlurHash {
pub fn recompute(&mut self, width: u32, height: u32, punch: f32) {
let pixels = blurhash::decode(&self.hash, width, height, punch)
.unwrap_or_else(|_| vec![0; (width * height * 4) as usize]);
let handle = iced::advanced::image::Handle::from_rgba(width, height, pixels);
self.handle = Arc::new(handle);
}
pub fn new(hash: impl AsRef<str>) -> Self {
let hash = SharedString::from(hash.as_ref().to_string());
let pixels = blurhash::decode(&hash, 32, 32, 1.0).unwrap_or_else(|_| vec![0; 32 * 32 * 4]);
let handle = iced::advanced::image::Handle::from_rgba(32, 32, pixels);
let handle = Arc::new(handle);
BlurHash {
hash,
handle,
width: 32.into(),
height: 32.into(),
punch: 1.0,
}
}
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.width = width.into();
self
}
pub fn height(mut self, height: impl Into<Length>) -> Self {
self.height = height.into();
self
}
pub fn punch(mut self, punch: f32) -> Self {
self.punch = punch;
self
}
}
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer> for BlurHash
where
Renderer: iced::advanced::image::Renderer<Handle = iced::advanced::image::Handle>,
{
fn size(&self) -> iced::Size<iced::Length> {
iced::Size {
width: self.width,
height: self.height,
}
}
fn layout(
&mut self,
_tree: &mut iced::advanced::widget::Tree,
renderer: &Renderer,
limits: &iced::advanced::layout::Limits,
) -> iced::advanced::layout::Node {
let layout = iced::widget::image::layout(
renderer,
limits,
&self.handle,
self.width,
self.height,
None,
iced::ContentFit::default(),
iced::Rotation::default(),
false,
);
let height = layout.bounds().height;
let width = layout.bounds().width;
self.recompute(width as u32, height as u32, self.punch);
layout
}
fn draw(
&self,
_state: &iced::advanced::widget::Tree,
renderer: &mut Renderer,
_theme: &Theme,
_style: &iced::advanced::renderer::Style,
layout: iced::advanced::Layout<'_>,
_cursor: iced::advanced::mouse::Cursor,
_viewport: &iced::Rectangle,
) {
iced::widget::image::draw(
renderer,
layout,
&self.handle,
None,
iced::border::Radius::default(),
iced::ContentFit::default(),
iced::widget::image::FilterMethod::default(),
iced::Rotation::default(),
1.0,
1.0,
);
}
}
impl<'a, Message, Theme, Renderer> From<BlurHash> for iced::Element<'a, Message, Theme, Renderer>
where
Renderer: iced::advanced::image::Renderer<Handle = iced::advanced::image::Handle>,
{
fn from(blur_hash: BlurHash) -> Element<'a, Message, Theme, Renderer> {
iced::Element::new(blur_hash)
}
}

View File

@@ -1,53 +1,494 @@
use iced::{Element, Task};
mod settings;
mod video;
mod shared_string;
use iced_video::{Ready, Video, VideoHandle};
use shared_string::SharedString;
use tap::Pipe as _;
use std::sync::Arc;
mod blur_hash;
use blur_hash::BlurHash;
mod preview;
// use preview::Preview;
use iced::{Alignment, Element, Length, Task, widget::*};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone)]
pub struct Loading {}
#[derive(Default, Debug, Clone)]
pub struct ItemCache {
pub items: BTreeMap<uuid::Uuid, Item>,
pub tree: BTreeMap<Option<uuid::Uuid>, BTreeSet<uuid::Uuid>>,
}
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();
self.tree.entry(parent).or_default().insert(item.id);
self.items.insert(item.id, item);
}
pub fn extend<I: IntoIterator<Item = Item>>(
&mut self,
parent: impl Into<Option<uuid::Uuid>>,
items: I,
) {
let parent = parent.into();
items.into_iter().for_each(|item| {
self.insert(parent, item);
});
}
pub fn items_of(&self, parent: impl Into<Option<uuid::Uuid>>) -> Vec<&Item> {
let parent = parent.into();
self.tree.get(&None);
self.tree
.get(&parent)
.map(|ids| {
ids.iter()
.filter_map(|id| self.items.get(id))
.collect::<Vec<&Item>>()
})
.unwrap_or_default()
}
pub fn get(&self, id: &uuid::Uuid) -> Option<&Item> {
self.items.get(id)
}
}
#[derive(Clone, Debug)]
pub struct Thumbnail {
pub id: SharedString,
pub blur_hash: Option<SharedString>,
}
impl From<api::jellyfin::BaseItemDto> for Item {
fn from(dto: api::jellyfin::BaseItemDto) -> Self {
Item {
id: dto.id,
name: dto.name.map(Into::into),
parent_id: dto.parent_id,
thumbnail: dto
.image_tags
.and_then(|tags| tags.get("Primary").cloned())
.map(|tag| Thumbnail {
id: tag.clone().into(),
blur_hash: dto
.image_blur_hashes
.primary
.and_then(|hashes| hashes.get(&tag).cloned())
.map(|s| s.clone().into()),
}),
_type: dto._type,
}
}
}
#[derive(Clone, Debug)]
pub struct Item {
pub id: uuid::Uuid,
pub parent_id: Option<uuid::Uuid>,
pub name: Option<SharedString>,
pub thumbnail: Option<Thumbnail>,
pub _type: api::jellyfin::BaseItemKind,
}
#[derive(Debug, Clone, Default)]
pub enum Screen {
#[default]
Home,
Settings,
User,
Video,
}
#[derive(Debug, Clone)]
pub struct Config {
pub server_url: Option<String>,
pub device_id: Option<String>,
pub device_name: Option<String>,
pub client_name: Option<String>,
pub version: Option<String>,
}
impl Default for Config {
fn default() -> Self {
Config {
server_url: Some("http://localhost:8096".to_string()),
device_id: Some("jello-iced".to_string()),
device_name: Some("Jello Iced".to_string()),
client_name: Some("Jello".to_string()),
version: Some("0.1.0".to_string()),
}
}
}
#[derive(Debug, Clone)]
struct State {
loading: Option<Loading>,
current: Option<uuid::Uuid>,
cache: ItemCache,
jellyfin_client: Option<api::JellyfinClient>,
messages: Vec<String>,
history: Vec<Option<uuid::Uuid>>,
query: Option<String>,
screen: Screen,
settings: settings::SettingsState,
is_authenticated: bool,
video: Option<Arc<VideoHandle<Message, Ready>>>,
}
enum Screen {
Contacts(Contacts),
Conversation(Conversation),
impl State {
pub fn new() -> Self {
State {
loading: None,
current: None,
cache: ItemCache::default(),
jellyfin_client: None,
messages: Vec::new(),
history: Vec::new(),
query: None,
screen: Screen::Home,
settings: settings::SettingsState::default(),
is_authenticated: false,
video: None,
}
}
}
enum Message {
Contacts(contacts::Message),
Conversation(conversation::Message),
#[derive(Debug, Clone)]
pub enum Message {
Settings(settings::SettingsMessage),
Refresh,
Search,
SearchQueryChanged(String),
OpenItem(Option<uuid::Uuid>),
LoadedItem(Option<uuid::Uuid>, Vec<Item>),
Error(String),
Back,
Home,
Video(video::VideoMessage),
}
fn update(state: &mut State, message: Message) -> Task<Message> {
match message {
Message::Contacts(message) => {
if let Screen::Contacts(contacts) = &mut state.screen {
let action = contacts.update(message);
match action {
contacts::Action::None => Task::none(),
contacts::Action::Run(task) => task.map(Message::Contacts),
contacts::Action::Chat(contact) => {
let (conversation, task) = Conversation::new(contact);
state.screen = Screen::Conversation(conversation);
task.map(Message::Conversation)
}
Message::Settings(msg) => settings::update(state, msg),
Message::OpenItem(id) => {
if let Some(client) = state.jellyfin_client.clone() {
use api::jellyfin::BaseItemKind::*;
if let Some(cached) = id.as_ref().and_then(|id| state.cache.get(id))
&& matches!(cached._type, Video | Movie | Episode)
{
let url = client
.stream_url(id.expect("ID exists"))
.expect("Failed to get stream URL");
Task::done(Message::Video(video::VideoMessage::Open(url)))
} else {
Task::perform(
async move {
let items: Result<Vec<Item>, api::JellyfinApiError> = client
.items(id)
.await
.map(|items| items.into_iter().map(Item::from).collect());
(id, items)
},
|(msg, items)| match items {
Err(e) => Message::Error(format!("Failed to load item: {}", e)),
Ok(items) => Message::LoadedItem(msg, items),
},
)
}
} else {
Task::none()
}
}
Message::Conversation(message) => {
if let Screen::Conversation(conversation) = &mut state.screen {
conversation.update(message).map(Message::Conversation)
Message::LoadedItem(id, items) => {
state.cache.extend(id, items);
state.history.push(state.current);
state.current = id;
Task::none()
}
Message::Refresh => {
if let Some(client) = state.jellyfin_client.clone() {
let current = state.current;
Task::perform(
async move {
let items: Result<Vec<Item>, api::JellyfinApiError> = client
.items(current)
.await
.map(|items| items.into_iter().map(Item::from).collect());
(current, items)
},
|(msg, items)| match items {
Err(e) => Message::Error(format!("Failed to refresh items: {}", e)),
Ok(items) => Message::LoadedItem(msg, items),
},
)
} else {
Task::none()
}
}
Message::Error(err) => {
tracing::error!("Error: {}", err);
state.messages.push(err);
Task::none()
}
Message::Back => {
state.current = state.history.pop().unwrap_or(None);
Task::none()
}
Message::Home => {
state.current = None;
Task::done(Message::Refresh)
}
Message::SearchQueryChanged(query) => {
state.query = Some(query);
Task::none()
}
Message::Search => {
// Handle search action
// let client = state.jellyfin_client.clone();
if let Some(client) = state.jellyfin_client.clone() {
let query = state.query.clone().unwrap_or_default();
Task::perform(async move { client.search(query).await }, |r| match r {
Err(e) => Message::Error(format!("Search failed: {}", e)),
Ok(items) => {
let items = items.into_iter().map(Item::from).collect();
Message::LoadedItem(None, items)
}
})
} else {
Task::none()
}
}
Message::Video(msg) => video::update(state, msg),
_ => todo!(),
}
}
fn view(state: &State) -> Element<Message> {
match &state.screen {
Screen::Contacts(contacts) => contacts.view().map(Message::Contacts),
Screen::Conversation(conversation) => conversation.view().map(Message::Conversation),
fn view(state: &State) -> Element<'_, Message> {
let content = home(state);
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,
}
}
fn home(state: &State) -> Element<'_, Message> {
column([header(state), body(state), footer(state)])
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn body(state: &State) -> Element<'_, Message> {
if let Some(ref video) = state.video {
video::player(video)
} else {
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)
.pipe(scrollable)
.height(Length::Fill)
.into()
}
}
fn header(state: &State) -> Element<'_, Message> {
row([
text(
state
.jellyfin_client
.as_ref()
.map(|c| c.config.server_url.as_str())
.unwrap_or("No Server"),
)
.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),
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)
.align_x(Alignment::End)
.align_y(Alignment::Center)
.style(container::rounded_box)
.into(),
])
.align_y(Alignment::Center)
.width(Length::Fill)
.height(50)
.into()
}
fn search(state: &State) -> Element<'_, Message> {
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> {
container(
column(
state
.messages
.iter()
.map(|msg| Text::new(msg).size(12).into())
.collect::<Vec<Element<'_, Message>>>(),
)
.spacing(5),
)
.padding(10)
.width(Length::Fill)
.height(Length::Shrink)
.style(container::rounded_box)
.into()
}
fn card(item: &Item) -> Element<'_, Message> {
let name = item
.name
.as_ref()
.map(|s| s.as_ref())
.unwrap_or("Unnamed Item");
MouseArea::new(
container(
column([
BlurHash::new(
item.thumbnail
.as_ref()
.and_then(|t| t.blur_hash.as_ref())
.map(|s| s.as_ref())
.unwrap_or(""),
)
.width(Length::Fill)
.height(Length::FillPortion(5))
.into(),
Text::new(name)
.size(16)
.align_y(Alignment::Center)
.height(Length::FillPortion(1))
.into(),
])
.align_x(Alignment::Center)
.width(Length::Fill)
.height(Length::Fill),
)
.style(container::rounded_box),
)
.on_press(Message::OpenItem(Some(item.id)))
.into()
}
fn init() -> (State, Task<Message>) {
// Create a default config for initial state
// let default_config = api::JellyfinConfig {
// server_url: "http://localhost:8096".parse().expect("Valid URL"),
// device_id: "jello-iced".to_string(),
// device_name: "Jello Iced".to_string(),
// client_name: "Jello".to_string(),
// version: "0.1.0".to_string(),
// };
// let default_client = api::JellyfinClient::new_with_config(default_config);
(
State::new(),
Task::perform(
async move {
let config_str = std::fs::read_to_string("config.toml")
.map_err(|e| api::JellyfinApiError::IoError(e))?;
let config: api::JellyfinConfig = toml::from_str(&config_str).map_err(|e| {
api::JellyfinApiError::IoError(std::io::Error::new(
std::io::ErrorKind::InvalidData,
e,
))
})?;
// Try to load cached token and authenticate
match std::fs::read_to_string(".session") {
Ok(token) => {
let client = api::JellyfinClient::pre_authenticated(token.trim(), config)?;
Ok((client, true))
}
Err(_) => {
// No cached token, create unauthenticated client
let client = api::JellyfinClient::new_with_config(config);
Ok((client, false))
}
}
},
|result: Result<_, api::JellyfinApiError>| match result {
// Ok((client, is_authenticated)) => Message::LoadedClient(client, is_authenticated),
Err(e) => Message::Error(format!("Initialization failed: {}", e)),
_ => Message::Error("Login Unimplemented".to_string()),
},
)
.chain(Task::done(Message::Refresh)),
)
}
pub fn ui() -> iced::Result {
iced::application(init, update, view).run()
}

224
ui-iced/src/preview.rs Normal file
View File

@@ -0,0 +1,224 @@
use iced::{Animation, Function, advanced::image::Handle, widget::image};
use reqwest::Method;
use std::sync::Arc;
use crate::blur_hash::BlurHash;
#[derive(Clone)]
pub struct ImageDownloader {
client: reqwest::Client,
request_modifier:
Option<Arc<dyn Fn(reqwest::RequestBuilder) -> reqwest::RequestBuilder + Send + Sync>>,
}
impl core::fmt::Debug for ImageDownloader {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("ImageDownloader")
.field("client", &self.client)
.field("request_modifier", &self.request_modifier.is_some())
.finish()
}
}
impl ImageDownloader {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
request_modifier: None,
}
}
pub fn with_modifier<F>(mut self, f: F) -> Self
where
F: Fn(reqwest::RequestBuilder) -> reqwest::RequestBuilder + Send + Sync + 'static,
{
self.request_modifier = Some(Arc::new(f));
self
}
pub async fn download(&self, url: &str) -> reqwest::Result<bytes::Bytes> {
use ::tap::*;
let response = self
.client
.request(Method::GET, url)
.pipe(|builder| {
if let Some(ref modifier) = self.request_modifier {
modifier(builder)
} else {
builder
}
})
.send()
.await?;
let bytes = response.bytes().await?;
Ok(bytes)
}
}
#[derive(Clone, Debug)]
pub enum Preview {
Thumbnail {
thumbnail: Image,
blur_hash: BlurHash,
},
BlurHash {
blur_hash: BlurHash,
},
}
#[derive(Debug, Clone)]
pub struct ImageCache {
cache: std::collections::HashMap<uuid::Uuid, Preview>,
downloader: ImageDownloader,
}
impl Preview {
pub fn thumbnail(image: Image, blur_hash: BlurHash) -> Self {
Preview::Thumbnail {
thumbnail: image,
blur_hash,
}
}
pub fn blur_hash(blur_hash: BlurHash) -> Self {
Preview::BlurHash { blur_hash }
}
// pub fn upgrade(
// self,
// fut: impl core::future::Future<Output = bytes::Bytes> + 'static + Send,
// ) -> iced::Task<PreviewMessage> {
// // let sip = iced::task::sipper(async move |mut sender| {
// // let bytes = fut.await;
// // let handle = Handle::from_bytes(bytes.clone());
// // let allocation = image::allocate(handle);
// // let image = Image {
// // bytes,
// // handle,
// // allocation,
// // fade_in: Animation::new(false),
// // };
// // let _ = sender.send(image).await;
// // });
// // iced::Task::sip(sip, ||)
// Task::
// }
}
enum PreviewMessage {
BlurHashLoaded(uuid::Uuid, BlurHash),
ThumbnailDownloaded(uuid::Uuid, bytes::Bytes),
// ThumbnailAllocated(
// uuid::Uuid,
// Result<image::Allocation, iced::advanced::image::Error>,
// ),
ThumbnailLoaded(uuid::Uuid, Image),
Failed(uuid::Uuid, String),
}
#[derive(Clone, Debug)]
pub struct Image {
bytes: bytes::Bytes,
handle: Handle,
allocation: image::Allocation,
fade_in: Animation<bool>,
}
#[derive(Clone, Debug)]
pub struct PreviewSource {
pub id: uuid::Uuid,
pub url: String,
pub width: iced::Length,
pub height: iced::Length,
pub blur_hash: Option<String>,
}
impl PreviewSource {
pub fn new(id: uuid::Uuid, url: String) -> Self {
Self {
id,
url,
width: iced::Length::Fill,
height: iced::Length::Fill,
blur_hash: None,
}
}
pub fn blur_hash(mut self, blur_hash: String) -> Self {
self.blur_hash = Some(blur_hash);
self
}
pub fn url(mut self, url: impl Into<String>) -> Self {
self.url = url.into();
self
}
pub fn width(mut self, width: iced::Length) -> Self {
self.width = width;
self
}
pub fn height(mut self, height: iced::Length) -> Self {
self.height = height;
self
}
// pub fn upgrade(self) -> Task<PreviewMessage> {
// let sip = iced::task::sipper(async move |mut sender| {
// let bytes = fut.await;
// let handle = Handle::from_bytes(bytes.clone());
// let allocation = image::allocate(handle);
// let image = Image {
// bytes,
// handle,
// allocation,
// fade_in: Animation::new(false),
// };
// let _ = sender.send(image).await;
// });
// // iced::Task::sip(sip, ||)
// }
}
impl ImageCache {
pub fn upgrade(&self, source: PreviewSource) -> iced::Task<PreviewMessage> {
let downloader = self.downloader.clone();
let sipper = iced::task::sipper(async move |mut sender| {
if let Some(blur_hash_str) = source.blur_hash {
let blur_hash = BlurHash::new(&blur_hash_str);
let _ = sender.send(blur_hash).await;
}
let bytes = downloader.download(&source.url).await?;
reqwest::Result::<bytes::Bytes>::Ok(bytes)
});
iced::Task::sip(
sipper,
move |progress| PreviewMessage::BlurHashLoaded(source.id, progress),
move |output: reqwest::Result<bytes::Bytes>| match output {
Ok(bytes) => PreviewMessage::ThumbnailDownloaded(source.id, bytes),
Err(e) => PreviewMessage::Failed(source.id, e.to_string()),
},
)
.then(|message| match message {
PreviewMessage::ThumbnailDownloaded(id, bytes) => {
let handle = Handle::from_bytes(bytes.clone());
let allocation = image::allocate(&handle);
allocation.map(move |output| match output {
Ok(allocation) => {
let image = Image {
bytes: bytes.clone(),
handle: handle.clone(),
allocation,
fade_in: Animation::new(false),
};
PreviewMessage::ThumbnailLoaded(id, image)
}
Err(e) => PreviewMessage::Failed(id, e.to_string()),
})
}
other => iced::Task::done(other),
})
}
}

296
ui-iced/src/settings.rs Normal file
View File

@@ -0,0 +1,296 @@
use crate::*;
use iced::Element;
pub fn settings(state: &State) -> Element<'_, Message> {
screens::settings(state)
}
pub fn update(state: &mut State, message: SettingsMessage) -> Task<Message> {
match message {
SettingsMessage::Open => {
tracing::trace!("Opening settings");
state.screen = Screen::Settings;
}
SettingsMessage::Close => {
tracing::trace!("Closing settings");
state.screen = Screen::Home;
}
SettingsMessage::Select(screen) => {
tracing::trace!("Switching settings screen to {:?}", screen);
state.settings.screen = screen;
}
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> {
column([]).into()
}
#[derive(Debug, Clone, Default)]
pub struct SettingsState {
login_form: LoginForm,
server_form: ServerForm,
screen: SettingsScreen,
}
#[derive(Debug, Clone)]
pub enum SettingsMessage {
Open,
Close,
Select(SettingsScreen),
User(UserMessage),
Server(ServerMessage),
}
#[derive(Debug, Clone)]
pub enum UserMessage {
Add,
UsernameChanged(String),
PasswordChanged(String),
// Edit(uuid::Uuid),
// Delete(uuid::Uuid),
Clear,
}
#[derive(Debug, Clone)]
pub enum ServerMessage {
Add,
NameChanged(String),
UrlChanged(String),
// Edit(uuid::Uuid),
// Delete(uuid::Uuid),
Clear,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum SettingsScreen {
#[default]
Main,
Users,
Servers,
}
#[derive(Debug, Clone)]
pub struct ServerItem {
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 {
username: String,
password: String,
}
impl LoginForm {
pub fn update(&mut self, message: UserMessage) {
match message {
UserMessage::UsernameChanged(data) => {
self.username = data;
}
UserMessage::PasswordChanged(data) => {
self.password = data;
}
UserMessage::Add => {
// Handle adding user
}
UserMessage::Clear => {
self.username.clear();
self.password.clear();
}
}
}
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)))
}),
row![
button(text("Add User")).on_press_maybe(self.validate()),
button(text("Cancel"))
.on_press(Message::Settings(SettingsMessage::User(UserMessage::Clear))),
]
.spacing(10),
]
.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) {
match message {
ServerMessage::NameChanged(data) => {
self.name = data;
}
ServerMessage::UrlChanged(data) => {
self.url = data;
}
ServerMessage::Add => {
// Handle adding server
}
ServerMessage::Clear => {
self.name.clear();
self.url.clear();
}
_ => {}
}
}
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 {
use iced_aw::Tabs;
use super::*;
pub fn settings(state: &State) -> Element<'_, Message> {
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> {
container(match state.settings.screen {
SettingsScreen::Main => main(state),
SettingsScreen::Servers => server(state),
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> {
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> {
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> {
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()
}
}
pub fn center_text(content: &str) -> Element<'_, Message> {
text(content)
.align_x(Alignment::Center)
.width(Length::Fill)
.into()
}

View File

@@ -0,0 +1,89 @@
use std::{borrow::Cow, sync::Arc};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SharedString(ArcCow<'static, str>);
impl From<String> for SharedString {
fn from(s: String) -> Self {
SharedString(ArcCow::Owned(Arc::from(s)))
}
}
impl<'a> iced::advanced::text::IntoFragment<'a> for SharedString {
fn into_fragment(self) -> Cow<'a, str> {
match self.0 {
ArcCow::Borrowed(b) => Cow::Borrowed(b),
ArcCow::Owned(o) => Cow::Owned(o.as_ref().to_string()),
}
}
}
impl<'a> iced::advanced::text::IntoFragment<'a> for &SharedString {
fn into_fragment(self) -> Cow<'a, str> {
match &self.0 {
ArcCow::Borrowed(b) => Cow::Borrowed(b),
ArcCow::Owned(o) => Cow::Owned(o.as_ref().to_string()),
}
}
}
impl From<&'static str> for SharedString {
fn from(s: &'static str) -> Self {
SharedString(ArcCow::Borrowed(s))
}
}
impl AsRef<str> for SharedString {
fn as_ref(&self) -> &str {
match &self.0 {
ArcCow::Borrowed(b) => b,
ArcCow::Owned(o) => o.as_ref(),
}
}
}
impl std::ops::Deref for SharedString {
type Target = str;
fn deref(&self) -> &Self::Target {
self.as_ref()
}
}
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct SecretSharedString(ArcCow<'static, str>);
impl core::fmt::Debug for SecretSharedString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("(..secret..)")
}
}
impl From<String> for SecretSharedString {
fn from(s: String) -> Self {
Self(ArcCow::Owned(Arc::from(s)))
}
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub enum ArcCow<'a, T: ?Sized> {
Borrowed(&'a T),
Owned(Arc<T>),
}
impl<'a, T> Clone for ArcCow<'a, T>
where
T: ?Sized,
{
fn clone(&self) -> Self {
match self {
ArcCow::Borrowed(b) => ArcCow::Borrowed(b),
ArcCow::Owned(o) => ArcCow::Owned(Arc::clone(o)),
}
}
}
impl<'a, T> From<&'a T> for ArcCow<'a, T> {
fn from(value: &'a T) -> Self {
ArcCow::Borrowed(value)
}
}

78
ui-iced/src/video.rs Normal file
View File

@@ -0,0 +1,78 @@
use super::*;
#[derive(Debug, Clone)]
pub enum VideoMessage {
EndOfStream,
Open(url::Url),
Loaded(VideoHandle<Message, Ready>),
Pause,
Play,
Seek(f64),
Stop,
Test,
}
pub fn update(state: &mut State, message: VideoMessage) -> Task<Message> {
match message {
VideoMessage::EndOfStream => {
state.video = None;
Task::none()
}
VideoMessage::Open(url) => {
Task::perform(VideoHandle::load(url.clone()), move |result| match result {
Ok(video) => Message::Video(VideoMessage::Loaded(video)),
Err(err) => Message::Error(format!("Error opening video at {}: {:?}", url, err)),
})
}
VideoMessage::Loaded(video) => {
state.video = Some(Arc::new(
video.on_end_of_stream(Message::Video(VideoMessage::EndOfStream)),
));
Task::done(VideoMessage::Play).map(Message::Video)
}
VideoMessage::Pause => {
if let Some(ref video) = state.video {
video.pause();
}
Task::none()
}
VideoMessage::Play => {
if let Some(ref video) = state.video {
video.play();
}
Task::none()
}
VideoMessage::Seek(position) => {
// if let Some(ref video) = state.video {
// // video.seek(position, true);
// }
Task::none()
}
VideoMessage::Stop => {
state.video.as_ref().map(|video| {
video.stop();
});
state.video = None;
Task::none()
}
VideoMessage::Test => {
let url = url::Url::parse("https://jellyfin.tsuba.darksailor.dev/Items/6010382cf25273e624d305907010d773/Download?api_key=036c140222464878862231ef66a2bc9c")
.expect("Impossible: Failed to parse hardcoded URL");
Task::done(VideoMessage::Open(url)).map(Message::Video)
}
}
}
pub fn player(video: &VideoHandle<Message, Ready>) -> Element<'_, Message> {
container(
Video::new(video)
.width(Length::Fill)
.height(Length::Fill)
.content_fit(iced::ContentFit::Contain),
)
.style(|_| container::background(iced::Color::BLACK))
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.into()
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long