Compare commits
9 Commits
a6ef6ba9c0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77fe7b6bb4 | ||
|
|
0fb5cb1d99 | ||
|
|
61a2ea1733 | ||
|
|
b1cfc19b96 | ||
|
|
f41625e0ed | ||
|
|
29674df85e | ||
|
|
442a7e49b2 | ||
|
|
a1c36e4fb2 | ||
|
|
3222c26bb6 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@
|
|||||||
.session
|
.session
|
||||||
api/config.toml
|
api/config.toml
|
||||||
api/items.json
|
api/items.json
|
||||||
|
config.toml
|
||||||
|
|||||||
1783
Cargo.lock
generated
1783
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
25
Cargo.toml
25
Cargo.toml
@@ -1,5 +1,23 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [".", "api", "typegen", "ui-gpui", "ui-iced"]
|
members = [
|
||||||
|
".",
|
||||||
|
"api",
|
||||||
|
"typegen",
|
||||||
|
"ui-gpui",
|
||||||
|
"ui-iced",
|
||||||
|
"crates/iced_video_player",
|
||||||
|
]
|
||||||
|
[workspace.dependencies]
|
||||||
|
iced = { git = "https://github.com/iced-rs/iced", features = [
|
||||||
|
"advanced",
|
||||||
|
"canvas",
|
||||||
|
"image",
|
||||||
|
"sipper",
|
||||||
|
"tokio",
|
||||||
|
"debug",
|
||||||
|
] }
|
||||||
|
iced_wgpu = { git = "https://github.com/iced-rs/iced" }
|
||||||
|
iced_video_player = { path = "crates/iced_video_player" }
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "jello"
|
name = "jello"
|
||||||
@@ -9,16 +27,13 @@ license = "MIT"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
api = { version = "0.1.0", path = "api" }
|
api = { version = "0.1.0", path = "api" }
|
||||||
blurhash = "0.2.3"
|
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
clap_complete = "4.5"
|
clap_complete = "4.5"
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
error-stack = "0.6"
|
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"
|
thiserror = "2.0"
|
||||||
tokio = { version = "1.43.1", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1.43.1", features = ["macros", "rt-multi-thread"] }
|
||||||
|
toml = "0.9.8"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
bytes = "1.11.0"
|
||||||
iref = { version = "3.2.2", features = ["serde"] }
|
iref = { version = "3.2.2", features = ["serde"] }
|
||||||
jiff = { version = "0.2.16", features = ["serde"] }
|
jiff = { version = "0.2.16", features = ["serde"] }
|
||||||
reqwest = { version = "0.12.24", features = ["json"] }
|
reqwest = { version = "0.12.24", features = ["json"] }
|
||||||
@@ -13,6 +14,8 @@ tap = "1.0.1"
|
|||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
tokio = { version = "1.48.0", features = ["fs"] }
|
tokio = { version = "1.48.0", features = ["fs"] }
|
||||||
toml = "0.9.8"
|
toml = "0.9.8"
|
||||||
|
tracing = "0.1.41"
|
||||||
|
url = "2.5.7"
|
||||||
uuid = { version = "1.18.1", features = ["serde"] }
|
uuid = { version = "1.18.1", features = ["serde"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -2,15 +2,31 @@ use api::*;
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
pub async fn main() {
|
pub async fn main() {
|
||||||
let config = std::fs::read_to_string("config.toml").expect("Config.toml");
|
let config = std::fs::read_to_string("config.toml").expect("Config.toml");
|
||||||
let config: JellyfinConfig =
|
let config: JellyfinConfig = toml::from_str(&config).expect("Failed to parse config.toml");
|
||||||
toml::from_str(&config).expect("Failed to parse config.toml");
|
|
||||||
|
|
||||||
let mut jellyfin = JellyfinClient::new(config);
|
let mut jellyfin = JellyfinClient::new(config);
|
||||||
jellyfin.authenticate_with_cached_token(".session").await.expect("Auth");
|
jellyfin
|
||||||
let items = jellyfin.raw_items().await.expect("Items");
|
.authenticate_with_cached_token(".session")
|
||||||
|
.await
|
||||||
|
.expect("Auth");
|
||||||
|
let items = jellyfin.items(None).await.expect("Items");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
"items.json",
|
"items.json",
|
||||||
serde_json::to_string_pretty(&items).expect("Serialize items"),
|
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
121
api/src/lib.rs
121
api/src/lib.rs
@@ -1,5 +1,7 @@
|
|||||||
pub mod jellyfin;
|
pub mod jellyfin;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use ::tap::*;
|
use ::tap::*;
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -12,6 +14,8 @@ pub enum JellyfinApiError {
|
|||||||
SerdeError(#[from] serde_json::Error),
|
SerdeError(#[from] serde_json::Error),
|
||||||
#[error("IO error: {0}")]
|
#[error("IO error: {0}")]
|
||||||
IoError(#[from] std::io::Error),
|
IoError(#[from] std::io::Error),
|
||||||
|
#[error("Unknown Jellyfin API error")]
|
||||||
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
type Result<T, E = JellyfinApiError> = std::result::Result<T, E>;
|
type Result<T, E = JellyfinApiError> = std::result::Result<T, E>;
|
||||||
@@ -19,8 +23,8 @@ type Result<T, E = JellyfinApiError> = std::result::Result<T, E>;
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct JellyfinClient {
|
pub struct JellyfinClient {
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
access_token: Option<String>,
|
access_token: Option<Arc<str>>,
|
||||||
config: JellyfinConfig,
|
pub config: Arc<JellyfinConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JellyfinClient {
|
impl JellyfinClient {
|
||||||
@@ -28,13 +32,13 @@ impl JellyfinClient {
|
|||||||
JellyfinClient {
|
JellyfinClient {
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
access_token: None,
|
access_token: None,
|
||||||
config,
|
config: Arc::new(config),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn save_token(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
|
pub async fn save_token(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
|
||||||
if let Some(token) = &self.access_token {
|
if let Some(token) = &self.access_token {
|
||||||
tokio::fs::write(path, token).await
|
tokio::fs::write(path, &**token).await
|
||||||
} else {
|
} else {
|
||||||
Err(std::io::Error::new(
|
Err(std::io::Error::new(
|
||||||
std::io::ErrorKind::Other,
|
std::io::ErrorKind::Other,
|
||||||
@@ -43,10 +47,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?;
|
let token = tokio::fs::read_to_string(path).await?;
|
||||||
self.access_token = Some(token);
|
self.access_token = Some(token.clone().into());
|
||||||
Ok(())
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_token(&mut self, token: impl AsRef<str>) {
|
||||||
|
self.access_token = Some(token.as_ref().into());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn request_builder(
|
pub fn request_builder(
|
||||||
@@ -59,7 +70,7 @@ impl JellyfinClient {
|
|||||||
.header("X-Emby-Authorization", format!("MediaBrowser Client=\"Jello\", Device=\"Jello\", DeviceId=\"{}\", Version=\"1.0.0\"", self.config.device_id))
|
.header("X-Emby-Authorization", format!("MediaBrowser Client=\"Jello\", Device=\"Jello\", DeviceId=\"{}\", Version=\"1.0.0\"", self.config.device_id))
|
||||||
.pipe(|builder| {
|
.pipe(|builder| {
|
||||||
if let Some(token) = &self.access_token {
|
if let Some(token) = &self.access_token {
|
||||||
builder.header("X-MediaBrowser-Token", token)
|
builder.header("X-MediaBrowser-Token", &**token)
|
||||||
} else {
|
} else {
|
||||||
builder
|
builder
|
||||||
}
|
}
|
||||||
@@ -118,20 +129,33 @@ impl JellyfinClient {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
self.access_token = auth_result.access_token.clone();
|
self.access_token = auth_result.access_token.clone().map(Into::into);
|
||||||
Ok(auth_result)
|
Ok(auth_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn authenticate_with_cached_token(
|
pub async fn authenticate_with_cached_token(
|
||||||
&mut self,
|
&mut self,
|
||||||
path: impl AsRef<std::path::Path>,
|
path: impl AsRef<std::path::Path>,
|
||||||
) -> Result<()> {
|
) -> Result<String> {
|
||||||
let path = path.as_ref();
|
let path = path.as_ref();
|
||||||
if !self.load_token(path).await.is_ok() {
|
if let Ok(token) = self
|
||||||
self.authenticate().await?;
|
.load_token(path)
|
||||||
|
.await
|
||||||
|
.inspect_err(|err| tracing::warn!("Failed to load cached token: {}", err))
|
||||||
|
{
|
||||||
|
tracing::info!("Authenticating with cached token from {:?}", path);
|
||||||
|
self.access_token = Some(token.clone().into());
|
||||||
|
Ok(token)
|
||||||
|
} else {
|
||||||
|
tracing::info!("No cached token found at {:?}, authenticating...", path);
|
||||||
|
let token = self
|
||||||
|
.authenticate()
|
||||||
|
.await?
|
||||||
|
.access_token
|
||||||
|
.ok_or_else(|| JellyfinApiError::Unknown)?;
|
||||||
self.save_token(path).await?;
|
self.save_token(path).await?;
|
||||||
|
Ok(token)
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn raw_items(&self) -> Result<jellyfin::BaseItemDtoQueryResult> {
|
pub async fn raw_items(&self) -> Result<jellyfin::BaseItemDtoQueryResult> {
|
||||||
@@ -148,7 +172,7 @@ impl JellyfinClient {
|
|||||||
|
|
||||||
pub async fn items(
|
pub async fn items(
|
||||||
&self,
|
&self,
|
||||||
root: impl Into<Option<String>>,
|
root: impl Into<Option<uuid::Uuid>>,
|
||||||
) -> Result<Vec<jellyfin::BaseItemDto>> {
|
) -> Result<Vec<jellyfin::BaseItemDto>> {
|
||||||
let text = &self
|
let text = &self
|
||||||
.request_builder(Method::GET, "Items")
|
.request_builder(Method::GET, "Items")
|
||||||
@@ -161,6 +185,75 @@ impl JellyfinClient {
|
|||||||
let out: jellyfin::BaseItemDtoQueryResult = serde_json::from_str(&text)?;
|
let out: jellyfin::BaseItemDtoQueryResult = serde_json::from_str(&text)?;
|
||||||
Ok(out.items)
|
Ok(out.items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
// item,
|
||||||
|
);
|
||||||
|
Ok(url::Url::parse(&stream_url).expect("Failed to parse stream URL"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pub trait Item {
|
// pub trait Item {
|
||||||
|
|||||||
3897
api/types.rs
3897
api/types.rs
File diff suppressed because it is too large
Load Diff
3
crates/iced_video_player/.gitignore
vendored
Normal file
3
crates/iced_video_player/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
.direnv
|
||||||
|
.media
|
||||||
5217
crates/iced_video_player/Cargo.lock
generated
Normal file
5217
crates/iced_video_player/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
63
crates/iced_video_player/Cargo.toml
Normal file
63
crates/iced_video_player/Cargo.toml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
[package]
|
||||||
|
name = "iced_video_player"
|
||||||
|
description = "A convenient video player widget for Iced"
|
||||||
|
homepage = "https://github.com/jazzfool/iced_video_player"
|
||||||
|
repository = "https://github.com/jazzfool/iced_video_player"
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = ["gui", "iced", "video"]
|
||||||
|
categories = ["gui", "multimedia"]
|
||||||
|
version = "0.6.0"
|
||||||
|
authors = ["jazzfool"]
|
||||||
|
edition = "2021"
|
||||||
|
resolver = "2"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
exclude = [".media/test.mp4"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
iced = { git = "https://github.com/iced-rs/iced", features = [
|
||||||
|
"image",
|
||||||
|
"advanced",
|
||||||
|
"wgpu",
|
||||||
|
] }
|
||||||
|
iced_wgpu = { git = "https://github.com/iced-rs/iced" }
|
||||||
|
gstreamer = "0.23"
|
||||||
|
gstreamer-app = "0.23" # appsink
|
||||||
|
gstreamer-base = "0.23" # basesrc
|
||||||
|
glib = "0.20" # gobject traits and error type
|
||||||
|
log = "0.4"
|
||||||
|
thiserror = "1"
|
||||||
|
url = "2" # media uri
|
||||||
|
|
||||||
|
[package.metadata.nix]
|
||||||
|
systems = ["x86_64-linux"]
|
||||||
|
app = true
|
||||||
|
build = true
|
||||||
|
runtimeLibs = [
|
||||||
|
"vulkan-loader",
|
||||||
|
"wayland",
|
||||||
|
"wayland-protocols",
|
||||||
|
"libxkbcommon",
|
||||||
|
"xorg.libX11",
|
||||||
|
"xorg.libXrandr",
|
||||||
|
"xorg.libXi",
|
||||||
|
"gst_all_1.gstreamer",
|
||||||
|
"gst_all_1.gstreamermm",
|
||||||
|
"gst_all_1.gst-plugins-bad",
|
||||||
|
"gst_all_1.gst-plugins-ugly",
|
||||||
|
"gst_all_1.gst-plugins-good",
|
||||||
|
"gst_all_1.gst-plugins-base",
|
||||||
|
]
|
||||||
|
buildInputs = [
|
||||||
|
"libxkbcommon",
|
||||||
|
"gst_all_1.gstreamer",
|
||||||
|
"gst_all_1.gstreamermm",
|
||||||
|
"gst_all_1.gst-plugins-bad",
|
||||||
|
"gst_all_1.gst-plugins-ugly",
|
||||||
|
"gst_all_1.gst-plugins-good",
|
||||||
|
"gst_all_1.gst-plugins-base",
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
rustc-args = ["--cfg", "docsrs"]
|
||||||
|
rustdoc-args = ["--cfg", "docsrs"]
|
||||||
|
targets = ["wasm32-unknown-unknown"]
|
||||||
176
crates/iced_video_player/LICENSE-APACHE
Normal file
176
crates/iced_video_player/LICENSE-APACHE
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
23
crates/iced_video_player/LICENSE-MIT
Normal file
23
crates/iced_video_player/LICENSE-MIT
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
Permission is hereby granted, free of charge, to any
|
||||||
|
person obtaining a copy of this software and associated
|
||||||
|
documentation files (the "Software"), to deal in the
|
||||||
|
Software without restriction, including without
|
||||||
|
limitation the rights to use, copy, modify, merge,
|
||||||
|
publish, distribute, sublicense, and/or sell copies of
|
||||||
|
the Software, and to permit persons to whom the Software
|
||||||
|
is furnished to do so, subject to the following
|
||||||
|
conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice
|
||||||
|
shall be included in all copies or substantial portions
|
||||||
|
of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||||
|
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||||
|
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||||
|
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||||
|
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||||
|
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||||
|
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||||
|
DEALINGS IN THE SOFTWARE.
|
||||||
64
crates/iced_video_player/README.md
Normal file
64
crates/iced_video_player/README.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Iced Video Player Widget
|
||||||
|
|
||||||
|
Composable component to play videos in any Iced application built on the excellent GStreamer library.
|
||||||
|
|
||||||
|
<img src=".media/screenshot.png" width="50%" />
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
In general, this supports anything that [`gstreamer/playbin`](https://gstreamer.freedesktop.org/documentation/playback/playbin.html?gi-language=c) supports.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Load video files from any file path **or URL** (support for streaming over network).
|
||||||
|
- Video buffering when streaming on a network.
|
||||||
|
- Audio support.
|
||||||
|
- Programmatic control.
|
||||||
|
- Can capture thumbnails from a set of timestamps.
|
||||||
|
- Good performance (i.e., comparable to other video players). GStreamer (with the right plugins) will perform hardware-accelerated decoding, and the color space (YUV to RGB) is converted on the GPU whilst rendering the frame.
|
||||||
|
|
||||||
|
Limitations (hopefully to be fixed):
|
||||||
|
- GStreamer is a bit annoying to set up on Windows.
|
||||||
|
|
||||||
|
The player **does not** come with any surrounding GUI controls, but they should be quite easy to implement should you need them.
|
||||||
|
See the "minimal" example for a demonstration on how you could implement pausing, looping, and seeking.
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use iced_video_player::{Video, VideoPlayer};
|
||||||
|
|
||||||
|
fn main() -> iced::Result {
|
||||||
|
iced::run("Video Player", (), App::view)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct App {
|
||||||
|
video: Video,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for App {
|
||||||
|
fn default() -> Self {
|
||||||
|
App {
|
||||||
|
video: Video::new(&url::Url::parse("file:///C:/my_video.mp4").unwrap()).unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
fn view(&self) -> iced::Element<()> {
|
||||||
|
VideoPlayer::new(&self.video).into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Follow the [GStreamer build instructions](https://github.com/sdroege/gstreamer-rs#installation). This should be able to compile on MSVC, MinGW, Linux, and MacOS.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Licensed under either
|
||||||
|
|
||||||
|
- [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0)
|
||||||
|
- [MIT](http://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
|
at your option.
|
||||||
139
crates/iced_video_player/examples/minimal.rs
Normal file
139
crates/iced_video_player/examples/minimal.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
use iced::{
|
||||||
|
widget::{Button, Column, Container, Row, Slider, Text},
|
||||||
|
Element,
|
||||||
|
};
|
||||||
|
use iced_video_player::{Video, VideoPlayer};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
fn main() -> iced::Result {
|
||||||
|
iced::run(App::update, App::view)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
enum Message {
|
||||||
|
TogglePause,
|
||||||
|
ToggleLoop,
|
||||||
|
Seek(f64),
|
||||||
|
SeekRelease,
|
||||||
|
EndOfStream,
|
||||||
|
NewFrame,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct App {
|
||||||
|
video: Video,
|
||||||
|
position: f64,
|
||||||
|
dragging: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for App {
|
||||||
|
fn default() -> Self {
|
||||||
|
App {
|
||||||
|
video: Video::new(
|
||||||
|
&url::Url::parse("https://jellyfin.tsuba.darksailor.dev/Videos/1d7e2012-e17d-edbb-25c3-2dbcc803d6b6/stream?static=true")
|
||||||
|
.expect("Failed to parse URL"),
|
||||||
|
)
|
||||||
|
.expect("Failed to create video"),
|
||||||
|
position: 0.0,
|
||||||
|
dragging: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
fn update(&mut self, message: Message) {
|
||||||
|
match message {
|
||||||
|
Message::TogglePause => {
|
||||||
|
self.video.set_paused(!self.video.paused());
|
||||||
|
}
|
||||||
|
Message::ToggleLoop => {
|
||||||
|
self.video.set_looping(!self.video.looping());
|
||||||
|
}
|
||||||
|
Message::Seek(secs) => {
|
||||||
|
self.dragging = true;
|
||||||
|
self.video.set_paused(true);
|
||||||
|
self.position = secs;
|
||||||
|
}
|
||||||
|
Message::SeekRelease => {
|
||||||
|
self.dragging = false;
|
||||||
|
self.video
|
||||||
|
.seek(Duration::from_secs_f64(self.position), false)
|
||||||
|
.expect("seek");
|
||||||
|
self.video.set_paused(false);
|
||||||
|
}
|
||||||
|
Message::EndOfStream => {
|
||||||
|
println!("end of stream");
|
||||||
|
}
|
||||||
|
Message::NewFrame => {
|
||||||
|
if !self.dragging {
|
||||||
|
self.position = self.video.position().as_secs_f64();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self) -> Element<Message> {
|
||||||
|
Column::new()
|
||||||
|
.push(
|
||||||
|
Container::new(
|
||||||
|
VideoPlayer::new(&self.video)
|
||||||
|
.width(iced::Length::Fill)
|
||||||
|
.height(iced::Length::Fill)
|
||||||
|
.content_fit(iced::ContentFit::Contain)
|
||||||
|
.on_end_of_stream(Message::EndOfStream)
|
||||||
|
.on_new_frame(Message::NewFrame),
|
||||||
|
)
|
||||||
|
.align_x(iced::Alignment::Center)
|
||||||
|
.align_y(iced::Alignment::Center)
|
||||||
|
.width(iced::Length::Fill)
|
||||||
|
.height(iced::Length::Fill),
|
||||||
|
)
|
||||||
|
.push(
|
||||||
|
Container::new(
|
||||||
|
Slider::new(
|
||||||
|
0.0..=self.video.duration().as_secs_f64(),
|
||||||
|
self.position,
|
||||||
|
Message::Seek,
|
||||||
|
)
|
||||||
|
.step(0.1)
|
||||||
|
.on_release(Message::SeekRelease),
|
||||||
|
)
|
||||||
|
.padding(iced::Padding::new(5.0).left(10.0).right(10.0)),
|
||||||
|
)
|
||||||
|
.push(
|
||||||
|
Row::new()
|
||||||
|
.spacing(5)
|
||||||
|
.align_y(iced::alignment::Vertical::Center)
|
||||||
|
.padding(iced::Padding::new(10.0).top(0.0))
|
||||||
|
.push(
|
||||||
|
Button::new(Text::new(if self.video.paused() {
|
||||||
|
"Play"
|
||||||
|
} else {
|
||||||
|
"Pause"
|
||||||
|
}))
|
||||||
|
.width(80.0)
|
||||||
|
.on_press(Message::TogglePause),
|
||||||
|
)
|
||||||
|
.push(
|
||||||
|
Button::new(Text::new(if self.video.looping() {
|
||||||
|
"Disable Loop"
|
||||||
|
} else {
|
||||||
|
"Enable Loop"
|
||||||
|
}))
|
||||||
|
.width(120.0)
|
||||||
|
.on_press(Message::ToggleLoop),
|
||||||
|
)
|
||||||
|
.push(
|
||||||
|
Text::new(format!(
|
||||||
|
"{}:{:02}s / {}:{:02}s",
|
||||||
|
self.position as u64 / 60,
|
||||||
|
self.position as u64 % 60,
|
||||||
|
self.video.duration().as_secs() / 60,
|
||||||
|
self.video.duration().as_secs() % 60,
|
||||||
|
))
|
||||||
|
.width(iced::Length::Fill)
|
||||||
|
.align_x(iced::alignment::Horizontal::Right),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
275
crates/iced_video_player/flake.lock
generated
Normal file
275
crates/iced_video_player/flake.lock
generated
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"crane": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1758758545,
|
||||||
|
"narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=",
|
||||||
|
"owner": "ipetkov",
|
||||||
|
"repo": "crane",
|
||||||
|
"rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "ipetkov",
|
||||||
|
"ref": "v0.21.1",
|
||||||
|
"repo": "crane",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dream2nix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixCargoIntegration",
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"purescript-overlay": "purescript-overlay",
|
||||||
|
"pyproject-nix": "pyproject-nix"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1763413832,
|
||||||
|
"narHash": "sha256-dkqBwDXiv8MPoFyIvOuC4bVubAP+TlVZUkVMB78TTSg=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "dream2nix",
|
||||||
|
"rev": "5658fba3a0b6b7d5cb0460b949651f64f644a743",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "dream2nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1696426674,
|
||||||
|
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flakeCompat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1761588595,
|
||||||
|
"narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mk-naked-shell": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681286841,
|
||||||
|
"narHash": "sha256-3XlJrwlR0nBiREnuogoa5i1b4+w/XPe0z8bbrJASw0g=",
|
||||||
|
"owner": "90-008",
|
||||||
|
"repo": "mk-naked-shell",
|
||||||
|
"rev": "7612f828dd6f22b7fb332cc69440e839d7ffe6bd",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "90-008",
|
||||||
|
"repo": "mk-naked-shell",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixCargoIntegration": {
|
||||||
|
"inputs": {
|
||||||
|
"crane": "crane",
|
||||||
|
"dream2nix": "dream2nix",
|
||||||
|
"mk-naked-shell": "mk-naked-shell",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"parts": "parts",
|
||||||
|
"rust-overlay": "rust-overlay",
|
||||||
|
"treefmt": "treefmt"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1763619566,
|
||||||
|
"narHash": "sha256-92rSHIwh5qTXjcktVEWyKu5EPB3/7UdgjgjtWZ5ET6w=",
|
||||||
|
"owner": "yusdacra",
|
||||||
|
"repo": "nix-cargo-integration",
|
||||||
|
"rev": "ac45d8c0d6876e6547d62bc729654c7b9a79c760",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "yusdacra",
|
||||||
|
"repo": "nix-cargo-integration",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1763421233,
|
||||||
|
"narHash": "sha256-Stk9ZYRkGrnnpyJ4eqt9eQtdFWRRIvMxpNRf4sIegnw=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "89c2b2330e733d6cdb5eae7b899326930c2c0648",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parts": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-lib": [
|
||||||
|
"nixCargoIntegration",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1762980239,
|
||||||
|
"narHash": "sha256-8oNVE8TrD19ulHinjaqONf9QWCKK+w4url56cdStMpM=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"rev": "52a2caecc898d0b46b2b905f058ccc5081f842da",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"purescript-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixCargoIntegration",
|
||||||
|
"dream2nix",
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"slimlock": "slimlock"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1728546539,
|
||||||
|
"narHash": "sha256-Sws7w0tlnjD+Bjck1nv29NjC5DbL6nH5auL9Ex9Iz2A=",
|
||||||
|
"owner": "thomashoneyman",
|
||||||
|
"repo": "purescript-overlay",
|
||||||
|
"rev": "4ad4c15d07bd899d7346b331f377606631eb0ee4",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "thomashoneyman",
|
||||||
|
"repo": "purescript-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pyproject-nix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixCargoIntegration",
|
||||||
|
"dream2nix",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1752481895,
|
||||||
|
"narHash": "sha256-luVj97hIMpCbwhx3hWiRwjP2YvljWy8FM+4W9njDhLA=",
|
||||||
|
"owner": "pyproject-nix",
|
||||||
|
"repo": "pyproject.nix",
|
||||||
|
"rev": "16ee295c25107a94e59a7fc7f2e5322851781162",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "pyproject-nix",
|
||||||
|
"repo": "pyproject.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flakeCompat": "flakeCompat",
|
||||||
|
"nixCargoIntegration": "nixCargoIntegration",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixCargoIntegration",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1763606317,
|
||||||
|
"narHash": "sha256-lsq4Urmb9Iyg2zyg2yG6oMQk9yuaoIgy+jgvYM4guxA=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "a5615abaf30cfaef2e32f1ff9bd5ca94e2911371",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"slimlock": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixCargoIntegration",
|
||||||
|
"dream2nix",
|
||||||
|
"purescript-overlay",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1688756706,
|
||||||
|
"narHash": "sha256-xzkkMv3neJJJ89zo3o2ojp7nFeaZc2G0fYwNXNJRFlo=",
|
||||||
|
"owner": "thomashoneyman",
|
||||||
|
"repo": "slimlock",
|
||||||
|
"rev": "cf72723f59e2340d24881fd7bf61cb113b4c407c",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "thomashoneyman",
|
||||||
|
"repo": "slimlock",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"treefmt": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixCargoIntegration",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1762938485,
|
||||||
|
"narHash": "sha256-AlEObg0syDl+Spi4LsZIBrjw+snSVU4T8MOeuZJUJjM=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "treefmt-nix",
|
||||||
|
"rev": "5b4ee75aeefd1e2d5a1cc43cf6ba65eba75e83e4",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "treefmt-nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
38
crates/iced_video_player/flake.nix
Normal file
38
crates/iced_video_player/flake.nix
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
inputs = {
|
||||||
|
flakeCompat = {
|
||||||
|
url = "github:edolstra/flake-compat";
|
||||||
|
flake = false;
|
||||||
|
};
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
nixCargoIntegration = {
|
||||||
|
url = "github:yusdacra/nix-cargo-integration";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = inputs: let
|
||||||
|
pkgs = import inputs.nixpkgs {
|
||||||
|
system = "x86_64-linux";
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
devShells."x86_64-linux".default = pkgs.mkShell {
|
||||||
|
# "GST_PLUGIN_PATH" = "${pkgs.gst_all_1.gstreamer}:${pkgs.gst_all_1.gst-plugins-bad}:${pkgs.gst_all_1.gst-plugins-ugly}:${pkgs.gst_all_1.gst-plugins-good}:${pkgs.gst_all_1.gst-plugins-base}";
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
gst_all_1.gstreamer
|
||||||
|
gst_all_1.gst-plugins-bad
|
||||||
|
gst_all_1.gst-plugins-ugly
|
||||||
|
gst_all_1.gst-plugins-good
|
||||||
|
gst_all_1.gst-plugins-base
|
||||||
|
libxkbcommon
|
||||||
|
wayland
|
||||||
|
rustup
|
||||||
|
];
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
pkg-config
|
||||||
|
wayland
|
||||||
|
];
|
||||||
|
packages = with pkgs; [wayland];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
12
crates/iced_video_player/shell.nix
Normal file
12
crates/iced_video_player/shell.nix
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Flake's devShell for non-flake-enabled nix instances
|
||||||
|
(import
|
||||||
|
(
|
||||||
|
let lock = builtins.fromJSON (builtins.readFile ./flake.lock);
|
||||||
|
in
|
||||||
|
fetchTarball {
|
||||||
|
url =
|
||||||
|
"https://github.com/edolstra/flake-compat/archive/${lock.nodes.flakeCompat.locked.rev}.tar.gz";
|
||||||
|
sha256 = lock.nodes.flakeCompat.locked.narHash;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
{ src = ./.; }).shellNix.default
|
||||||
76
crates/iced_video_player/src/lib.rs
Normal file
76
crates/iced_video_player/src/lib.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
//! # Iced Video Player
|
||||||
|
//!
|
||||||
|
//! A convenient video player widget for Iced.
|
||||||
|
//!
|
||||||
|
//! To get started, load a video from a URI (e.g., a file path prefixed with `file:///`) using [`Video::new`](crate::Video::new),
|
||||||
|
//! then use it like any other Iced widget in your `view` function by creating a [`VideoPlayer`].
|
||||||
|
//!
|
||||||
|
//! Example:
|
||||||
|
//! ```rust
|
||||||
|
//! use iced_video_player::{Video, VideoPlayer};
|
||||||
|
//!
|
||||||
|
//! fn main() -> iced::Result {
|
||||||
|
//! iced::run("Video Player", (), App::view)
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! struct App {
|
||||||
|
//! video: Video,
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! impl Default for App {
|
||||||
|
//! fn default() -> Self {
|
||||||
|
//! App {
|
||||||
|
//! video: Video::new(&url::Url::parse("file:///C:/my_video.mp4").unwrap()).unwrap(),
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! impl App {
|
||||||
|
//! fn view(&self) -> iced::Element<()> {
|
||||||
|
//! VideoPlayer::new(&self.video).into()
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! You can programmatically control the video (e.g., seek, pause, loop, grab thumbnails) by accessing various methods on [`Video`].
|
||||||
|
|
||||||
|
mod pipeline;
|
||||||
|
mod video;
|
||||||
|
mod video_player;
|
||||||
|
|
||||||
|
use gstreamer as gst;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub use video::Position;
|
||||||
|
pub use video::Video;
|
||||||
|
pub use video_player::VideoPlayer;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("{0}")]
|
||||||
|
Glib(#[from] glib::Error),
|
||||||
|
#[error("{0}")]
|
||||||
|
Bool(#[from] glib::BoolError),
|
||||||
|
#[error("failed to get the gstreamer bus")]
|
||||||
|
Bus,
|
||||||
|
#[error("failed to get AppSink element with name='{0}' from gstreamer pipeline")]
|
||||||
|
AppSink(String),
|
||||||
|
#[error("{0}")]
|
||||||
|
StateChange(#[from] gst::StateChangeError),
|
||||||
|
#[error("failed to cast gstreamer element")]
|
||||||
|
Cast,
|
||||||
|
#[error("{0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("invalid URI")]
|
||||||
|
Uri,
|
||||||
|
#[error("failed to get media capabilities")]
|
||||||
|
Caps,
|
||||||
|
#[error("failed to query media duration or position")]
|
||||||
|
Duration,
|
||||||
|
#[error("failed to sync with playback")]
|
||||||
|
Sync,
|
||||||
|
#[error("failed to lock internal sync primitive")]
|
||||||
|
Lock,
|
||||||
|
#[error("invalid framerate: {0}")]
|
||||||
|
Framerate(f64),
|
||||||
|
}
|
||||||
469
crates/iced_video_player/src/pipeline.rs
Normal file
469
crates/iced_video_player/src/pipeline.rs
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
use crate::video::Frame;
|
||||||
|
use iced_wgpu::primitive::Primitive;
|
||||||
|
use iced_wgpu::wgpu;
|
||||||
|
use std::{
|
||||||
|
collections::{btree_map::Entry, BTreeMap},
|
||||||
|
num::NonZero,
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||||
|
Arc, Mutex,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct Uniforms {
|
||||||
|
rect: [f32; 4],
|
||||||
|
// because wgpu min_uniform_buffer_offset_alignment
|
||||||
|
_pad: [u8; 240],
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VideoEntry {
|
||||||
|
texture_y: wgpu::Texture,
|
||||||
|
texture_uv: wgpu::Texture,
|
||||||
|
instances: wgpu::Buffer,
|
||||||
|
bg0: wgpu::BindGroup,
|
||||||
|
alive: Arc<AtomicBool>,
|
||||||
|
|
||||||
|
prepare_index: AtomicUsize,
|
||||||
|
render_index: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct VideoPipeline {
|
||||||
|
pipeline: wgpu::RenderPipeline,
|
||||||
|
bg0_layout: wgpu::BindGroupLayout,
|
||||||
|
sampler: wgpu::Sampler,
|
||||||
|
videos: BTreeMap<u64, VideoEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VideoPipeline {
|
||||||
|
fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
|
||||||
|
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("iced_video_player shader"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let bg0_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("iced_video_player bind group 0 layout"),
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 0,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 1,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 2,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 3,
|
||||||
|
visibility: wgpu::ShaderStages::VERTEX,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Uniform,
|
||||||
|
has_dynamic_offset: true,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
label: Some("iced_video_player pipeline layout"),
|
||||||
|
bind_group_layouts: &[&bg0_layout],
|
||||||
|
push_constant_ranges: &[],
|
||||||
|
});
|
||||||
|
|
||||||
|
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("iced_video_player pipeline"),
|
||||||
|
layout: Some(&layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: Some("vs_main"),
|
||||||
|
buffers: &[],
|
||||||
|
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||||||
|
},
|
||||||
|
primitive: wgpu::PrimitiveState::default(),
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: wgpu::MultisampleState {
|
||||||
|
count: 1,
|
||||||
|
mask: !0,
|
||||||
|
alpha_to_coverage_enabled: false,
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: Some("fs_main"),
|
||||||
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
|
format,
|
||||||
|
blend: None,
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
})],
|
||||||
|
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
||||||
|
}),
|
||||||
|
multiview: None,
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||||
|
label: Some("iced_video_player 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,
|
||||||
|
lod_min_clamp: 0.0,
|
||||||
|
lod_max_clamp: 1.0,
|
||||||
|
compare: None,
|
||||||
|
anisotropy_clamp: 1,
|
||||||
|
border_color: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
VideoPipeline {
|
||||||
|
pipeline,
|
||||||
|
bg0_layout,
|
||||||
|
sampler,
|
||||||
|
videos: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upload(
|
||||||
|
&mut self,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
video_id: u64,
|
||||||
|
alive: &Arc<AtomicBool>,
|
||||||
|
(width, height): (u32, u32),
|
||||||
|
frame: &[u8],
|
||||||
|
) {
|
||||||
|
if let Entry::Vacant(entry) = self.videos.entry(video_id) {
|
||||||
|
let texture_y = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("iced_video_player texture"),
|
||||||
|
size: wgpu::Extent3d {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: wgpu::TextureFormat::R8Unorm,
|
||||||
|
usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
|
||||||
|
let texture_uv = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("iced_video_player texture"),
|
||||||
|
size: wgpu::Extent3d {
|
||||||
|
width: width / 2,
|
||||||
|
height: height / 2,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: wgpu::TextureFormat::Rg8Unorm,
|
||||||
|
usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
|
||||||
|
let view_y = texture_y.create_view(&wgpu::TextureViewDescriptor {
|
||||||
|
label: Some("iced_video_player texture view"),
|
||||||
|
format: None,
|
||||||
|
dimension: None,
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
base_mip_level: 0,
|
||||||
|
mip_level_count: None,
|
||||||
|
base_array_layer: 0,
|
||||||
|
array_layer_count: None,
|
||||||
|
usage: Some(wgpu::TextureUsages::empty()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let view_uv = texture_uv.create_view(&wgpu::TextureViewDescriptor {
|
||||||
|
label: Some("iced_video_player texture view"),
|
||||||
|
format: None,
|
||||||
|
dimension: None,
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
base_mip_level: 0,
|
||||||
|
mip_level_count: None,
|
||||||
|
base_array_layer: 0,
|
||||||
|
array_layer_count: None,
|
||||||
|
usage: Some(wgpu::TextureUsages::empty()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let instances = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("iced_video_player uniform buffer"),
|
||||||
|
size: 256 * std::mem::size_of::<Uniforms>() as u64, // max 256 video players per frame
|
||||||
|
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::UNIFORM,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("iced_video_player bind group"),
|
||||||
|
layout: &self.bg0_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&view_y),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&view_uv),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 3,
|
||||||
|
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||||
|
buffer: &instances,
|
||||||
|
offset: 0,
|
||||||
|
size: Some(NonZero::new(std::mem::size_of::<Uniforms>() as _).unwrap()),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
entry.insert(VideoEntry {
|
||||||
|
texture_y,
|
||||||
|
texture_uv,
|
||||||
|
instances,
|
||||||
|
bg0: bind_group,
|
||||||
|
alive: Arc::clone(alive),
|
||||||
|
|
||||||
|
prepare_index: AtomicUsize::new(0),
|
||||||
|
render_index: AtomicUsize::new(0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let VideoEntry {
|
||||||
|
texture_y,
|
||||||
|
texture_uv,
|
||||||
|
..
|
||||||
|
} = self.videos.get(&video_id).unwrap();
|
||||||
|
|
||||||
|
queue.write_texture(
|
||||||
|
wgpu::TexelCopyTextureInfo {
|
||||||
|
texture: texture_y,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: wgpu::Origin3d::ZERO,
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
},
|
||||||
|
&frame[..(width * height) as usize],
|
||||||
|
wgpu::TexelCopyBufferLayout {
|
||||||
|
offset: 0,
|
||||||
|
bytes_per_row: Some(width),
|
||||||
|
rows_per_image: Some(height),
|
||||||
|
},
|
||||||
|
wgpu::Extent3d {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
queue.write_texture(
|
||||||
|
wgpu::TexelCopyTextureInfo {
|
||||||
|
texture: texture_uv,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: wgpu::Origin3d::ZERO,
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
},
|
||||||
|
&frame[(width * height) as usize..],
|
||||||
|
wgpu::TexelCopyBufferLayout {
|
||||||
|
offset: 0,
|
||||||
|
bytes_per_row: Some(width),
|
||||||
|
rows_per_image: Some(height / 2),
|
||||||
|
},
|
||||||
|
wgpu::Extent3d {
|
||||||
|
width: width / 2,
|
||||||
|
height: height / 2,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup(&mut self) {
|
||||||
|
let ids: Vec<_> = self
|
||||||
|
.videos
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(id, entry)| (!entry.alive.load(Ordering::SeqCst)).then_some(*id))
|
||||||
|
.collect();
|
||||||
|
for id in ids {
|
||||||
|
if let Some(video) = self.videos.remove(&id) {
|
||||||
|
video.texture_y.destroy();
|
||||||
|
video.texture_uv.destroy();
|
||||||
|
video.instances.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare(&mut self, queue: &wgpu::Queue, video_id: u64, bounds: &iced::Rectangle) {
|
||||||
|
if let Some(video) = self.videos.get_mut(&video_id) {
|
||||||
|
let uniforms = Uniforms {
|
||||||
|
rect: [
|
||||||
|
bounds.x,
|
||||||
|
bounds.y,
|
||||||
|
bounds.x + bounds.width,
|
||||||
|
bounds.y + bounds.height,
|
||||||
|
],
|
||||||
|
_pad: [0; 240],
|
||||||
|
};
|
||||||
|
queue.write_buffer(
|
||||||
|
&video.instances,
|
||||||
|
(video.prepare_index.load(Ordering::Relaxed) * std::mem::size_of::<Uniforms>())
|
||||||
|
as u64,
|
||||||
|
unsafe {
|
||||||
|
std::slice::from_raw_parts(
|
||||||
|
&uniforms as *const _ as *const u8,
|
||||||
|
std::mem::size_of::<Uniforms>(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
video.prepare_index.fetch_add(1, Ordering::Relaxed);
|
||||||
|
video.render_index.store(0, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(
|
||||||
|
&self,
|
||||||
|
target: &wgpu::TextureView,
|
||||||
|
encoder: &mut wgpu::CommandEncoder,
|
||||||
|
clip: &iced::Rectangle<u32>,
|
||||||
|
video_id: u64,
|
||||||
|
) {
|
||||||
|
if let Some(video) = self.videos.get(&video_id) {
|
||||||
|
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
|
label: Some("iced_video_player 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
pass.set_pipeline(&self.pipeline);
|
||||||
|
pass.set_bind_group(
|
||||||
|
0,
|
||||||
|
&video.bg0,
|
||||||
|
&[
|
||||||
|
(video.render_index.load(Ordering::Relaxed) * std::mem::size_of::<Uniforms>())
|
||||||
|
as u32,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
pass.set_scissor_rect(clip.x as _, clip.y as _, clip.width as _, clip.height as _);
|
||||||
|
pass.draw(0..6, 0..1);
|
||||||
|
|
||||||
|
video.prepare_index.store(0, Ordering::Relaxed);
|
||||||
|
video.render_index.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct VideoPrimitive {
|
||||||
|
video_id: u64,
|
||||||
|
alive: Arc<AtomicBool>,
|
||||||
|
frame: Arc<Mutex<Frame>>,
|
||||||
|
size: (u32, u32),
|
||||||
|
upload_frame: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VideoPrimitive {
|
||||||
|
pub fn new(
|
||||||
|
video_id: u64,
|
||||||
|
alive: Arc<AtomicBool>,
|
||||||
|
frame: Arc<Mutex<Frame>>,
|
||||||
|
size: (u32, u32),
|
||||||
|
upload_frame: bool,
|
||||||
|
) -> Self {
|
||||||
|
VideoPrimitive {
|
||||||
|
video_id,
|
||||||
|
alive,
|
||||||
|
frame,
|
||||||
|
size,
|
||||||
|
upload_frame,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Primitive for VideoPrimitive {
|
||||||
|
type Renderer = VideoPipeline;
|
||||||
|
|
||||||
|
fn initialize(
|
||||||
|
&self,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
_queue: &wgpu::Queue,
|
||||||
|
format: wgpu::TextureFormat,
|
||||||
|
) -> Self::Renderer {
|
||||||
|
VideoPipeline::new(device, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare(
|
||||||
|
&self,
|
||||||
|
renderer: &mut Self::Renderer,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
bounds: &iced::Rectangle,
|
||||||
|
viewport: &iced_wgpu::graphics::Viewport,
|
||||||
|
) {
|
||||||
|
if self.upload_frame {
|
||||||
|
if let Some(readable) = self.frame.lock().expect("lock frame mutex").readable() {
|
||||||
|
renderer.upload(
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
self.video_id,
|
||||||
|
&self.alive,
|
||||||
|
self.size,
|
||||||
|
readable.as_slice(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.prepare(
|
||||||
|
queue,
|
||||||
|
self.video_id,
|
||||||
|
&(*bounds
|
||||||
|
* iced::Transformation::orthographic(
|
||||||
|
viewport.logical_size().width as _,
|
||||||
|
viewport.logical_size().height as _,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(
|
||||||
|
&self,
|
||||||
|
renderer: &Self::Renderer,
|
||||||
|
encoder: &mut wgpu::CommandEncoder,
|
||||||
|
target: &wgpu::TextureView,
|
||||||
|
clip_bounds: &iced::Rectangle<u32>,
|
||||||
|
) {
|
||||||
|
renderer.draw(target, encoder, clip_bounds, self.video_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
crates/iced_video_player/src/shader.wgsl
Normal file
61
crates/iced_video_player/src/shader.wgsl
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
struct VertexOutput {
|
||||||
|
@builtin(position) position: vec4<f32>,
|
||||||
|
@location(0) uv: vec2<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Uniforms {
|
||||||
|
rect: vec4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0)
|
||||||
|
var tex_y: texture_2d<f32>;
|
||||||
|
|
||||||
|
@group(0) @binding(1)
|
||||||
|
var tex_uv: texture_2d<f32>;
|
||||||
|
|
||||||
|
@group(0) @binding(2)
|
||||||
|
var s: sampler;
|
||||||
|
|
||||||
|
@group(0) @binding(3)
|
||||||
|
var<uniform> uniforms: Uniforms;
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_main(@builtin(vertex_index) in_vertex_index: u32) -> VertexOutput {
|
||||||
|
var quad = array<vec4<f32>, 6>(
|
||||||
|
vec4<f32>(uniforms.rect.xy, 0.0, 0.0),
|
||||||
|
vec4<f32>(uniforms.rect.zy, 1.0, 0.0),
|
||||||
|
vec4<f32>(uniforms.rect.xw, 0.0, 1.0),
|
||||||
|
vec4<f32>(uniforms.rect.zy, 1.0, 0.0),
|
||||||
|
vec4<f32>(uniforms.rect.zw, 1.0, 1.0),
|
||||||
|
vec4<f32>(uniforms.rect.xw, 0.0, 1.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
var out: VertexOutput;
|
||||||
|
out.uv = quad[in_vertex_index].zw;
|
||||||
|
out.position = vec4<f32>(quad[in_vertex_index].xy, 1.0, 1.0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
|
let yuv2r = vec3<f32>(1.164, 0.0, 1.596);
|
||||||
|
let yuv2g = vec3<f32>(1.164, -0.391, -0.813);
|
||||||
|
let yuv2b = vec3<f32>(1.164, 2.018, 0.0);
|
||||||
|
|
||||||
|
var yuv = vec3<f32>(0.0);
|
||||||
|
yuv.x = textureSample(tex_y, s, in.uv).r - 0.0625;
|
||||||
|
yuv.y = textureSample(tex_uv, s, in.uv).r - 0.5;
|
||||||
|
yuv.z = textureSample(tex_uv, s, in.uv).g - 0.5;
|
||||||
|
|
||||||
|
var rgb = vec3<f32>(0.0);
|
||||||
|
rgb.x = dot(yuv, yuv2r);
|
||||||
|
rgb.y = dot(yuv, yuv2g);
|
||||||
|
rgb.z = dot(yuv, yuv2b);
|
||||||
|
|
||||||
|
let threshold = rgb <= vec3<f32>(0.04045);
|
||||||
|
let hi = pow((rgb + vec3<f32>(0.055)) / vec3<f32>(1.055), vec3<f32>(2.4));
|
||||||
|
let lo = rgb * vec3<f32>(1.0 / 12.92);
|
||||||
|
rgb = select(hi, lo, threshold);
|
||||||
|
|
||||||
|
return vec4<f32>(rgb, 1.0);
|
||||||
|
}
|
||||||
662
crates/iced_video_player/src/video.rs
Normal file
662
crates/iced_video_player/src/video.rs
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
use crate::Error;
|
||||||
|
use gstreamer as gst;
|
||||||
|
use gstreamer_app as gst_app;
|
||||||
|
use gstreamer_app::prelude::*;
|
||||||
|
use iced::widget::image as img;
|
||||||
|
use std::num::NonZeroU8;
|
||||||
|
use std::ops::{Deref, DerefMut};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Position in the media.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum Position {
|
||||||
|
/// Position based on time.
|
||||||
|
///
|
||||||
|
/// Not the most accurate format for videos.
|
||||||
|
Time(Duration),
|
||||||
|
/// Position based on nth frame.
|
||||||
|
Frame(u64),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Position> for gst::GenericFormattedValue {
|
||||||
|
fn from(pos: Position) -> Self {
|
||||||
|
match pos {
|
||||||
|
Position::Time(t) => gst::ClockTime::from_nseconds(t.as_nanos() as _).into(),
|
||||||
|
Position::Frame(f) => gst::format::Default::from_u64(f).into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Duration> for Position {
|
||||||
|
fn from(t: Duration) -> Self {
|
||||||
|
Position::Time(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<u64> for Position {
|
||||||
|
fn from(f: u64) -> Self {
|
||||||
|
Position::Frame(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct Frame(gst::Sample);
|
||||||
|
|
||||||
|
impl Frame {
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self(gst::Sample::builder().build())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn readable(&self) -> Option<gst::BufferMap<'_, gst::buffer::Readable>> {
|
||||||
|
self.0.buffer().and_then(|x| x.map_readable().ok())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct Internal {
|
||||||
|
pub(crate) id: u64,
|
||||||
|
|
||||||
|
pub(crate) bus: gst::Bus,
|
||||||
|
pub(crate) source: gst::Pipeline,
|
||||||
|
pub(crate) alive: Arc<AtomicBool>,
|
||||||
|
pub(crate) worker: Option<std::thread::JoinHandle<()>>,
|
||||||
|
|
||||||
|
pub(crate) width: i32,
|
||||||
|
pub(crate) height: i32,
|
||||||
|
pub(crate) framerate: f64,
|
||||||
|
pub(crate) duration: Duration,
|
||||||
|
pub(crate) speed: f64,
|
||||||
|
pub(crate) sync_av: bool,
|
||||||
|
|
||||||
|
pub(crate) frame: Arc<Mutex<Frame>>,
|
||||||
|
pub(crate) upload_frame: Arc<AtomicBool>,
|
||||||
|
pub(crate) last_frame_time: Arc<Mutex<Instant>>,
|
||||||
|
pub(crate) looping: bool,
|
||||||
|
pub(crate) is_eos: bool,
|
||||||
|
pub(crate) restart_stream: bool,
|
||||||
|
pub(crate) sync_av_avg: u64,
|
||||||
|
pub(crate) sync_av_counter: u64,
|
||||||
|
|
||||||
|
pub(crate) subtitle_text: Arc<Mutex<Option<String>>>,
|
||||||
|
pub(crate) upload_text: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Internal {
|
||||||
|
pub(crate) fn seek(&self, position: impl Into<Position>, accurate: bool) -> Result<(), Error> {
|
||||||
|
let position = position.into();
|
||||||
|
|
||||||
|
// gstreamer complains if the start & end value types aren't the same
|
||||||
|
match &position {
|
||||||
|
Position::Time(_) => self.source.seek(
|
||||||
|
self.speed,
|
||||||
|
gst::SeekFlags::FLUSH
|
||||||
|
| if accurate {
|
||||||
|
gst::SeekFlags::ACCURATE
|
||||||
|
} else {
|
||||||
|
gst::SeekFlags::empty()
|
||||||
|
},
|
||||||
|
gst::SeekType::Set,
|
||||||
|
gst::GenericFormattedValue::from(position),
|
||||||
|
gst::SeekType::Set,
|
||||||
|
gst::ClockTime::NONE,
|
||||||
|
)?,
|
||||||
|
Position::Frame(_) => self.source.seek(
|
||||||
|
self.speed,
|
||||||
|
gst::SeekFlags::FLUSH
|
||||||
|
| if accurate {
|
||||||
|
gst::SeekFlags::ACCURATE
|
||||||
|
} else {
|
||||||
|
gst::SeekFlags::empty()
|
||||||
|
},
|
||||||
|
gst::SeekType::Set,
|
||||||
|
gst::GenericFormattedValue::from(position),
|
||||||
|
gst::SeekType::Set,
|
||||||
|
gst::format::Default::NONE,
|
||||||
|
)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
*self.subtitle_text.lock().expect("lock subtitle_text") = None;
|
||||||
|
self.upload_text.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_speed(&mut self, speed: f64) -> Result<(), Error> {
|
||||||
|
let Some(position) = self.source.query_position::<gst::ClockTime>() else {
|
||||||
|
return Err(Error::Caps);
|
||||||
|
};
|
||||||
|
if speed > 0.0 {
|
||||||
|
self.source.seek(
|
||||||
|
speed,
|
||||||
|
gst::SeekFlags::FLUSH | gst::SeekFlags::ACCURATE,
|
||||||
|
gst::SeekType::Set,
|
||||||
|
position,
|
||||||
|
gst::SeekType::End,
|
||||||
|
gst::ClockTime::from_seconds(0),
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
self.source.seek(
|
||||||
|
speed,
|
||||||
|
gst::SeekFlags::FLUSH | gst::SeekFlags::ACCURATE,
|
||||||
|
gst::SeekType::Set,
|
||||||
|
gst::ClockTime::from_seconds(0),
|
||||||
|
gst::SeekType::Set,
|
||||||
|
position,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
self.speed = speed;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn restart_stream(&mut self) -> Result<(), Error> {
|
||||||
|
self.is_eos = false;
|
||||||
|
self.set_paused(false);
|
||||||
|
self.seek(0, false)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_paused(&mut self, paused: bool) {
|
||||||
|
self.source
|
||||||
|
.set_state(if paused {
|
||||||
|
gst::State::Paused
|
||||||
|
} else {
|
||||||
|
gst::State::Playing
|
||||||
|
})
|
||||||
|
.unwrap(/* state was changed in ctor; state errors caught there */);
|
||||||
|
|
||||||
|
// Set restart_stream flag to make the stream restart on the next Message::NextFrame
|
||||||
|
if self.is_eos && !paused {
|
||||||
|
self.restart_stream = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn paused(&self) -> bool {
|
||||||
|
self.source.state(gst::ClockTime::ZERO).1 == gst::State::Paused
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Syncs audio with video when there is (inevitably) latency presenting the frame.
|
||||||
|
pub(crate) fn set_av_offset(&mut self, offset: Duration) {
|
||||||
|
if self.sync_av {
|
||||||
|
self.sync_av_counter += 1;
|
||||||
|
self.sync_av_avg = self.sync_av_avg * (self.sync_av_counter - 1) / self.sync_av_counter
|
||||||
|
+ offset.as_nanos() as u64 / self.sync_av_counter;
|
||||||
|
if self.sync_av_counter % 128 == 0 {
|
||||||
|
self.source
|
||||||
|
.set_property("av-offset", -(self.sync_av_avg as i64));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A multimedia video loaded from a URI (e.g., a local file path or HTTP stream).
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Video(pub(crate) RwLock<Internal>);
|
||||||
|
|
||||||
|
impl Drop for Video {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let inner = self.0.get_mut().expect("failed to lock");
|
||||||
|
|
||||||
|
inner
|
||||||
|
.source
|
||||||
|
.set_state(gst::State::Null)
|
||||||
|
.expect("failed to set state");
|
||||||
|
|
||||||
|
inner.alive.store(false, Ordering::SeqCst);
|
||||||
|
if let Some(worker) = inner.worker.take() {
|
||||||
|
if let Err(err) = worker.join() {
|
||||||
|
match err.downcast_ref::<String>() {
|
||||||
|
Some(e) => log::error!("Video thread panicked: {e}"),
|
||||||
|
None => log::error!("Video thread panicked with unknown reason"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Video {
|
||||||
|
/// Create a new video player from a given video which loads from `uri`.
|
||||||
|
/// Note that live sources will report the duration to be zero.
|
||||||
|
pub fn new(uri: &url::Url) -> Result<Self, Error> {
|
||||||
|
gst::init()?;
|
||||||
|
|
||||||
|
let pipeline = format!("playbin uri=\"{}\" text-sink=\"appsink name=iced_text sync=true drop=true\" video-sink=\"videoscale ! videoconvert ! appsink name=iced_video drop=true caps=video/x-raw,format=NV12,pixel-aspect-ratio=1/1\"", uri.as_str());
|
||||||
|
let pipeline = gst::parse::launch(pipeline.as_ref())?
|
||||||
|
.downcast::<gst::Pipeline>()
|
||||||
|
.map_err(|_| Error::Cast)?;
|
||||||
|
|
||||||
|
let video_sink: gst::Element = pipeline.property("video-sink");
|
||||||
|
let pad = video_sink.pads().first().cloned().unwrap();
|
||||||
|
let pad = pad.dynamic_cast::<gst::GhostPad>().unwrap();
|
||||||
|
let bin = pad
|
||||||
|
.parent_element()
|
||||||
|
.unwrap()
|
||||||
|
.downcast::<gst::Bin>()
|
||||||
|
.unwrap();
|
||||||
|
let video_sink = bin.by_name("iced_video").unwrap();
|
||||||
|
let video_sink = video_sink.downcast::<gst_app::AppSink>().unwrap();
|
||||||
|
|
||||||
|
let text_sink: gst::Element = pipeline.property("text-sink");
|
||||||
|
let text_sink = text_sink.downcast::<gst_app::AppSink>().unwrap();
|
||||||
|
|
||||||
|
Self::from_gst_pipeline(pipeline, video_sink, Some(text_sink))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new video based on an existing GStreamer pipeline and appsink.
|
||||||
|
/// Expects an `appsink` plugin with `caps=video/x-raw,format=NV12`.
|
||||||
|
///
|
||||||
|
/// An optional `text_sink` can be provided, which enables subtitle messages
|
||||||
|
/// to be emitted.
|
||||||
|
///
|
||||||
|
/// **Note:** Many functions of [`Video`] assume a `playbin` pipeline.
|
||||||
|
/// Non-`playbin` pipelines given here may not have full functionality.
|
||||||
|
pub fn from_gst_pipeline(
|
||||||
|
pipeline: gst::Pipeline,
|
||||||
|
video_sink: gst_app::AppSink,
|
||||||
|
text_sink: Option<gst_app::AppSink>,
|
||||||
|
) -> Result<Self, Error> {
|
||||||
|
gst::init()?;
|
||||||
|
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
|
||||||
|
|
||||||
|
// We need to ensure we stop the pipeline if we hit an error,
|
||||||
|
// or else there may be audio left playing in the background.
|
||||||
|
macro_rules! cleanup {
|
||||||
|
($expr:expr) => {
|
||||||
|
$expr.map_err(|e| {
|
||||||
|
let _ = pipeline.set_state(gst::State::Null);
|
||||||
|
e
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let pad = video_sink.pads().first().cloned().unwrap();
|
||||||
|
|
||||||
|
dbg!(&pad);
|
||||||
|
dbg!(&pipeline);
|
||||||
|
cleanup!(pipeline.set_state(gst::State::Playing))?;
|
||||||
|
|
||||||
|
// wait for up to 5 seconds until the decoder gets the source capabilities
|
||||||
|
cleanup!(pipeline.state(gst::ClockTime::from_seconds(5)).0)?;
|
||||||
|
|
||||||
|
// extract resolution and framerate
|
||||||
|
// TODO(jazzfool): maybe we want to extract some other information too?
|
||||||
|
let caps = cleanup!(pad.current_caps().ok_or(Error::Caps))?;
|
||||||
|
let s = cleanup!(caps.structure(0).ok_or(Error::Caps))?;
|
||||||
|
let width = cleanup!(s.get::<i32>("width").map_err(|_| Error::Caps))?;
|
||||||
|
let height = cleanup!(s.get::<i32>("height").map_err(|_| Error::Caps))?;
|
||||||
|
// resolution should be mod4
|
||||||
|
let width = ((width + 4 - 1) / 4) * 4;
|
||||||
|
let framerate = cleanup!(s.get::<gst::Fraction>("framerate").map_err(|_| Error::Caps))?;
|
||||||
|
let framerate = framerate.numer() as f64 / framerate.denom() as f64;
|
||||||
|
|
||||||
|
if framerate.is_nan()
|
||||||
|
|| framerate.is_infinite()
|
||||||
|
|| framerate < 0.0
|
||||||
|
|| framerate.abs() < f64::EPSILON
|
||||||
|
{
|
||||||
|
let _ = pipeline.set_state(gst::State::Null);
|
||||||
|
return Err(Error::Framerate(framerate));
|
||||||
|
}
|
||||||
|
|
||||||
|
let duration = Duration::from_nanos(
|
||||||
|
pipeline
|
||||||
|
.query_duration::<gst::ClockTime>()
|
||||||
|
.map(|duration| duration.nseconds())
|
||||||
|
.unwrap_or(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
let sync_av = pipeline.has_property("av-offset", None);
|
||||||
|
|
||||||
|
// NV12 = 12bpp
|
||||||
|
let frame = Arc::new(Mutex::new(Frame::empty()));
|
||||||
|
let upload_frame = Arc::new(AtomicBool::new(false));
|
||||||
|
let alive = Arc::new(AtomicBool::new(true));
|
||||||
|
let last_frame_time = Arc::new(Mutex::new(Instant::now()));
|
||||||
|
|
||||||
|
let frame_ref = Arc::clone(&frame);
|
||||||
|
let upload_frame_ref = Arc::clone(&upload_frame);
|
||||||
|
let alive_ref = Arc::clone(&alive);
|
||||||
|
let last_frame_time_ref = Arc::clone(&last_frame_time);
|
||||||
|
|
||||||
|
let subtitle_text = Arc::new(Mutex::new(None));
|
||||||
|
let upload_text = Arc::new(AtomicBool::new(false));
|
||||||
|
let subtitle_text_ref = Arc::clone(&subtitle_text);
|
||||||
|
let upload_text_ref = Arc::clone(&upload_text);
|
||||||
|
|
||||||
|
let pipeline_ref = pipeline.clone();
|
||||||
|
|
||||||
|
let worker = std::thread::spawn(move || {
|
||||||
|
let mut clear_subtitles_at = None;
|
||||||
|
|
||||||
|
while alive_ref.load(Ordering::Acquire) {
|
||||||
|
if let Err(gst::FlowError::Error) = (|| -> Result<(), gst::FlowError> {
|
||||||
|
let sample =
|
||||||
|
if pipeline_ref.state(gst::ClockTime::ZERO).1 != gst::State::Playing {
|
||||||
|
video_sink
|
||||||
|
.try_pull_preroll(gst::ClockTime::from_mseconds(16))
|
||||||
|
.ok_or(gst::FlowError::Eos)?
|
||||||
|
} else {
|
||||||
|
video_sink
|
||||||
|
.try_pull_sample(gst::ClockTime::from_mseconds(16))
|
||||||
|
.ok_or(gst::FlowError::Eos)?
|
||||||
|
};
|
||||||
|
|
||||||
|
*last_frame_time_ref
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| gst::FlowError::Error)? = Instant::now();
|
||||||
|
|
||||||
|
let frame_segment = sample.segment().cloned().ok_or(gst::FlowError::Error)?;
|
||||||
|
let buffer = sample.buffer().ok_or(gst::FlowError::Error)?;
|
||||||
|
let frame_pts = buffer.pts().ok_or(gst::FlowError::Error)?;
|
||||||
|
let frame_duration = buffer.duration().ok_or(gst::FlowError::Error)?;
|
||||||
|
{
|
||||||
|
let mut frame_guard =
|
||||||
|
frame_ref.lock().map_err(|_| gst::FlowError::Error)?;
|
||||||
|
*frame_guard = Frame(sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_frame_ref.swap(true, Ordering::SeqCst);
|
||||||
|
|
||||||
|
if let Some(at) = clear_subtitles_at {
|
||||||
|
if frame_pts >= at {
|
||||||
|
*subtitle_text_ref
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| gst::FlowError::Error)? = None;
|
||||||
|
upload_text_ref.store(true, Ordering::SeqCst);
|
||||||
|
clear_subtitles_at = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = text_sink
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|sink| sink.try_pull_sample(gst::ClockTime::from_seconds(0)));
|
||||||
|
if let Some(text) = text {
|
||||||
|
let text_segment = text.segment().ok_or(gst::FlowError::Error)?;
|
||||||
|
let text = text.buffer().ok_or(gst::FlowError::Error)?;
|
||||||
|
let text_pts = text.pts().ok_or(gst::FlowError::Error)?;
|
||||||
|
let text_duration = text.duration().ok_or(gst::FlowError::Error)?;
|
||||||
|
|
||||||
|
let frame_running_time = frame_segment.to_running_time(frame_pts).value();
|
||||||
|
let frame_running_time_end = frame_segment
|
||||||
|
.to_running_time(frame_pts + frame_duration)
|
||||||
|
.value();
|
||||||
|
|
||||||
|
let text_running_time = text_segment.to_running_time(text_pts).value();
|
||||||
|
let text_running_time_end = text_segment
|
||||||
|
.to_running_time(text_pts + text_duration)
|
||||||
|
.value();
|
||||||
|
|
||||||
|
// see gst-plugins-base/ext/pango/gstbasetextoverlay.c (gst_base_text_overlay_video_chain)
|
||||||
|
// as an example of how to correctly synchronize the text+video segments
|
||||||
|
if text_running_time_end > frame_running_time
|
||||||
|
&& frame_running_time_end > text_running_time
|
||||||
|
{
|
||||||
|
let duration = text.duration().unwrap_or(gst::ClockTime::ZERO);
|
||||||
|
let map = text.map_readable().map_err(|_| gst::FlowError::Error)?;
|
||||||
|
|
||||||
|
let text = std::str::from_utf8(map.as_slice())
|
||||||
|
.map_err(|_| gst::FlowError::Error)?
|
||||||
|
.to_string();
|
||||||
|
*subtitle_text_ref
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| gst::FlowError::Error)? = Some(text);
|
||||||
|
upload_text_ref.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
|
clear_subtitles_at = Some(text_pts + duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})() {
|
||||||
|
log::error!("error pulling frame");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Video(RwLock::new(Internal {
|
||||||
|
id,
|
||||||
|
|
||||||
|
bus: pipeline.bus().unwrap(),
|
||||||
|
source: pipeline,
|
||||||
|
alive,
|
||||||
|
worker: Some(worker),
|
||||||
|
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
framerate,
|
||||||
|
duration,
|
||||||
|
speed: 1.0,
|
||||||
|
sync_av,
|
||||||
|
|
||||||
|
frame,
|
||||||
|
upload_frame,
|
||||||
|
last_frame_time,
|
||||||
|
looping: false,
|
||||||
|
is_eos: false,
|
||||||
|
restart_stream: false,
|
||||||
|
sync_av_avg: 0,
|
||||||
|
sync_av_counter: 0,
|
||||||
|
|
||||||
|
subtitle_text,
|
||||||
|
upload_text,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read(&self) -> impl Deref<Target = Internal> + '_ {
|
||||||
|
self.0.read().expect("lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn write(&self) -> impl DerefMut<Target = Internal> + '_ {
|
||||||
|
self.0.write().expect("lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_mut(&mut self) -> impl DerefMut<Target = Internal> + '_ {
|
||||||
|
self.0.get_mut().expect("lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the size/resolution of the video as `(width, height)`.
|
||||||
|
pub fn size(&self) -> (i32, i32) {
|
||||||
|
(self.read().width, self.read().height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the framerate of the video as frames per second.
|
||||||
|
pub fn framerate(&self) -> f64 {
|
||||||
|
self.read().framerate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the volume multiplier of the audio.
|
||||||
|
/// `0.0` = 0% volume, `1.0` = 100% volume.
|
||||||
|
///
|
||||||
|
/// This uses a linear scale, for example `0.5` is perceived as half as loud.
|
||||||
|
pub fn set_volume(&mut self, volume: f64) {
|
||||||
|
self.get_mut().source.set_property("volume", volume);
|
||||||
|
self.set_muted(self.muted()); // for some reason gstreamer unmutes when changing volume?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the volume multiplier of the audio.
|
||||||
|
pub fn volume(&self) -> f64 {
|
||||||
|
self.read().source.property("volume")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set if the audio is muted or not, without changing the volume.
|
||||||
|
pub fn set_muted(&mut self, muted: bool) {
|
||||||
|
self.get_mut().source.set_property("mute", muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get if the audio is muted or not.
|
||||||
|
pub fn muted(&self) -> bool {
|
||||||
|
self.read().source.property("mute")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get if the stream ended or not.
|
||||||
|
pub fn eos(&self) -> bool {
|
||||||
|
self.read().is_eos
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get if the media will loop or not.
|
||||||
|
pub fn looping(&self) -> bool {
|
||||||
|
self.read().looping
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set if the media will loop or not.
|
||||||
|
pub fn set_looping(&mut self, looping: bool) {
|
||||||
|
self.get_mut().looping = looping;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set if the media is paused or not.
|
||||||
|
pub fn set_paused(&mut self, paused: bool) {
|
||||||
|
self.get_mut().set_paused(paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get if the media is paused or not.
|
||||||
|
pub fn paused(&self) -> bool {
|
||||||
|
self.read().paused()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jumps to a specific position in the media.
|
||||||
|
/// Passing `true` to the `accurate` parameter will result in more accurate seeking,
|
||||||
|
/// however, it is also slower. For most seeks (e.g., scrubbing) this is not needed.
|
||||||
|
pub fn seek(&mut self, position: impl Into<Position>, accurate: bool) -> Result<(), Error> {
|
||||||
|
self.get_mut().seek(position, accurate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the playback speed of the media.
|
||||||
|
/// The default speed is `1.0`.
|
||||||
|
pub fn set_speed(&mut self, speed: f64) -> Result<(), Error> {
|
||||||
|
self.get_mut().set_speed(speed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current playback speed.
|
||||||
|
pub fn speed(&self) -> f64 {
|
||||||
|
self.read().speed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current playback position in time.
|
||||||
|
pub fn position(&self) -> Duration {
|
||||||
|
Duration::from_nanos(
|
||||||
|
self.read()
|
||||||
|
.source
|
||||||
|
.query_position::<gst::ClockTime>()
|
||||||
|
.map_or(0, |pos| pos.nseconds()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the media duration.
|
||||||
|
pub fn duration(&self) -> Duration {
|
||||||
|
self.read().duration
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restarts a stream; seeks to the first frame and unpauses, sets the `eos` flag to false.
|
||||||
|
pub fn restart_stream(&mut self) -> Result<(), Error> {
|
||||||
|
self.get_mut().restart_stream()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the subtitle URL to display.
|
||||||
|
pub fn set_subtitle_url(&mut self, url: &url::Url) -> Result<(), Error> {
|
||||||
|
let paused = self.paused();
|
||||||
|
let mut inner = self.get_mut();
|
||||||
|
inner.source.set_state(gst::State::Ready)?;
|
||||||
|
inner.source.set_property("suburi", url.as_str());
|
||||||
|
inner.set_paused(paused);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current subtitle URL.
|
||||||
|
pub fn subtitle_url(&self) -> Option<url::Url> {
|
||||||
|
url::Url::parse(
|
||||||
|
&self
|
||||||
|
.read()
|
||||||
|
.source
|
||||||
|
.property::<Option<String>>("current-suburi")?,
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the underlying GStreamer pipeline.
|
||||||
|
pub fn pipeline(&self) -> gst::Pipeline {
|
||||||
|
self.read().source.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates a list of thumbnails based on a set of positions in the media, downscaled by a given factor.
|
||||||
|
///
|
||||||
|
/// Slow; only needs to be called once for each instance.
|
||||||
|
/// It's best to call this at the very start of playback, otherwise the position may shift.
|
||||||
|
pub fn thumbnails<I>(
|
||||||
|
&mut self,
|
||||||
|
positions: I,
|
||||||
|
downscale: NonZeroU8,
|
||||||
|
) -> Result<Vec<img::Handle>, Error>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = Position>,
|
||||||
|
{
|
||||||
|
let downscale = u8::from(downscale) as u32;
|
||||||
|
|
||||||
|
let paused = self.paused();
|
||||||
|
let muted = self.muted();
|
||||||
|
let pos = self.position();
|
||||||
|
|
||||||
|
self.set_paused(false);
|
||||||
|
self.set_muted(true);
|
||||||
|
|
||||||
|
let out = {
|
||||||
|
let inner = self.read();
|
||||||
|
let width = inner.width;
|
||||||
|
let height = inner.height;
|
||||||
|
positions
|
||||||
|
.into_iter()
|
||||||
|
.map(|pos| {
|
||||||
|
inner.seek(pos, true)?;
|
||||||
|
inner.upload_frame.store(false, Ordering::SeqCst);
|
||||||
|
while !inner.upload_frame.load(Ordering::SeqCst) {
|
||||||
|
std::hint::spin_loop();
|
||||||
|
}
|
||||||
|
let frame_guard = inner.frame.lock().map_err(|_| Error::Lock)?;
|
||||||
|
let frame = frame_guard.readable().ok_or(Error::Lock)?;
|
||||||
|
|
||||||
|
Ok(img::Handle::from_rgba(
|
||||||
|
inner.width as u32 / downscale,
|
||||||
|
inner.height as u32 / downscale,
|
||||||
|
yuv_to_rgba(frame.as_slice(), width as _, height as _, downscale),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
self.set_paused(paused);
|
||||||
|
self.set_muted(muted);
|
||||||
|
self.seek(pos, true)?;
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yuv_to_rgba(yuv: &[u8], width: u32, height: u32, downscale: u32) -> Vec<u8> {
|
||||||
|
let uv_start = width * height;
|
||||||
|
let mut rgba = vec![];
|
||||||
|
|
||||||
|
for y in 0..height / downscale {
|
||||||
|
for x in 0..width / downscale {
|
||||||
|
let x_src = x * downscale;
|
||||||
|
let y_src = y * downscale;
|
||||||
|
|
||||||
|
let uv_i = uv_start + width * (y_src / 2) + x_src / 2 * 2;
|
||||||
|
|
||||||
|
let y = yuv[(y_src * width + x_src) as usize] as f32;
|
||||||
|
let u = yuv[uv_i as usize] as f32;
|
||||||
|
let v = yuv[(uv_i + 1) as usize] as f32;
|
||||||
|
|
||||||
|
let r = 1.164 * (y - 16.0) + 1.596 * (v - 128.0);
|
||||||
|
let g = 1.164 * (y - 16.0) - 0.813 * (v - 128.0) - 0.391 * (u - 128.0);
|
||||||
|
let b = 1.164 * (y - 16.0) + 2.018 * (u - 128.0);
|
||||||
|
|
||||||
|
rgba.push(r as u8);
|
||||||
|
rgba.push(g as u8);
|
||||||
|
rgba.push(b as u8);
|
||||||
|
rgba.push(0xFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rgba
|
||||||
|
}
|
||||||
305
crates/iced_video_player/src/video_player.rs
Normal file
305
crates/iced_video_player/src/video_player.rs
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
use crate::{pipeline::VideoPrimitive, video::Video};
|
||||||
|
use gstreamer as gst;
|
||||||
|
use iced::{
|
||||||
|
advanced::{self, layout, widget, Widget},
|
||||||
|
Element,
|
||||||
|
};
|
||||||
|
use iced_wgpu::primitive::Renderer as PrimitiveRenderer;
|
||||||
|
use log::error;
|
||||||
|
use std::{marker::PhantomData, sync::atomic::Ordering};
|
||||||
|
use std::{sync::Arc, time::Instant};
|
||||||
|
|
||||||
|
/// Video player widget which displays the current frame of a [`Video`](crate::Video).
|
||||||
|
pub struct VideoPlayer<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer>
|
||||||
|
where
|
||||||
|
Renderer: PrimitiveRenderer,
|
||||||
|
{
|
||||||
|
video: &'a Video,
|
||||||
|
content_fit: iced::ContentFit,
|
||||||
|
width: iced::Length,
|
||||||
|
height: iced::Length,
|
||||||
|
on_end_of_stream: Option<Message>,
|
||||||
|
on_new_frame: Option<Message>,
|
||||||
|
on_subtitle_text: Option<Box<dyn Fn(Option<String>) -> Message + 'a>>,
|
||||||
|
on_error: Option<Box<dyn Fn(&glib::Error) -> Message + 'a>>,
|
||||||
|
_phantom: PhantomData<(Theme, Renderer)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, Message, Theme, Renderer> VideoPlayer<'a, Message, Theme, Renderer>
|
||||||
|
where
|
||||||
|
Renderer: PrimitiveRenderer,
|
||||||
|
{
|
||||||
|
/// Creates a new video player widget for a given video.
|
||||||
|
pub fn new(video: &'a Video) -> Self {
|
||||||
|
VideoPlayer {
|
||||||
|
video,
|
||||||
|
content_fit: iced::ContentFit::default(),
|
||||||
|
width: iced::Length::Shrink,
|
||||||
|
height: iced::Length::Shrink,
|
||||||
|
on_end_of_stream: None,
|
||||||
|
on_new_frame: None,
|
||||||
|
on_subtitle_text: None,
|
||||||
|
on_error: None,
|
||||||
|
_phantom: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the width of the `VideoPlayer` boundaries.
|
||||||
|
pub fn width(self, width: impl Into<iced::Length>) -> Self {
|
||||||
|
VideoPlayer {
|
||||||
|
width: width.into(),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the height of the `VideoPlayer` boundaries.
|
||||||
|
pub fn height(self, height: impl Into<iced::Length>) -> Self {
|
||||||
|
VideoPlayer {
|
||||||
|
height: height.into(),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the `ContentFit` of the `VideoPlayer`.
|
||||||
|
pub fn content_fit(self, content_fit: iced::ContentFit) -> Self {
|
||||||
|
VideoPlayer {
|
||||||
|
content_fit,
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message to send when the video reaches the end of stream (i.e., the video ends).
|
||||||
|
pub fn on_end_of_stream(self, on_end_of_stream: Message) -> Self {
|
||||||
|
VideoPlayer {
|
||||||
|
on_end_of_stream: Some(on_end_of_stream),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message to send when the video receives a new frame.
|
||||||
|
pub fn on_new_frame(self, on_new_frame: Message) -> Self {
|
||||||
|
VideoPlayer {
|
||||||
|
on_new_frame: Some(on_new_frame),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message to send when the video receives a new frame.
|
||||||
|
pub fn on_subtitle_text<F>(self, on_subtitle_text: F) -> Self
|
||||||
|
where
|
||||||
|
F: 'a + Fn(Option<String>) -> Message,
|
||||||
|
{
|
||||||
|
VideoPlayer {
|
||||||
|
on_subtitle_text: Some(Box::new(on_subtitle_text)),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message to send when the video playback encounters an error.
|
||||||
|
pub fn on_error<F>(self, on_error: F) -> Self
|
||||||
|
where
|
||||||
|
F: 'a + Fn(&glib::Error) -> Message,
|
||||||
|
{
|
||||||
|
VideoPlayer {
|
||||||
|
on_error: Some(Box::new(on_error)),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||||
|
for VideoPlayer<'_, Message, Theme, Renderer>
|
||||||
|
where
|
||||||
|
Message: Clone,
|
||||||
|
Renderer: PrimitiveRenderer,
|
||||||
|
{
|
||||||
|
fn size(&self) -> iced::Size<iced::Length> {
|
||||||
|
iced::Size {
|
||||||
|
width: iced::Length::Shrink,
|
||||||
|
height: iced::Length::Shrink,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout(
|
||||||
|
&mut self,
|
||||||
|
_tree: &mut widget::Tree,
|
||||||
|
_renderer: &Renderer,
|
||||||
|
limits: &layout::Limits,
|
||||||
|
) -> layout::Node {
|
||||||
|
let (video_width, video_height) = self.video.size();
|
||||||
|
|
||||||
|
// based on `Image::layout`
|
||||||
|
let image_size = iced::Size::new(video_width as f32, video_height as f32);
|
||||||
|
let raw_size = limits.resolve(self.width, self.height, image_size);
|
||||||
|
let full_size = self.content_fit.fit(image_size, raw_size);
|
||||||
|
let final_size = iced::Size {
|
||||||
|
width: match self.width {
|
||||||
|
iced::Length::Shrink => f32::min(raw_size.width, full_size.width),
|
||||||
|
_ => raw_size.width,
|
||||||
|
},
|
||||||
|
height: match self.height {
|
||||||
|
iced::Length::Shrink => f32::min(raw_size.height, full_size.height),
|
||||||
|
_ => raw_size.height,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
layout::Node::new(final_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(
|
||||||
|
&self,
|
||||||
|
_tree: &widget::Tree,
|
||||||
|
renderer: &mut Renderer,
|
||||||
|
_theme: &Theme,
|
||||||
|
_style: &advanced::renderer::Style,
|
||||||
|
layout: advanced::Layout<'_>,
|
||||||
|
_cursor: advanced::mouse::Cursor,
|
||||||
|
_viewport: &iced::Rectangle,
|
||||||
|
) {
|
||||||
|
let mut inner = self.video.write();
|
||||||
|
|
||||||
|
// bounds based on `Image::draw`
|
||||||
|
let image_size = iced::Size::new(inner.width as f32, inner.height as f32);
|
||||||
|
let bounds = layout.bounds();
|
||||||
|
let adjusted_fit = self.content_fit.fit(image_size, bounds.size());
|
||||||
|
let scale = iced::Vector::new(
|
||||||
|
adjusted_fit.width / image_size.width,
|
||||||
|
adjusted_fit.height / image_size.height,
|
||||||
|
);
|
||||||
|
let final_size = image_size * scale;
|
||||||
|
|
||||||
|
let position = match self.content_fit {
|
||||||
|
iced::ContentFit::None => iced::Point::new(
|
||||||
|
bounds.x + (image_size.width - adjusted_fit.width) / 2.0,
|
||||||
|
bounds.y + (image_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 upload_frame = inner.upload_frame.swap(false, Ordering::SeqCst);
|
||||||
|
|
||||||
|
if upload_frame {
|
||||||
|
let last_frame_time = inner
|
||||||
|
.last_frame_time
|
||||||
|
.lock()
|
||||||
|
.map(|time| *time)
|
||||||
|
.unwrap_or_else(|_| Instant::now());
|
||||||
|
inner.set_av_offset(Instant::now() - last_frame_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
let render = |renderer: &mut Renderer| {
|
||||||
|
renderer.draw_primitive(
|
||||||
|
drawing_bounds,
|
||||||
|
VideoPrimitive::new(
|
||||||
|
inner.id,
|
||||||
|
Arc::clone(&inner.alive),
|
||||||
|
Arc::clone(&inner.frame),
|
||||||
|
(inner.width as _, inner.height as _),
|
||||||
|
upload_frame,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if adjusted_fit.width > bounds.width || adjusted_fit.height > bounds.height {
|
||||||
|
renderer.with_layer(bounds, render);
|
||||||
|
} else {
|
||||||
|
render(renderer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(
|
||||||
|
&mut self,
|
||||||
|
_state: &mut widget::Tree,
|
||||||
|
event: &iced::Event,
|
||||||
|
_layout: advanced::Layout<'_>,
|
||||||
|
_cursor: advanced::mouse::Cursor,
|
||||||
|
_renderer: &Renderer,
|
||||||
|
_clipboard: &mut dyn advanced::Clipboard,
|
||||||
|
shell: &mut advanced::Shell<'_, Message>,
|
||||||
|
_viewport: &iced::Rectangle,
|
||||||
|
) {
|
||||||
|
let mut inner = self.video.write();
|
||||||
|
|
||||||
|
if let iced::Event::Window(iced::window::Event::RedrawRequested(_)) = event {
|
||||||
|
if inner.restart_stream || (!inner.is_eos && !inner.paused()) {
|
||||||
|
let mut restart_stream = false;
|
||||||
|
if inner.restart_stream {
|
||||||
|
restart_stream = true;
|
||||||
|
// Set flag to false to avoid potentially multiple seeks
|
||||||
|
inner.restart_stream = false;
|
||||||
|
}
|
||||||
|
let mut eos_pause = false;
|
||||||
|
|
||||||
|
while let Some(msg) = inner
|
||||||
|
.bus
|
||||||
|
.pop_filtered(&[gst::MessageType::Error, gst::MessageType::Eos])
|
||||||
|
{
|
||||||
|
match msg.view() {
|
||||||
|
gst::MessageView::Error(err) => {
|
||||||
|
error!("bus returned an error: {err}");
|
||||||
|
if let Some(ref on_error) = self.on_error {
|
||||||
|
shell.publish(on_error(&err.error()))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
gst::MessageView::Eos(_eos) => {
|
||||||
|
if let Some(on_end_of_stream) = self.on_end_of_stream.clone() {
|
||||||
|
shell.publish(on_end_of_stream);
|
||||||
|
}
|
||||||
|
if inner.looping {
|
||||||
|
restart_stream = true;
|
||||||
|
} else {
|
||||||
|
eos_pause = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't run eos_pause if restart_stream is true; fixes "pausing" after restarting a stream
|
||||||
|
if restart_stream {
|
||||||
|
if let Err(err) = inner.restart_stream() {
|
||||||
|
error!("cannot restart stream (can't seek): {err:#?}");
|
||||||
|
}
|
||||||
|
} else if eos_pause {
|
||||||
|
inner.is_eos = true;
|
||||||
|
inner.set_paused(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if inner.upload_frame.load(Ordering::SeqCst) {
|
||||||
|
if let Some(on_new_frame) = self.on_new_frame.clone() {
|
||||||
|
shell.publish(on_new_frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(on_subtitle_text) = &self.on_subtitle_text {
|
||||||
|
if inner.upload_text.swap(false, Ordering::SeqCst) {
|
||||||
|
if let Ok(text) = inner.subtitle_text.try_lock() {
|
||||||
|
shell.publish(on_subtitle_text(text.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.request_redraw();
|
||||||
|
} else {
|
||||||
|
shell.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, Message, Theme, Renderer> From<VideoPlayer<'a, Message, Theme, Renderer>>
|
||||||
|
for Element<'a, Message, Theme, Renderer>
|
||||||
|
where
|
||||||
|
Message: 'a + Clone,
|
||||||
|
Theme: 'a,
|
||||||
|
Renderer: 'a + PrimitiveRenderer,
|
||||||
|
{
|
||||||
|
fn from(video_player: VideoPlayer<'a, Message, Theme, Renderer>) -> Self {
|
||||||
|
Self::new(video_player)
|
||||||
|
}
|
||||||
|
}
|
||||||
36
flake.nix
36
flake.nix
@@ -43,7 +43,7 @@
|
|||||||
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
|
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
|
||||||
name = cargoToml.package.name;
|
name = cargoToml.package.name;
|
||||||
|
|
||||||
toolchain = pkgs.rust-bin.nightly.latest.default;
|
toolchain = pkgs.rust-bin.stable.latest.default;
|
||||||
toolchainWithLLvmTools = toolchain.override {
|
toolchainWithLLvmTools = toolchain.override {
|
||||||
extensions = ["rust-src" "llvm-tools"];
|
extensions = ["rust-src" "llvm-tools"];
|
||||||
};
|
};
|
||||||
@@ -74,18 +74,36 @@
|
|||||||
|
|
||||||
buildInputs = with pkgs;
|
buildInputs = with pkgs;
|
||||||
[
|
[
|
||||||
vulkan-loader
|
gst_all_1.gst-editing-services
|
||||||
|
gst_all_1.gst-libav
|
||||||
|
gst_all_1.gst-plugins-bad
|
||||||
|
gst_all_1.gst-plugins-base
|
||||||
|
gst_all_1.gst-plugins-good
|
||||||
|
gst_all_1.gst-plugins-rs
|
||||||
|
gst_all_1.gst-plugins-ugly
|
||||||
|
gst_all_1.gst-rtsp-server
|
||||||
|
gst_all_1.gstreamer
|
||||||
|
|
||||||
openssl
|
openssl
|
||||||
|
vulkan-loader
|
||||||
|
glib
|
||||||
]
|
]
|
||||||
++ (lib.optionals pkgs.stdenv.isLinux [
|
++ (lib.optionals pkgs.stdenv.isLinux [
|
||||||
|
gst_all_1.gstreamermm
|
||||||
|
gst_all_1.gst-vaapi
|
||||||
|
|
||||||
alsa-lib-with-plugins
|
alsa-lib-with-plugins
|
||||||
libxkbcommon
|
libxkbcommon
|
||||||
udev
|
udev
|
||||||
wayland
|
wayland
|
||||||
|
wayland-protocols
|
||||||
|
xorg.libX11
|
||||||
|
xorg.libXi
|
||||||
|
xorg.libXrandr
|
||||||
])
|
])
|
||||||
++ (lib.optionals pkgs.stdenv.isDarwin [
|
++ (lib.optionals pkgs.stdenv.isDarwin [
|
||||||
libiconv
|
libiconv
|
||||||
apple-sdk_13
|
apple-sdk_26
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
cargoArtifacts = craneLib.buildPackage commonArgs;
|
cargoArtifacts = craneLib.buildPackage commonArgs;
|
||||||
@@ -144,11 +162,10 @@
|
|||||||
devShells = {
|
devShells = {
|
||||||
default =
|
default =
|
||||||
pkgs.mkShell.override {
|
pkgs.mkShell.override {
|
||||||
stdenv = pkgs.clangStdenv;
|
stdenv =
|
||||||
# stdenv =
|
if pkgs.stdenv.isLinux
|
||||||
# if pkgs.stdenv.isLinux
|
then (pkgs.stdenvAdapters.useMoldLinker pkgs.clangStdenv)
|
||||||
# then (pkgs.stdenvAdapters.useMoldLinker pkgs.clangStdenv)
|
else pkgs.clangStdenv;
|
||||||
# else pkgs.clangStdenv;
|
|
||||||
} (commonArgs
|
} (commonArgs
|
||||||
// {
|
// {
|
||||||
packages = with pkgs;
|
packages = with pkgs;
|
||||||
@@ -162,9 +179,10 @@
|
|||||||
cargo-hack
|
cargo-hack
|
||||||
cargo-outdated
|
cargo-outdated
|
||||||
lld
|
lld
|
||||||
|
lldb
|
||||||
]
|
]
|
||||||
++ (lib.optionals pkgs.stdenv.isDarwin [
|
++ (lib.optionals pkgs.stdenv.isDarwin [
|
||||||
apple-sdk_13
|
apple-sdk_26
|
||||||
])
|
])
|
||||||
++ (lib.optionals pkgs.stdenv.isLinux [
|
++ (lib.optionals pkgs.stdenv.isLinux [
|
||||||
mold
|
mold
|
||||||
|
|||||||
67
src/main.rs
67
src/main.rs
@@ -1,31 +1,52 @@
|
|||||||
mod errors;
|
mod errors;
|
||||||
use api::{JellyfinClient, JellyfinConfig};
|
use api::JellyfinConfig;
|
||||||
use errors::*;
|
use errors::*;
|
||||||
|
|
||||||
#[tokio::main]
|
fn jellyfin_config_try() -> Result<JellyfinConfig> {
|
||||||
pub async fn main() -> Result<()> {
|
let file = std::fs::read("config.toml").change_context(Error)?;
|
||||||
dotenvy::dotenv()
|
let config: JellyfinConfig = toml::from_slice(&file)
|
||||||
.change_context(Error)
|
.change_context(Error)
|
||||||
.inspect_err(|err| {
|
.attach("Failed to parse Jellyfin Config")?;
|
||||||
eprintln!("Failed to load .env file: {}", err);
|
Ok(config)
|
||||||
})
|
}
|
||||||
.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)?;
|
|
||||||
|
|
||||||
#[cfg(feature = "iced")]
|
fn jellyfin_config() -> JellyfinConfig {
|
||||||
ui_iced::ui(jellyfin);
|
jellyfin_config_try().unwrap_or_else(|err| {
|
||||||
#[cfg(feature = "gpui")]
|
eprintln!("Error loading Jellyfin configuration: {:?}", err);
|
||||||
ui_gpui::ui(jellyfin);
|
std::process::exit(1);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
ui_iced::ui(jellyfin_config).change_context(Error)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #[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)?;
|
||||||
|
//
|
||||||
|
// #[cfg(feature = "iced")]
|
||||||
|
// ui_iced::ui(jellyfin);
|
||||||
|
// #[cfg(feature = "gpui")]
|
||||||
|
// ui_gpui::ui(jellyfin);
|
||||||
|
//
|
||||||
|
// Ok(())
|
||||||
|
// }
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ proc-macro2 = "1.0.103"
|
|||||||
quote = "1.0.41"
|
quote = "1.0.41"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.145"
|
serde_json = "1.0.145"
|
||||||
syn = { version = "2.0.108", features = ["full", "parsing"] }
|
syn = { version = "2.0.108", features = ["extra-traits", "full", "parsing"] }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use indexmap::IndexMap;
|
|||||||
|
|
||||||
const KEYWORDS: &[&str] = &[
|
const KEYWORDS: &[&str] = &[
|
||||||
"type", "match", "enum", "struct", "fn", "mod", "pub", "use", "crate", "self", "super", "as",
|
"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)]
|
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
|
||||||
@@ -15,6 +15,7 @@ pub struct JellyfinOpenapi {
|
|||||||
pub struct Components {
|
pub struct Components {
|
||||||
schemas: indexmap::IndexMap<String, Schema>,
|
schemas: indexmap::IndexMap<String, Schema>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
|
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
|
||||||
pub struct Schema {
|
pub struct Schema {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
@@ -46,12 +47,15 @@ pub struct Property {
|
|||||||
nullable: Option<bool>,
|
nullable: Option<bool>,
|
||||||
format: Option<String>,
|
format: Option<String>,
|
||||||
items: Option<Box<Property>>,
|
items: Option<Box<Property>>,
|
||||||
|
properties: Option<indexmap::IndexMap<String, Property>>,
|
||||||
#[serde(rename = "additionalProperties")]
|
#[serde(rename = "additionalProperties")]
|
||||||
additional_properties: Option<Box<Property>>,
|
additional_properties: Option<Box<Property>>,
|
||||||
#[serde(rename = "enum")]
|
#[serde(rename = "enum")]
|
||||||
_enum: Option<Vec<String>>,
|
_enum: Option<Vec<String>>,
|
||||||
#[serde(rename = "allOf")]
|
#[serde(rename = "allOf")]
|
||||||
all_of: Option<Vec<RefName>>,
|
all_of: Option<Vec<RefName>>,
|
||||||
|
#[serde(rename = "oneOf")]
|
||||||
|
one_of: Option<Vec<RefName>>,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
#[serde(rename = "$ref")]
|
#[serde(rename = "$ref")]
|
||||||
_ref: Option<String>,
|
_ref: Option<String>,
|
||||||
@@ -62,7 +66,7 @@ impl Property {
|
|||||||
let out = match self._type {
|
let out = match self._type {
|
||||||
Some(Types::String) => match self.format.as_deref() {
|
Some(Types::String) => match self.format.as_deref() {
|
||||||
Some("uuid") => "uuid::Uuid".to_string(),
|
Some("uuid") => "uuid::Uuid".to_string(),
|
||||||
Some("date-time") => "jiff::Zoned".to_string(),
|
Some("date-time") => "jiff::Timestamp".to_string(),
|
||||||
_ => "String".to_string(),
|
_ => "String".to_string(),
|
||||||
},
|
},
|
||||||
Some(Types::Integer) => match self.format.as_deref() {
|
Some(Types::Integer) => match self.format.as_deref() {
|
||||||
@@ -89,6 +93,8 @@ impl Property {
|
|||||||
"std::collections::HashMap<String, {}>",
|
"std::collections::HashMap<String, {}>",
|
||||||
properties.to_string()
|
properties.to_string()
|
||||||
)
|
)
|
||||||
|
// } else if let Some(props) = &self.properties {
|
||||||
|
// todo!()
|
||||||
} else {
|
} else {
|
||||||
"std::collections::HashMap<String, serde_json::Value>".to_string()
|
"std::collections::HashMap<String, serde_json::Value>".to_string()
|
||||||
}
|
}
|
||||||
@@ -106,6 +112,8 @@ impl Property {
|
|||||||
};
|
};
|
||||||
if let Some(true) = self.nullable {
|
if let Some(true) = self.nullable {
|
||||||
format!("Option<{}>", out)
|
format!("Option<{}>", out)
|
||||||
|
} else if self.nullable.is_none() && self._type == Some(Types::Object) {
|
||||||
|
format!("Option<{}>", out)
|
||||||
} else {
|
} else {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -115,7 +123,7 @@ impl Property {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
|
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum Types {
|
pub enum Types {
|
||||||
Object,
|
Object,
|
||||||
@@ -144,73 +152,10 @@ fn main() {
|
|||||||
.map(|(k, v)| (k.clone(), v.clone()))
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let syn_structs: Vec<syn::ItemStruct> = structs
|
let syn_structs: Vec<syn::Item> = structs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(key, value)| {
|
.map(|(key, value)| generate_struct(key, value))
|
||||||
let fields = value
|
.flatten()
|
||||||
.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_desc = _type.description();
|
|
||||||
let _type_desc = if let Some(desc) = &_type_desc {
|
|
||||||
Some(format!(" {}", desc))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
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: 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,
|
|
||||||
)),
|
|
||||||
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),*
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
syn::parse2(tokens).expect("Failed to parse struct")
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let syn_enums = enums
|
let syn_enums = enums
|
||||||
@@ -239,7 +184,6 @@ fn main() {
|
|||||||
quote::quote! {
|
quote::quote! {
|
||||||
#[doc = #desc]
|
#[doc = #desc]
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
|
||||||
pub enum #key {
|
pub enum #key {
|
||||||
#(#variants),*
|
#(#variants),*
|
||||||
}
|
}
|
||||||
@@ -247,7 +191,6 @@ fn main() {
|
|||||||
} else {
|
} else {
|
||||||
quote::quote! {
|
quote::quote! {
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
|
||||||
pub enum #key {
|
pub enum #key {
|
||||||
#(#variants),*
|
#(#variants),*
|
||||||
}
|
}
|
||||||
@@ -262,7 +205,6 @@ fn main() {
|
|||||||
attrs: vec![],
|
attrs: vec![],
|
||||||
items: syn_structs
|
items: syn_structs
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(syn::Item::Struct)
|
|
||||||
.chain(syn_enums.into_iter().map(syn::Item::Enum))
|
.chain(syn_enums.into_iter().map(syn::Item::Enum))
|
||||||
.collect(),
|
.collect(),
|
||||||
};
|
};
|
||||||
@@ -277,3 +219,104 @@ fn modify_keyword(name: &str) -> String {
|
|||||||
name.to_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)),
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
gpui = { version = "0.2.2", default-features = false, features = ["wayland"] }
|
gpui = { version = "0.2.2", default-features = false, features = ["wayland"] }
|
||||||
tap = "1.0.1"
|
tap = "1.0.1"
|
||||||
|
blurhash = "0.2.3"
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
api = { version = "0.1.0", path = "../api" }
|
||||||
|
blurhash = "0.2.3"
|
||||||
|
bytes = "1.11.0"
|
||||||
gpui_util = "0.2.2"
|
gpui_util = "0.2.2"
|
||||||
iced = { version = "0.13.1", features = ["canvas", "image", "tokio"] }
|
iced = { workspace = true }
|
||||||
|
iced_video_player = { workspace = true }
|
||||||
|
reqwest = "0.12.24"
|
||||||
|
tap = "1.0.1"
|
||||||
|
tracing = "0.1.41"
|
||||||
|
url = "2.5.7"
|
||||||
uuid = "1.18.1"
|
uuid = "1.18.1"
|
||||||
|
|||||||
130
ui-iced/src/blur_hash.rs
Normal file
130
ui-iced/src/blur_hash.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +1,684 @@
|
|||||||
use std::{collections::BTreeMap, sync::Arc};
|
mod shared_string;
|
||||||
type SharedString = Arc<str>;
|
use iced_video_player::{Video, VideoPlayer};
|
||||||
|
use shared_string::SharedString;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use iced::{Element, Task};
|
mod blur_hash;
|
||||||
|
use blur_hash::BlurHash;
|
||||||
|
|
||||||
struct State {
|
mod preview;
|
||||||
loading: Option<Loading>,
|
use preview::Preview;
|
||||||
current: Screen,
|
|
||||||
cache: ItemCache,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
use iced::{Alignment, Element, Length, Shadow, Task, widget::*};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Loading {}
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Clone)]
|
||||||
pub struct ItemCache {
|
pub struct ItemCache {
|
||||||
pub items: BTreeMap<uuid::Uuid, Item>,
|
pub items: BTreeMap<uuid::Uuid, Item>,
|
||||||
|
pub tree: BTreeMap<Option<uuid::Uuid>, BTreeSet<uuid::Uuid>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ItemCache {
|
||||||
|
pub fn insert(&mut self, parent: impl Into<Option<uuid::Uuid>>, item: Item) {
|
||||||
|
let parent = parent.into();
|
||||||
|
self.tree.entry(parent).or_default().insert(item.id);
|
||||||
|
self.items.insert(item.id, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extend<I: IntoIterator<Item = Item>>(
|
||||||
|
&mut self,
|
||||||
|
parent: impl Into<Option<uuid::Uuid>>,
|
||||||
|
items: I,
|
||||||
|
) {
|
||||||
|
let parent = parent.into();
|
||||||
|
items.into_iter().for_each(|item| {
|
||||||
|
self.insert(parent, item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn items_of(&self, parent: impl Into<Option<uuid::Uuid>>) -> Vec<&Item> {
|
||||||
|
let parent = parent.into();
|
||||||
|
self.tree.get(&None);
|
||||||
|
self.tree
|
||||||
|
.get(&parent)
|
||||||
|
.map(|ids| {
|
||||||
|
ids.iter()
|
||||||
|
.filter_map(|id| self.items.get(id))
|
||||||
|
.collect::<Vec<&Item>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: &uuid::Uuid) -> Option<&Item> {
|
||||||
|
self.items.get(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Thumbnail {
|
||||||
|
pub id: SharedString,
|
||||||
|
pub blur_hash: Option<SharedString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<api::jellyfin::BaseItemDto> for Item {
|
||||||
|
fn from(dto: api::jellyfin::BaseItemDto) -> Self {
|
||||||
|
Item {
|
||||||
|
id: dto.id,
|
||||||
|
name: dto.name.map(Into::into),
|
||||||
|
parent_id: dto.parent_id,
|
||||||
|
thumbnail: dto
|
||||||
|
.image_tags
|
||||||
|
.and_then(|tags| tags.get("Primary").cloned())
|
||||||
|
.map(|tag| Thumbnail {
|
||||||
|
id: tag.clone().into(),
|
||||||
|
blur_hash: dto
|
||||||
|
.image_blur_hashes
|
||||||
|
.primary
|
||||||
|
.and_then(|hashes| hashes.get(&tag).cloned())
|
||||||
|
.map(|s| s.clone().into()),
|
||||||
|
}),
|
||||||
|
_type: dto._type,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Item {
|
pub struct Item {
|
||||||
pub id: SharedString,
|
pub id: uuid::Uuid,
|
||||||
pub name: SharedString,
|
pub parent_id: Option<uuid::Uuid>,
|
||||||
pub item_type: SharedString,
|
pub name: Option<SharedString>,
|
||||||
pub media_type: SharedString,
|
pub thumbnail: Option<Thumbnail>,
|
||||||
|
pub _type: api::jellyfin::BaseItemKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
pub enum Screen {
|
pub enum Screen {
|
||||||
|
#[default]
|
||||||
Home,
|
Home,
|
||||||
Settings,
|
Settings,
|
||||||
Profile,
|
User,
|
||||||
|
Video,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct State {
|
||||||
|
loading: Option<Loading>,
|
||||||
|
current: Option<uuid::Uuid>,
|
||||||
|
cache: ItemCache,
|
||||||
|
jellyfin_client: api::JellyfinClient,
|
||||||
|
messages: Vec<String>,
|
||||||
|
history: Vec<Option<uuid::Uuid>>,
|
||||||
|
query: Option<String>,
|
||||||
|
screen: Screen,
|
||||||
|
// Login form state
|
||||||
|
username_input: String,
|
||||||
|
password_input: String,
|
||||||
|
is_authenticated: bool,
|
||||||
|
// Video
|
||||||
|
video: Option<Arc<Video>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Loading {
|
impl State {
|
||||||
to: Screen,
|
pub fn new(jellyfin_client: api::JellyfinClient) -> Self {
|
||||||
from: Screen,
|
State {
|
||||||
|
loading: None,
|
||||||
|
current: None,
|
||||||
|
cache: ItemCache::default(),
|
||||||
|
jellyfin_client,
|
||||||
|
messages: Vec::new(),
|
||||||
|
history: Vec::new(),
|
||||||
|
query: None,
|
||||||
|
screen: Screen::Home,
|
||||||
|
username_input: String::new(),
|
||||||
|
password_input: String::new(),
|
||||||
|
is_authenticated: false,
|
||||||
|
video: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Message {
|
||||||
|
OpenSettings,
|
||||||
|
CloseSettings,
|
||||||
|
Refresh,
|
||||||
|
Search,
|
||||||
|
SearchQueryChanged(String),
|
||||||
|
OpenItem(Option<uuid::Uuid>),
|
||||||
|
LoadedItem(Option<uuid::Uuid>, Vec<Item>),
|
||||||
|
Error(String),
|
||||||
|
SetToken(String),
|
||||||
|
Back,
|
||||||
|
Home,
|
||||||
|
// Login-related messages
|
||||||
|
UsernameChanged(String),
|
||||||
|
PasswordChanged(String),
|
||||||
|
Login,
|
||||||
|
LoginSuccess(String),
|
||||||
|
Logout,
|
||||||
|
Video(VideoMessage),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum VideoMessage {
|
||||||
|
EndOfStream,
|
||||||
|
Open(url::Url),
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Seek(f64),
|
||||||
|
Stop,
|
||||||
|
Test,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||||
|
match message {
|
||||||
|
Message::OpenSettings => {
|
||||||
|
state.screen = Screen::Settings;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::CloseSettings => {
|
||||||
|
state.screen = Screen::Home;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::UsernameChanged(username) => {
|
||||||
|
state.username_input = username;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::PasswordChanged(password) => {
|
||||||
|
state.password_input = password;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::Login => {
|
||||||
|
let username = state.username_input.clone();
|
||||||
|
let password = state.password_input.clone();
|
||||||
|
|
||||||
|
// Update the client config with the new credentials
|
||||||
|
let mut config = (*state.jellyfin_client.config).clone();
|
||||||
|
config.username = username;
|
||||||
|
config.password = password;
|
||||||
|
|
||||||
|
Task::perform(
|
||||||
|
async move {
|
||||||
|
let mut client = api::JellyfinClient::new(config);
|
||||||
|
client.authenticate().await
|
||||||
|
},
|
||||||
|
|result| match result {
|
||||||
|
Ok(auth_result) => {
|
||||||
|
if let Some(token) = auth_result.access_token {
|
||||||
|
Message::LoginSuccess(token)
|
||||||
|
} else {
|
||||||
|
Message::Error("Authentication failed: No token received".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Message::Error(format!("Login failed: {}", e)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Message::LoginSuccess(token) => {
|
||||||
|
state.jellyfin_client.set_token(token.clone());
|
||||||
|
state.is_authenticated = true;
|
||||||
|
state.password_input.clear();
|
||||||
|
state.messages.push("Login successful!".to_string());
|
||||||
|
state.screen = Screen::Home;
|
||||||
|
|
||||||
|
// Save token and refresh items
|
||||||
|
let client = state.jellyfin_client.clone();
|
||||||
|
Task::perform(
|
||||||
|
async move {
|
||||||
|
let _ = client.save_token(".session").await;
|
||||||
|
},
|
||||||
|
|_| Message::Refresh,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Message::Logout => {
|
||||||
|
state.is_authenticated = false;
|
||||||
|
state.jellyfin_client.set_token("");
|
||||||
|
state.cache = ItemCache::default();
|
||||||
|
state.current = None;
|
||||||
|
state.username_input.clear();
|
||||||
|
state.password_input.clear();
|
||||||
|
state.messages.push("Logged out successfully".to_string());
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::OpenItem(id) => {
|
||||||
|
let 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(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),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::LoadedItem(id, items) => {
|
||||||
|
state.cache.extend(id, items);
|
||||||
|
state.history.push(state.current);
|
||||||
|
state.current = id;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::Refresh => {
|
||||||
|
// Handle refresh logic
|
||||||
|
let 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),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Message::Error(err) => {
|
||||||
|
tracing::error!("Error: {}", err);
|
||||||
|
state.messages.push(err);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::SetToken(token) => {
|
||||||
|
tracing::info!("Authenticated with token: {}", token);
|
||||||
|
state.jellyfin_client.set_token(token);
|
||||||
|
state.is_authenticated = true;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::Back => {
|
||||||
|
state.current = state.history.pop().unwrap_or(None);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::Home => {
|
||||||
|
state.current = None;
|
||||||
|
Task::done(Message::Refresh)
|
||||||
|
}
|
||||||
|
Message::SearchQueryChanged(query) => {
|
||||||
|
state.query = Some(query);
|
||||||
|
// Handle search query change
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::Search => {
|
||||||
|
// Handle search action
|
||||||
|
let client = state.jellyfin_client.clone();
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Message::Video(msg) => match msg {
|
||||||
|
VideoMessage::EndOfStream => {
|
||||||
|
state.video = None;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
VideoMessage::Open(url) => {
|
||||||
|
state.video = Video::new(&url)
|
||||||
|
.inspect_err(|err| {
|
||||||
|
tracing::error!("Failed to play video at {}: {:?}", url, err);
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
.map(Arc::new);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
VideoMessage::Pause => {
|
||||||
|
if let Some(video) = state.video.as_mut().and_then(Arc::get_mut) {
|
||||||
|
video.set_paused(true);
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
VideoMessage::Play => {
|
||||||
|
if let Some(video) = state.video.as_mut().and_then(Arc::get_mut) {
|
||||||
|
video.set_paused(false);
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
VideoMessage::Seek(position) => {
|
||||||
|
// if let Some(ref video) = state.video {
|
||||||
|
// // video.seek(position, true);
|
||||||
|
// }
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
VideoMessage::Stop => {
|
||||||
|
state.video = None;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
VideoMessage::Test => {
|
||||||
|
let url = url::Url::parse(
|
||||||
|
// "file:///home/servius/Projects/jello/crates/iced_video_player/.media/test.mp4",
|
||||||
|
"https://gstreamer.freedesktop.org/data/media/sintel_trailer-480p.webm",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
state.video = Video::new(&url)
|
||||||
|
.inspect_err(|err| {
|
||||||
|
dbg!(err);
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
.map(Arc::new);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(state: &State) -> Element<'_, Message> {
|
||||||
|
match state.screen {
|
||||||
|
Screen::Settings => settings(state),
|
||||||
|
Screen::Home | _ => home(state),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn home(state: &State) -> Element<'_, Message> {
|
||||||
|
column([header(state), body(state), footer(state)])
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn player(video: &Video) -> Element<'_, Message> {
|
||||||
|
container(
|
||||||
|
VideoPlayer::new(video)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.content_fit(iced::ContentFit::Contain)
|
||||||
|
.on_end_of_stream(Message::Video(VideoMessage::EndOfStream)),
|
||||||
|
)
|
||||||
|
.style(|_| container::background(iced::Color::BLACK))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn body(state: &State) -> Element<'_, Message> {
|
||||||
|
if let Some(ref video) = state.video {
|
||||||
|
player(video)
|
||||||
|
} else {
|
||||||
|
scrollable(
|
||||||
|
container(
|
||||||
|
Grid::with_children(state.cache.items_of(state.current).into_iter().map(card))
|
||||||
|
.fluid(400)
|
||||||
|
.spacing(50),
|
||||||
|
)
|
||||||
|
.padding(50)
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
// .align_y(Alignment::Center)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.width(Length::Fill),
|
||||||
|
)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header(state: &State) -> Element<'_, Message> {
|
||||||
|
row([
|
||||||
|
container(
|
||||||
|
Button::new(
|
||||||
|
Text::new(state.jellyfin_client.config.server_url.as_str())
|
||||||
|
.align_x(Alignment::Start),
|
||||||
|
)
|
||||||
|
.on_press(Message::Home),
|
||||||
|
)
|
||||||
|
.padding(10)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.align_x(Alignment::Start)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.style(container::rounded_box)
|
||||||
|
.into(),
|
||||||
|
search(state),
|
||||||
|
container(
|
||||||
|
row([
|
||||||
|
button("Refresh").on_press(Message::Refresh).into(),
|
||||||
|
button("Settings").on_press(Message::OpenSettings).into(),
|
||||||
|
button("TestVideo")
|
||||||
|
.on_press(Message::Video(VideoMessage::Test))
|
||||||
|
.into(),
|
||||||
|
])
|
||||||
|
.spacing(10),
|
||||||
|
)
|
||||||
|
.padding(10)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.align_x(Alignment::End)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.style(container::rounded_box)
|
||||||
|
.into(),
|
||||||
|
])
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(50)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search(state: &State) -> Element<'_, Message> {
|
||||||
|
container(
|
||||||
|
TextInput::new("Search...", state.query.as_deref().unwrap_or_default())
|
||||||
|
.padding(10)
|
||||||
|
.size(16)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.on_input(Message::SearchQueryChanged)
|
||||||
|
.on_submit(Message::Search),
|
||||||
|
)
|
||||||
|
.padding(10)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Shrink)
|
||||||
|
.style(container::rounded_box)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn footer(state: &State) -> Element<'_, Message> {
|
||||||
|
container(
|
||||||
|
column(
|
||||||
|
state
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.map(|msg| Text::new(msg).size(12).into())
|
||||||
|
.collect::<Vec<Element<'_, Message>>>(),
|
||||||
|
)
|
||||||
|
.spacing(5),
|
||||||
|
)
|
||||||
|
.padding(10)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Shrink)
|
||||||
|
.style(container::rounded_box)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings(state: &State) -> Element<'_, Message> {
|
||||||
|
let content = if state.is_authenticated {
|
||||||
|
// Authenticated view - show user info and logout
|
||||||
|
column([
|
||||||
|
Text::new("Settings").size(32).into(),
|
||||||
|
container(
|
||||||
|
column([
|
||||||
|
Text::new("Account").size(24).into(),
|
||||||
|
Text::new("Server URL").size(14).into(),
|
||||||
|
Text::new(state.jellyfin_client.config.server_url.as_str())
|
||||||
|
.size(12)
|
||||||
|
.into(),
|
||||||
|
container(Text::new("Status: Logged In").size(14))
|
||||||
|
.padding(10)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into(),
|
||||||
|
container(
|
||||||
|
row([
|
||||||
|
Button::new(Text::new("Logout"))
|
||||||
|
.padding(10)
|
||||||
|
.on_press(Message::Logout)
|
||||||
|
.into(),
|
||||||
|
Button::new(Text::new("Close"))
|
||||||
|
.padding(10)
|
||||||
|
.on_press(Message::CloseSettings)
|
||||||
|
.into(),
|
||||||
|
])
|
||||||
|
.spacing(10),
|
||||||
|
)
|
||||||
|
.padding(10)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into(),
|
||||||
|
])
|
||||||
|
.spacing(10)
|
||||||
|
.max_width(400)
|
||||||
|
.align_x(Alignment::Center),
|
||||||
|
)
|
||||||
|
.padding(20)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.style(container::rounded_box)
|
||||||
|
.into(),
|
||||||
|
])
|
||||||
|
.spacing(20)
|
||||||
|
.padding(50)
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
} else {
|
||||||
|
// Not authenticated view - show login form
|
||||||
|
column([
|
||||||
|
Text::new("Settings").size(32).into(),
|
||||||
|
container(
|
||||||
|
column([
|
||||||
|
Text::new("Login to Jellyfin").size(24).into(),
|
||||||
|
Text::new("Server URL").size(14).into(),
|
||||||
|
Text::new(state.jellyfin_client.config.server_url.as_str())
|
||||||
|
.size(12)
|
||||||
|
.into(),
|
||||||
|
container(
|
||||||
|
TextInput::new("Username", &state.username_input)
|
||||||
|
.padding(10)
|
||||||
|
.size(16)
|
||||||
|
.on_input(Message::UsernameChanged),
|
||||||
|
)
|
||||||
|
.padding(10)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into(),
|
||||||
|
container(
|
||||||
|
TextInput::new("Password", &state.password_input)
|
||||||
|
.padding(10)
|
||||||
|
.size(16)
|
||||||
|
.secure(true)
|
||||||
|
.on_input(Message::PasswordChanged)
|
||||||
|
.on_submit(Message::Login),
|
||||||
|
)
|
||||||
|
.padding(10)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into(),
|
||||||
|
container(
|
||||||
|
row([
|
||||||
|
Button::new(Text::new("Login"))
|
||||||
|
.padding(10)
|
||||||
|
.on_press(Message::Login)
|
||||||
|
.into(),
|
||||||
|
Button::new(Text::new("Cancel"))
|
||||||
|
.padding(10)
|
||||||
|
.on_press(Message::CloseSettings)
|
||||||
|
.into(),
|
||||||
|
])
|
||||||
|
.spacing(10),
|
||||||
|
)
|
||||||
|
.padding(10)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into(),
|
||||||
|
])
|
||||||
|
.spacing(10)
|
||||||
|
.max_width(400)
|
||||||
|
.align_x(Alignment::Center),
|
||||||
|
)
|
||||||
|
.padding(20)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.style(container::rounded_box)
|
||||||
|
.into(),
|
||||||
|
])
|
||||||
|
.spacing(20)
|
||||||
|
.padding(50)
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
};
|
||||||
|
|
||||||
|
container(content)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.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 video(url: &str
|
||||||
|
|
||||||
|
fn init(config: impl Fn() -> api::JellyfinConfig + 'static) -> impl Fn() -> (State, Task<Message>) {
|
||||||
|
move || {
|
||||||
|
let mut jellyfin = api::JellyfinClient::new(config());
|
||||||
|
(
|
||||||
|
State::new(jellyfin.clone()),
|
||||||
|
Task::perform(
|
||||||
|
async move { jellyfin.authenticate_with_cached_token(".session").await },
|
||||||
|
|token| match token {
|
||||||
|
Ok(token) => Message::SetToken(token),
|
||||||
|
Err(e) => Message::Error(format!("Authentication failed: {}", e)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.chain(Task::done(Message::Refresh)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ui(config: impl Fn() -> api::JellyfinConfig + 'static) -> iced::Result {
|
||||||
|
iced::application(init(config), update, view).run()
|
||||||
}
|
}
|
||||||
|
|||||||
224
ui-iced/src/preview.rs
Normal file
224
ui-iced/src/preview.rs
Normal 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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
68
ui-iced/src/shared_string.rs
Normal file
68
ui-iced/src/shared_string.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
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(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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user