Compare commits
7 Commits
a1c36e4fb2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77fe7b6bb4 | ||
|
|
0fb5cb1d99 | ||
|
|
61a2ea1733 | ||
|
|
b1cfc19b96 | ||
|
|
f41625e0ed | ||
|
|
29674df85e | ||
|
|
442a7e49b2 |
356
Cargo.lock
generated
356
Cargo.lock
generated
@@ -172,22 +172,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.4"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.10"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -200,6 +200,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
name = "api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"iref",
|
||||
"jiff",
|
||||
"reqwest",
|
||||
@@ -211,6 +212,7 @@ dependencies = [
|
||||
"tokio-test",
|
||||
"toml 0.9.8",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -592,6 +594,12 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "atomic_refcell"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41e67cd8309bbd06cd603a9e693a784ac2e5d1e955f11286e355089fcab3047c"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
@@ -647,6 +655,15 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.71.1"
|
||||
@@ -908,9 +925,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.10.1"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
||||
|
||||
[[package]]
|
||||
name = "calloop"
|
||||
@@ -992,9 +1009,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.45"
|
||||
version = "1.2.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe"
|
||||
checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -1026,6 +1043,16 @@ dependencies = [
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-expr"
|
||||
version = "0.20.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9acd0bdbbf4b2612d09f52ba61da432140cb10930354079d0d53fafc12968726"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -1098,9 +1125,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.51"
|
||||
version = "4.5.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5"
|
||||
checksum = "aa8120877db0e5c011242f96806ce3c94e0737ab8108532a76a3300a01db2ab8"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -1108,9 +1135,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.51"
|
||||
version = "4.5.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a"
|
||||
checksum = "02576b399397b659c26064fbc92a75fede9d18ffd5f80ca1cd74ddab167016e1"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@@ -2073,9 +2100,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127"
|
||||
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
@@ -2428,6 +2455,19 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio-sys"
|
||||
version = "0.20.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl_generator"
|
||||
version = "0.14.0"
|
||||
@@ -2445,6 +2485,50 @@ version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3"
|
||||
|
||||
[[package]]
|
||||
name = "glib"
|
||||
version = "0.20.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
"gio-sys",
|
||||
"glib-macros",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"memchr",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-macros"
|
||||
version = "0.20.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-sys"
|
||||
version = "0.20.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
@@ -2497,6 +2581,17 @@ dependencies = [
|
||||
"gl_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.20.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu-alloc"
|
||||
version = "0.6.0"
|
||||
@@ -2819,6 +2914,98 @@ version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12101ecc8225ea6d675bc70263074eab6169079621c2186fe0c66590b2df9681"
|
||||
|
||||
[[package]]
|
||||
name = "gstreamer"
|
||||
version = "0.23.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8757a87f3706560037a01a9f06a59fcc7bdb0864744dcf73546606e60c4316e1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"glib",
|
||||
"gstreamer-sys",
|
||||
"itertools 0.14.0",
|
||||
"libc",
|
||||
"muldiv",
|
||||
"num-integer",
|
||||
"num-rational",
|
||||
"once_cell",
|
||||
"option-operations",
|
||||
"paste",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gstreamer-app"
|
||||
version = "0.23.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e9a883eb21aebcf1289158225c05f7aea5da6ecf71fa7f0ff1ce4d25baf004e"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"glib",
|
||||
"gstreamer",
|
||||
"gstreamer-app-sys",
|
||||
"gstreamer-base",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gstreamer-app-sys"
|
||||
version = "0.23.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94f7ef838306fe51852d503a14dc79ac42de005a59008a05098de3ecdaf05455"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"gstreamer-base-sys",
|
||||
"gstreamer-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gstreamer-base"
|
||||
version = "0.23.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f19a74fd04ffdcb847dd322640f2cf520897129d00a7bcb92fd62a63f3e27404"
|
||||
dependencies = [
|
||||
"atomic_refcell",
|
||||
"cfg-if",
|
||||
"glib",
|
||||
"gstreamer",
|
||||
"gstreamer-base-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gstreamer-base-sys"
|
||||
version = "0.23.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87f2fb0037b6d3c5b51f60dea11e667910f33be222308ca5a101450018a09840"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gstreamer-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gstreamer-sys"
|
||||
version = "0.23.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "feea73b4d92dbf9c24a203c9cd0bcc740d584f6b5960d5faf359febf288919b2"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "guillotiere"
|
||||
version = "0.6.2"
|
||||
@@ -3007,9 +3194,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.0"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1744436df46f0bde35af3eda22aeaba453aada65d8f1c171cd8a5f59030bd69f"
|
||||
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -3062,9 +3249,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8"
|
||||
checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
@@ -3083,7 +3270,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry 0.5.3",
|
||||
"windows-registry 0.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3093,6 +3280,7 @@ source = "git+https://github.com/iced-rs/iced#645643bfd63ed4c01aa281f97992e3c276
|
||||
dependencies = [
|
||||
"iced_core",
|
||||
"iced_debug",
|
||||
"iced_devtools",
|
||||
"iced_futures",
|
||||
"iced_renderer",
|
||||
"iced_runtime",
|
||||
@@ -3102,6 +3290,21 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iced_beacon"
|
||||
version = "0.14.0-dev"
|
||||
source = "git+https://github.com/iced-rs/iced#645643bfd63ed4c01aa281f97992e3c276e71498"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"futures",
|
||||
"iced_core",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iced_core"
|
||||
version = "0.14.0-dev"
|
||||
@@ -3114,6 +3317,7 @@ dependencies = [
|
||||
"log",
|
||||
"num-traits",
|
||||
"rustc-hash 2.1.1",
|
||||
"serde",
|
||||
"smol_str",
|
||||
"thiserror 2.0.17",
|
||||
"web-time",
|
||||
@@ -3124,11 +3328,23 @@ name = "iced_debug"
|
||||
version = "0.14.0-dev"
|
||||
source = "git+https://github.com/iced-rs/iced#645643bfd63ed4c01aa281f97992e3c276e71498"
|
||||
dependencies = [
|
||||
"iced_beacon",
|
||||
"iced_core",
|
||||
"iced_futures",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iced_devtools"
|
||||
version = "0.14.0-dev"
|
||||
source = "git+https://github.com/iced-rs/iced#645643bfd63ed4c01aa281f97992e3c276e71498"
|
||||
dependencies = [
|
||||
"iced_debug",
|
||||
"iced_program",
|
||||
"iced_widget",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iced_futures"
|
||||
version = "0.14.0-dev"
|
||||
@@ -3194,6 +3410,7 @@ dependencies = [
|
||||
"iced_core",
|
||||
"iced_futures",
|
||||
"raw-window-handle",
|
||||
"sipper",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
@@ -3213,6 +3430,21 @@ dependencies = [
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iced_video_player"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"glib",
|
||||
"gstreamer",
|
||||
"gstreamer-app",
|
||||
"gstreamer-base",
|
||||
"iced",
|
||||
"iced_wgpu",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iced_wgpu"
|
||||
version = "0.14.0-dev"
|
||||
@@ -3908,9 +4140,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lyon_geom"
|
||||
version = "1.0.17"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e16770d760c7848b0c1c2d209101e408207a65168109509f8483837a36cf2e7"
|
||||
checksum = "e260b6de923e6e47adfedf6243013a7a874684165a6a277594ee3906021b2343"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"euclid",
|
||||
@@ -4095,6 +4327,12 @@ dependencies = [
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muldiv"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "956787520e75e9bd233246045d19f42fb73242759cc57fba9611d940ae96d4b0"
|
||||
|
||||
[[package]]
|
||||
name = "mundy"
|
||||
version = "0.2.1"
|
||||
@@ -4875,9 +5113,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.2"
|
||||
version = "5.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95"
|
||||
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
@@ -4934,6 +5172,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "option-operations"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c26d27bb1aeab65138e4bf7666045169d1717febcc9ff870166be8348b223d0"
|
||||
dependencies = [
|
||||
"paste",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.49"
|
||||
@@ -5499,9 +5746,9 @@ checksum = "d20581732dd76fa913c7dff1a2412b714afe3573e94d41c34719de73337cc8ab"
|
||||
|
||||
[[package]]
|
||||
name = "rangemap"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f93e7e49bb0bf967717f7bd674458b3d6b0c5f48ec7e3038166026a69fc22223"
|
||||
checksum = "acbbbbea733ec66275512d0b9694f34102e7d5406fdbe2ad8d21b28dce92887c"
|
||||
|
||||
[[package]]
|
||||
name = "rav1e"
|
||||
@@ -6085,6 +6332,10 @@ name = "semver"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
@@ -6275,6 +6526,16 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
|
||||
|
||||
[[package]]
|
||||
name = "sipper"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bccb4192828b3d9a08e0b5a73f17795080dfb278b50190216e3ae2132cf4f95"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.37.0"
|
||||
@@ -6762,6 +7023,19 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "7.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f"
|
||||
dependencies = [
|
||||
"cfg-expr",
|
||||
"heck 0.5.0",
|
||||
"pkg-config",
|
||||
"toml 0.9.8",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "taffy"
|
||||
version = "0.9.0"
|
||||
@@ -6786,6 +7060,12 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.23.0"
|
||||
@@ -7319,9 +7599,14 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"api",
|
||||
"blurhash",
|
||||
"bytes",
|
||||
"gpui_util",
|
||||
"iced",
|
||||
"iced_video_player",
|
||||
"reqwest",
|
||||
"tap",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -7544,6 +7829,12 @@ version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -8301,6 +8592,17 @@ dependencies = [
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
|
||||
20
Cargo.toml
20
Cargo.toml
@@ -1,5 +1,23 @@
|
||||
[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]
|
||||
name = "jello"
|
||||
|
||||
@@ -4,6 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
bytes = "1.11.0"
|
||||
iref = { version = "3.2.2", features = ["serde"] }
|
||||
jiff = { version = "0.2.16", features = ["serde"] }
|
||||
reqwest = { version = "0.12.24", features = ["json"] }
|
||||
@@ -14,6 +15,7 @@ thiserror = "2.0.17"
|
||||
tokio = { version = "1.48.0", features = ["fs"] }
|
||||
toml = "0.9.8"
|
||||
tracing = "0.1.41"
|
||||
url = "2.5.7"
|
||||
uuid = { version = "1.18.1", features = ["serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -17,8 +17,16 @@ pub async fn main() {
|
||||
for item in items {
|
||||
println!("{}: {:?}", item.id, item.name);
|
||||
let items = jellyfin.items(item.id).await.expect("Items");
|
||||
for item in items {
|
||||
println!(" {}: {:?}", item.id, item.name);
|
||||
}
|
||||
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"),
|
||||
// );
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6013,7 +6013,6 @@ pub struct XbmcMetadataOptions {
|
||||
pub enable_extra_thumbs_duplication: bool,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ApiName {
|
||||
#[serde(rename = "Mal")]
|
||||
Mal,
|
||||
@@ -6030,7 +6029,6 @@ pub enum ApiName {
|
||||
}
|
||||
/// An enum representing formats of spatial audio.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum AudioSpatialFormat {
|
||||
#[serde(rename = "None")]
|
||||
None,
|
||||
@@ -6041,7 +6039,6 @@ pub enum AudioSpatialFormat {
|
||||
}
|
||||
/// The base item kind.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum BaseItemKind {
|
||||
#[serde(rename = "AggregateFolder")]
|
||||
AggregateFolder,
|
||||
@@ -6119,7 +6116,6 @@ pub enum BaseItemKind {
|
||||
Year,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ChannelItemSortField {
|
||||
#[serde(rename = "Name")]
|
||||
Name,
|
||||
@@ -6137,7 +6133,6 @@ pub enum ChannelItemSortField {
|
||||
CommunityPlayCount,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ChannelMediaContentType {
|
||||
#[serde(rename = "Clip")]
|
||||
Clip,
|
||||
@@ -6157,7 +6152,6 @@ pub enum ChannelMediaContentType {
|
||||
TvExtra,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ChannelMediaType {
|
||||
#[serde(rename = "Audio")]
|
||||
Audio,
|
||||
@@ -6168,7 +6162,6 @@ pub enum ChannelMediaType {
|
||||
}
|
||||
/// Enum ChannelType.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ChannelType {
|
||||
#[serde(rename = "TV")]
|
||||
Tv,
|
||||
@@ -6176,7 +6169,6 @@ pub enum ChannelType {
|
||||
Radio,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum CodecType {
|
||||
#[serde(rename = "Video")]
|
||||
Video,
|
||||
@@ -6187,7 +6179,6 @@ pub enum CodecType {
|
||||
}
|
||||
/// Collection type.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum CollectionType {
|
||||
#[serde(rename = "unknown")]
|
||||
Unknown,
|
||||
@@ -6218,7 +6209,6 @@ pub enum CollectionType {
|
||||
}
|
||||
/// The collection type options.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum CollectionTypeOptions {
|
||||
#[serde(rename = "movies")]
|
||||
Movies,
|
||||
@@ -6238,7 +6228,6 @@ pub enum CollectionTypeOptions {
|
||||
Mixed,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum DayOfWeek {
|
||||
#[serde(rename = "Sunday")]
|
||||
Sunday,
|
||||
@@ -6256,7 +6245,6 @@ pub enum DayOfWeek {
|
||||
Saturday,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum DayPattern {
|
||||
#[serde(rename = "Daily")]
|
||||
Daily,
|
||||
@@ -6267,7 +6255,6 @@ pub enum DayPattern {
|
||||
}
|
||||
/// Enum containing deinterlace methods.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum DeinterlaceMethod {
|
||||
#[serde(rename = "yadif")]
|
||||
Yadif,
|
||||
@@ -6275,7 +6262,6 @@ pub enum DeinterlaceMethod {
|
||||
Bwdif,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum DlnaProfileType {
|
||||
#[serde(rename = "Audio")]
|
||||
Audio,
|
||||
@@ -6290,7 +6276,6 @@ pub enum DlnaProfileType {
|
||||
}
|
||||
/// An enum representing an algorithm to downmix surround sound to stereo.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum DownMixStereoAlgorithms {
|
||||
#[serde(rename = "None")]
|
||||
None,
|
||||
@@ -6305,7 +6290,6 @@ pub enum DownMixStereoAlgorithms {
|
||||
}
|
||||
/// An enum that represents a day of the week, weekdays, weekends, or all days.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum DynamicDayOfWeek {
|
||||
#[serde(rename = "Sunday")]
|
||||
Sunday,
|
||||
@@ -6330,7 +6314,6 @@ pub enum DynamicDayOfWeek {
|
||||
}
|
||||
/// An enum representing the options to disable embedded subs.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum EmbeddedSubtitleOptions {
|
||||
#[serde(rename = "AllowAll")]
|
||||
AllowAll,
|
||||
@@ -6343,7 +6326,6 @@ pub enum EmbeddedSubtitleOptions {
|
||||
}
|
||||
/// Enum containing encoder presets.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum EncoderPreset {
|
||||
#[serde(rename = "auto")]
|
||||
Auto,
|
||||
@@ -6369,7 +6351,6 @@ pub enum EncoderPreset {
|
||||
Ultrafast,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum EncodingContext {
|
||||
#[serde(rename = "Streaming")]
|
||||
Streaming,
|
||||
@@ -6378,7 +6359,6 @@ pub enum EncodingContext {
|
||||
}
|
||||
/// The specific media type of an MediaBrowser.Model.Providers.ExternalIdInfo.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ExternalIdMediaType {
|
||||
#[serde(rename = "Album")]
|
||||
Album,
|
||||
@@ -6408,7 +6388,6 @@ pub enum ExternalIdMediaType {
|
||||
Book,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ExtraType {
|
||||
#[serde(rename = "Unknown")]
|
||||
Unknown,
|
||||
@@ -6437,7 +6416,6 @@ pub enum ExtraType {
|
||||
}
|
||||
/// Enum FileSystemEntryType.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum FileSystemEntryType {
|
||||
#[serde(rename = "File")]
|
||||
File,
|
||||
@@ -6449,7 +6427,6 @@ pub enum FileSystemEntryType {
|
||||
NetworkShare,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ForgotPasswordAction {
|
||||
#[serde(rename = "ContactAdmin")]
|
||||
ContactAdmin,
|
||||
@@ -6460,7 +6437,6 @@ pub enum ForgotPasswordAction {
|
||||
}
|
||||
/// This exists simply to identify a set of known commands.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum GeneralCommandType {
|
||||
#[serde(rename = "MoveUp")]
|
||||
MoveUp,
|
||||
@@ -6551,7 +6527,6 @@ pub enum GeneralCommandType {
|
||||
}
|
||||
/// Enum GroupQueueMode.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum GroupQueueMode {
|
||||
#[serde(rename = "Queue")]
|
||||
Queue,
|
||||
@@ -6560,7 +6535,6 @@ pub enum GroupQueueMode {
|
||||
}
|
||||
/// Enum GroupRepeatMode.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum GroupRepeatMode {
|
||||
#[serde(rename = "RepeatOne")]
|
||||
RepeatOne,
|
||||
@@ -6571,7 +6545,6 @@ pub enum GroupRepeatMode {
|
||||
}
|
||||
/// Enum GroupShuffleMode.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum GroupShuffleMode {
|
||||
#[serde(rename = "Sorted")]
|
||||
Sorted,
|
||||
@@ -6580,7 +6553,6 @@ pub enum GroupShuffleMode {
|
||||
}
|
||||
/// Enum GroupState.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum GroupStateType {
|
||||
#[serde(rename = "Idle")]
|
||||
Idle,
|
||||
@@ -6593,7 +6565,6 @@ pub enum GroupStateType {
|
||||
}
|
||||
/// Enum GroupUpdateType.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum GroupUpdateType {
|
||||
#[serde(rename = "UserJoined")]
|
||||
UserJoined,
|
||||
@@ -6620,7 +6591,6 @@ pub enum GroupUpdateType {
|
||||
}
|
||||
/// Enum containing hardware acceleration types.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum HardwareAccelerationType {
|
||||
#[serde(rename = "none")]
|
||||
None,
|
||||
@@ -6641,7 +6611,6 @@ pub enum HardwareAccelerationType {
|
||||
}
|
||||
/// Enum ImageOutputFormat.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ImageFormat {
|
||||
#[serde(rename = "Bmp")]
|
||||
Bmp,
|
||||
@@ -6657,7 +6626,6 @@ pub enum ImageFormat {
|
||||
Svg,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ImageOrientation {
|
||||
#[serde(rename = "TopLeft")]
|
||||
TopLeft,
|
||||
@@ -6678,7 +6646,6 @@ pub enum ImageOrientation {
|
||||
}
|
||||
/// Enum ImageResolution.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ImageResolution {
|
||||
#[serde(rename = "MatchSource")]
|
||||
MatchSource,
|
||||
@@ -6700,7 +6667,6 @@ pub enum ImageResolution {
|
||||
P2160,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ImageSavingConvention {
|
||||
#[serde(rename = "Legacy")]
|
||||
Legacy,
|
||||
@@ -6709,7 +6675,6 @@ pub enum ImageSavingConvention {
|
||||
}
|
||||
/// Enum ImageType.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ImageType {
|
||||
#[serde(rename = "Primary")]
|
||||
Primary,
|
||||
@@ -6740,7 +6705,6 @@ pub enum ImageType {
|
||||
}
|
||||
/// Enum IsoType.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum IsoType {
|
||||
#[serde(rename = "Dvd")]
|
||||
Dvd,
|
||||
@@ -6749,7 +6713,6 @@ pub enum IsoType {
|
||||
}
|
||||
/// Used to control the data that gets attached to DtoBaseItems.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ItemFields {
|
||||
#[serde(rename = "AirTime")]
|
||||
AirTime,
|
||||
@@ -6874,7 +6837,6 @@ pub enum ItemFields {
|
||||
}
|
||||
/// Enum ItemFilter.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ItemFilter {
|
||||
#[serde(rename = "IsFolder")]
|
||||
IsFolder,
|
||||
@@ -6897,7 +6859,6 @@ pub enum ItemFilter {
|
||||
}
|
||||
/// These represent sort orders.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ItemSortBy {
|
||||
#[serde(rename = "Default")]
|
||||
Default,
|
||||
@@ -6965,7 +6926,6 @@ pub enum ItemSortBy {
|
||||
SearchScore,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum KeepUntil {
|
||||
#[serde(rename = "UntilDeleted")]
|
||||
UntilDeleted,
|
||||
@@ -6977,7 +6937,6 @@ pub enum KeepUntil {
|
||||
UntilDate,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum LiveTvServiceStatus {
|
||||
#[serde(rename = "Ok")]
|
||||
Ok,
|
||||
@@ -6986,7 +6945,6 @@ pub enum LiveTvServiceStatus {
|
||||
}
|
||||
/// Enum LocationType.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum LocationType {
|
||||
#[serde(rename = "FileSystem")]
|
||||
FileSystem,
|
||||
@@ -6998,7 +6956,6 @@ pub enum LocationType {
|
||||
Offline,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum LogLevel {
|
||||
#[serde(rename = "Trace")]
|
||||
Trace,
|
||||
@@ -7016,7 +6973,6 @@ pub enum LogLevel {
|
||||
None,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum MediaProtocol {
|
||||
#[serde(rename = "File")]
|
||||
File,
|
||||
@@ -7035,7 +6991,6 @@ pub enum MediaProtocol {
|
||||
}
|
||||
/// Defines the types of content an individual Jellyfin.Data.Entities.MediaSegment represents.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum MediaSegmentType {
|
||||
#[serde(rename = "Unknown")]
|
||||
Unknown,
|
||||
@@ -7051,7 +7006,6 @@ pub enum MediaSegmentType {
|
||||
Intro,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum MediaSourceType {
|
||||
#[serde(rename = "Default")]
|
||||
Default,
|
||||
@@ -7063,7 +7017,6 @@ pub enum MediaSourceType {
|
||||
/** Media streaming protocol.
|
||||
Lowercase for backwards compatibility.*/
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum MediaStreamProtocol {
|
||||
#[serde(rename = "http")]
|
||||
Http,
|
||||
@@ -7072,7 +7025,6 @@ pub enum MediaStreamProtocol {
|
||||
}
|
||||
/// Enum MediaStreamType.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum MediaStreamType {
|
||||
#[serde(rename = "Audio")]
|
||||
Audio,
|
||||
@@ -7089,7 +7041,6 @@ pub enum MediaStreamType {
|
||||
}
|
||||
/// Media types.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum MediaType {
|
||||
#[serde(rename = "Unknown")]
|
||||
Unknown,
|
||||
@@ -7104,7 +7055,6 @@ pub enum MediaType {
|
||||
}
|
||||
/// Enum MetadataFields.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum MetadataField {
|
||||
#[serde(rename = "Cast")]
|
||||
Cast,
|
||||
@@ -7126,7 +7076,6 @@ pub enum MetadataField {
|
||||
OfficialRating,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum MetadataRefreshMode {
|
||||
#[serde(rename = "None")]
|
||||
None,
|
||||
@@ -7138,7 +7087,6 @@ pub enum MetadataRefreshMode {
|
||||
FullRefresh,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ParameterInclude {
|
||||
#[serde(rename = "ProviderList")]
|
||||
ProviderList,
|
||||
@@ -7151,7 +7099,6 @@ pub enum ParameterInclude {
|
||||
}
|
||||
/// The person kind.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum PersonKind {
|
||||
#[serde(rename = "Unknown")]
|
||||
Unknown,
|
||||
@@ -7205,7 +7152,6 @@ pub enum PersonKind {
|
||||
Translator,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum PlayAccess {
|
||||
#[serde(rename = "Full")]
|
||||
Full,
|
||||
@@ -7213,7 +7159,6 @@ pub enum PlayAccess {
|
||||
None,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum PlaybackErrorCode {
|
||||
#[serde(rename = "NotAllowed")]
|
||||
NotAllowed,
|
||||
@@ -7224,7 +7169,6 @@ pub enum PlaybackErrorCode {
|
||||
}
|
||||
/// Enum PlaybackOrder.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum PlaybackOrder {
|
||||
#[serde(rename = "Default")]
|
||||
Default,
|
||||
@@ -7233,7 +7177,6 @@ pub enum PlaybackOrder {
|
||||
}
|
||||
/// Enum PlaybackRequestType.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum PlaybackRequestType {
|
||||
#[serde(rename = "Play")]
|
||||
Play,
|
||||
@@ -7272,7 +7215,6 @@ pub enum PlaybackRequestType {
|
||||
}
|
||||
/// Enum PlayCommand.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum PlayCommand {
|
||||
#[serde(rename = "PlayNow")]
|
||||
PlayNow,
|
||||
@@ -7286,7 +7228,6 @@ pub enum PlayCommand {
|
||||
PlayShuffle,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum PlayMethod {
|
||||
#[serde(rename = "Transcode")]
|
||||
Transcode,
|
||||
@@ -7297,7 +7238,6 @@ pub enum PlayMethod {
|
||||
}
|
||||
/// Enum PlayQueueUpdateReason.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum PlayQueueUpdateReason {
|
||||
#[serde(rename = "NewPlaylist")]
|
||||
NewPlaylist,
|
||||
@@ -7322,7 +7262,6 @@ pub enum PlayQueueUpdateReason {
|
||||
}
|
||||
/// Enum PlaystateCommand.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum PlaystateCommand {
|
||||
#[serde(rename = "Stop")]
|
||||
Stop,
|
||||
@@ -7345,7 +7284,6 @@ pub enum PlaystateCommand {
|
||||
}
|
||||
/// Plugin load status.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum PluginStatus {
|
||||
#[serde(rename = "Active")]
|
||||
Active,
|
||||
@@ -7363,7 +7301,6 @@ pub enum PluginStatus {
|
||||
Disabled,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ProcessPriorityClass {
|
||||
#[serde(rename = "Normal")]
|
||||
Normal,
|
||||
@@ -7379,7 +7316,6 @@ pub enum ProcessPriorityClass {
|
||||
AboveNormal,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ProfileConditionType {
|
||||
#[serde(rename = "Equals")]
|
||||
Equals,
|
||||
@@ -7393,7 +7329,6 @@ pub enum ProfileConditionType {
|
||||
EqualsAny,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ProfileConditionValue {
|
||||
#[serde(rename = "AudioChannels")]
|
||||
AudioChannels,
|
||||
@@ -7445,7 +7380,6 @@ pub enum ProfileConditionValue {
|
||||
VideoRangeType,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ProgramAudio {
|
||||
#[serde(rename = "Mono")]
|
||||
Mono,
|
||||
@@ -7461,7 +7395,6 @@ pub enum ProgramAudio {
|
||||
Atmos,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum RatingType {
|
||||
#[serde(rename = "Score")]
|
||||
Score,
|
||||
@@ -7469,7 +7402,6 @@ pub enum RatingType {
|
||||
Likes,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum RecommendationType {
|
||||
#[serde(rename = "SimilarToRecentlyPlayed")]
|
||||
SimilarToRecentlyPlayed,
|
||||
@@ -7485,7 +7417,6 @@ pub enum RecommendationType {
|
||||
HasLikedActor,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum RecordingStatus {
|
||||
#[serde(rename = "New")]
|
||||
New,
|
||||
@@ -7503,7 +7434,6 @@ pub enum RecordingStatus {
|
||||
Error,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum RepeatMode {
|
||||
#[serde(rename = "RepeatNone")]
|
||||
RepeatNone,
|
||||
@@ -7514,7 +7444,6 @@ pub enum RepeatMode {
|
||||
}
|
||||
/// An enum representing the axis that should be scrolled.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ScrollDirection {
|
||||
#[serde(rename = "Horizontal")]
|
||||
Horizontal,
|
||||
@@ -7523,7 +7452,6 @@ pub enum ScrollDirection {
|
||||
}
|
||||
/// Enum SendCommandType.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum SendCommandType {
|
||||
#[serde(rename = "Unpause")]
|
||||
Unpause,
|
||||
@@ -7536,7 +7464,6 @@ pub enum SendCommandType {
|
||||
}
|
||||
/// The status of a series.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum SeriesStatus {
|
||||
#[serde(rename = "Continuing")]
|
||||
Continuing,
|
||||
@@ -7547,7 +7474,6 @@ pub enum SeriesStatus {
|
||||
}
|
||||
/// The different kinds of messages that are used in the WebSocket api.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum SessionMessageType {
|
||||
#[serde(rename = "ForceKeepAlive")]
|
||||
ForceKeepAlive,
|
||||
@@ -7620,7 +7546,6 @@ pub enum SessionMessageType {
|
||||
}
|
||||
/// An enum representing the sorting order.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum SortOrder {
|
||||
#[serde(rename = "Ascending")]
|
||||
Ascending,
|
||||
@@ -7628,7 +7553,6 @@ pub enum SortOrder {
|
||||
Descending,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum Status {
|
||||
#[serde(rename = "Completed")]
|
||||
Completed,
|
||||
@@ -7639,7 +7563,6 @@ pub enum Status {
|
||||
}
|
||||
/// Delivery method to use during playback of a specific subtitle format.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum SubtitleDeliveryMethod {
|
||||
#[serde(rename = "Encode")]
|
||||
Encode,
|
||||
@@ -7654,7 +7577,6 @@ pub enum SubtitleDeliveryMethod {
|
||||
}
|
||||
/// An enum representing a subtitle playback mode.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum SubtitlePlaybackMode {
|
||||
#[serde(rename = "Default")]
|
||||
Default,
|
||||
@@ -7668,7 +7590,6 @@ pub enum SubtitlePlaybackMode {
|
||||
Smart,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum SyncAction {
|
||||
#[serde(rename = "UpdateProvider")]
|
||||
UpdateProvider,
|
||||
@@ -7677,7 +7598,6 @@ pub enum SyncAction {
|
||||
}
|
||||
/// Enum SyncPlayUserAccessType.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum SyncPlayUserAccessType {
|
||||
#[serde(rename = "CreateAndJoinGroups")]
|
||||
CreateAndJoinGroups,
|
||||
@@ -7688,7 +7608,6 @@ pub enum SyncPlayUserAccessType {
|
||||
}
|
||||
/// Enum TaskCompletionStatus.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum TaskCompletionStatus {
|
||||
#[serde(rename = "Completed")]
|
||||
Completed,
|
||||
@@ -7701,7 +7620,6 @@ pub enum TaskCompletionStatus {
|
||||
}
|
||||
/// Enum TaskState.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum TaskState {
|
||||
#[serde(rename = "Idle")]
|
||||
Idle,
|
||||
@@ -7712,7 +7630,6 @@ pub enum TaskState {
|
||||
}
|
||||
/// Enum containing tonemapping algorithms.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum TonemappingAlgorithm {
|
||||
#[serde(rename = "none")]
|
||||
None,
|
||||
@@ -7733,7 +7650,6 @@ pub enum TonemappingAlgorithm {
|
||||
}
|
||||
/// Enum containing tonemapping modes.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum TonemappingMode {
|
||||
#[serde(rename = "auto")]
|
||||
Auto,
|
||||
@@ -7748,7 +7664,6 @@ pub enum TonemappingMode {
|
||||
}
|
||||
/// Enum containing tonemapping ranges.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum TonemappingRange {
|
||||
#[serde(rename = "auto")]
|
||||
Auto,
|
||||
@@ -7758,7 +7673,6 @@ pub enum TonemappingRange {
|
||||
Pc,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum TranscodeReason {
|
||||
#[serde(rename = "ContainerNotSupported")]
|
||||
ContainerNotSupported,
|
||||
@@ -7814,7 +7728,6 @@ pub enum TranscodeReason {
|
||||
VideoCodecTagNotSupported,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum TranscodeSeekInfo {
|
||||
#[serde(rename = "Auto")]
|
||||
Auto,
|
||||
@@ -7822,7 +7735,6 @@ pub enum TranscodeSeekInfo {
|
||||
Bytes,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum TransportStreamTimestamp {
|
||||
#[serde(rename = "None")]
|
||||
None,
|
||||
@@ -7833,7 +7745,6 @@ pub enum TransportStreamTimestamp {
|
||||
}
|
||||
/// Enum TrickplayScanBehavior.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum TrickplayScanBehavior {
|
||||
#[serde(rename = "Blocking")]
|
||||
Blocking,
|
||||
@@ -7842,7 +7753,6 @@ pub enum TrickplayScanBehavior {
|
||||
}
|
||||
/// An enum representing an unrated item.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum UnratedItem {
|
||||
#[serde(rename = "Movie")]
|
||||
Movie,
|
||||
@@ -7864,7 +7774,6 @@ pub enum UnratedItem {
|
||||
Other,
|
||||
}
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum Video3DFormat {
|
||||
#[serde(rename = "HalfSideBySide")]
|
||||
HalfSideBySide,
|
||||
@@ -7879,7 +7788,6 @@ pub enum Video3DFormat {
|
||||
}
|
||||
/// An enum representing video ranges.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum VideoRange {
|
||||
#[serde(rename = "Unknown")]
|
||||
Unknown,
|
||||
@@ -7890,7 +7798,6 @@ pub enum VideoRange {
|
||||
}
|
||||
/// An enum representing types of video ranges.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum VideoRangeType {
|
||||
#[serde(rename = "Unknown")]
|
||||
Unknown,
|
||||
@@ -7913,7 +7820,6 @@ pub enum VideoRangeType {
|
||||
}
|
||||
/// Enum VideoType.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum VideoType {
|
||||
#[serde(rename = "VideoFile")]
|
||||
VideoFile,
|
||||
|
||||
@@ -185,6 +185,75 @@ impl JellyfinClient {
|
||||
let out: jellyfin::BaseItemDtoQueryResult = serde_json::from_str(&text)?;
|
||||
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 {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
34
flake.nix
34
flake.nix
@@ -74,18 +74,36 @@
|
||||
|
||||
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
|
||||
vulkan-loader
|
||||
glib
|
||||
]
|
||||
++ (lib.optionals pkgs.stdenv.isLinux [
|
||||
gst_all_1.gstreamermm
|
||||
gst_all_1.gst-vaapi
|
||||
|
||||
alsa-lib-with-plugins
|
||||
libxkbcommon
|
||||
udev
|
||||
wayland
|
||||
wayland-protocols
|
||||
xorg.libX11
|
||||
xorg.libXi
|
||||
xorg.libXrandr
|
||||
])
|
||||
++ (lib.optionals pkgs.stdenv.isDarwin [
|
||||
libiconv
|
||||
apple-sdk_13
|
||||
apple-sdk_26
|
||||
]);
|
||||
};
|
||||
cargoArtifacts = craneLib.buildPackage commonArgs;
|
||||
@@ -144,11 +162,10 @@
|
||||
devShells = {
|
||||
default =
|
||||
pkgs.mkShell.override {
|
||||
stdenv = pkgs.clangStdenv;
|
||||
# stdenv =
|
||||
# if pkgs.stdenv.isLinux
|
||||
# then (pkgs.stdenvAdapters.useMoldLinker pkgs.clangStdenv)
|
||||
# else pkgs.clangStdenv;
|
||||
stdenv =
|
||||
if pkgs.stdenv.isLinux
|
||||
then (pkgs.stdenvAdapters.useMoldLinker pkgs.clangStdenv)
|
||||
else pkgs.clangStdenv;
|
||||
} (commonArgs
|
||||
// {
|
||||
packages = with pkgs;
|
||||
@@ -162,9 +179,10 @@
|
||||
cargo-hack
|
||||
cargo-outdated
|
||||
lld
|
||||
lldb
|
||||
]
|
||||
++ (lib.optionals pkgs.stdenv.isDarwin [
|
||||
apple-sdk_13
|
||||
apple-sdk_26
|
||||
])
|
||||
++ (lib.optionals pkgs.stdenv.isLinux [
|
||||
mold
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
mod errors;
|
||||
use api::{JellyfinClient, JellyfinConfig};
|
||||
use api::JellyfinConfig;
|
||||
use errors::*;
|
||||
|
||||
fn jellyfin_config_try() -> Result<JellyfinConfig> {
|
||||
|
||||
@@ -184,7 +184,6 @@ fn main() {
|
||||
quote::quote! {
|
||||
#[doc = #desc]
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum #key {
|
||||
#(#variants),*
|
||||
}
|
||||
@@ -192,7 +191,6 @@ fn main() {
|
||||
} else {
|
||||
quote::quote! {
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum #key {
|
||||
#(#variants),*
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ edition = "2024"
|
||||
[dependencies]
|
||||
api = { version = "0.1.0", path = "../api" }
|
||||
blurhash = "0.2.3"
|
||||
bytes = "1.11.0"
|
||||
gpui_util = "0.2.2"
|
||||
iced = { git = "https://github.com/iced-rs/iced", features = [
|
||||
"advanced",
|
||||
"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"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::sync::{Arc, LazyLock, atomic::AtomicBool};
|
||||
|
||||
use iced::{Element, advanced::Widget, widget::Image};
|
||||
use iced::{Element, Length, advanced::Widget};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::shared_string::SharedString;
|
||||
|
||||
@@ -8,16 +7,27 @@ use crate::shared_string::SharedString;
|
||||
pub struct BlurHash {
|
||||
hash: SharedString,
|
||||
handle: Arc<iced::advanced::image::Handle>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
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) {
|
||||
let pixels = blurhash::decode(&self.hash, self.width, self.height, self.punch)
|
||||
.unwrap_or_else(|_| vec![0; (self.width * self.height * 4) as usize]);
|
||||
let handle = iced::advanced::image::Handle::from_rgba(self.width, self.height, pixels);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -29,27 +39,24 @@ impl BlurHash {
|
||||
BlurHash {
|
||||
hash,
|
||||
handle,
|
||||
width: 32,
|
||||
height: 32,
|
||||
width: 32.into(),
|
||||
height: 32.into(),
|
||||
punch: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn width(mut self, height: u32) -> Self {
|
||||
self.width = height;
|
||||
self.recompute();
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn height(mut self, height: u32) -> Self {
|
||||
self.height = height;
|
||||
self.recompute();
|
||||
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.recompute();
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -60,8 +67,8 @@ where
|
||||
{
|
||||
fn size(&self) -> iced::Size<iced::Length> {
|
||||
iced::Size {
|
||||
width: iced::Length::Fixed(self.width as f32),
|
||||
height: iced::Length::Fixed(self.height as f32),
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,17 +78,21 @@ where
|
||||
renderer: &Renderer,
|
||||
limits: &iced::advanced::layout::Limits,
|
||||
) -> iced::advanced::layout::Node {
|
||||
iced::widget::image::layout(
|
||||
let layout = iced::widget::image::layout(
|
||||
renderer,
|
||||
limits,
|
||||
&self.handle,
|
||||
self.width.into(),
|
||||
self.height.into(),
|
||||
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(
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
mod shared_string;
|
||||
use iced_video_player::{Video, VideoPlayer};
|
||||
use shared_string::SharedString;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod blur_hash;
|
||||
use blur_hash::BlurHash;
|
||||
|
||||
use iced::{Alignment, Element, Length, Task, widget::*};
|
||||
mod preview;
|
||||
use preview::Preview;
|
||||
|
||||
use iced::{Alignment, Element, Length, Shadow, Task, widget::*};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Loading {
|
||||
to: Screen,
|
||||
from: Screen,
|
||||
}
|
||||
pub struct Loading {}
|
||||
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub struct ItemCache {
|
||||
@@ -25,6 +27,7 @@ impl ItemCache {
|
||||
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>>,
|
||||
@@ -35,6 +38,7 @@ impl ItemCache {
|
||||
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);
|
||||
@@ -47,6 +51,10 @@ impl ItemCache {
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &uuid::Uuid) -> Option<&Item> {
|
||||
self.items.get(id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -72,6 +80,7 @@ impl From<api::jellyfin::BaseItemDto> for Item {
|
||||
.and_then(|hashes| hashes.get(&tag).cloned())
|
||||
.map(|s| s.clone().into()),
|
||||
}),
|
||||
_type: dto._type,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +91,7 @@ pub struct Item {
|
||||
pub parent_id: Option<uuid::Uuid>,
|
||||
pub name: Option<SharedString>,
|
||||
pub thumbnail: Option<Thumbnail>,
|
||||
pub _type: api::jellyfin::BaseItemKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -89,7 +99,8 @@ pub enum Screen {
|
||||
#[default]
|
||||
Home,
|
||||
Settings,
|
||||
Profile,
|
||||
User,
|
||||
Video,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
struct State {
|
||||
@@ -98,6 +109,15 @@ struct State {
|
||||
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>>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
@@ -108,6 +128,13 @@ impl State {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,37 +142,135 @@ impl State {
|
||||
#[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 => {
|
||||
// Foo
|
||||
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();
|
||||
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),
|
||||
},
|
||||
)
|
||||
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()
|
||||
}
|
||||
@@ -175,33 +300,147 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
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> {
|
||||
column([header(state), body(state), footer(state)]).into()
|
||||
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> {
|
||||
container(
|
||||
Grid::with_children(state.cache.items_of(state.current).into_iter().map(card)).spacing(70),
|
||||
)
|
||||
.padding(70)
|
||||
.align_x(Alignment::Center)
|
||||
// .align_y(Alignment::Center)
|
||||
.height(Length::Fill)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
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(
|
||||
Text::new(state.jellyfin_client.config.server_url.as_str())
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::Start),
|
||||
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)
|
||||
@@ -210,10 +449,14 @@ fn header(state: &State) -> Element<'_, Message> {
|
||||
.align_y(Alignment::Center)
|
||||
.style(container::rounded_box)
|
||||
.into(),
|
||||
search(state),
|
||||
container(
|
||||
row([
|
||||
button("Settings").on_press(Message::OpenSettings).into(),
|
||||
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),
|
||||
)
|
||||
@@ -231,6 +474,22 @@ fn header(state: &State) -> Element<'_, Message> {
|
||||
.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(
|
||||
@@ -249,37 +508,160 @@ fn footer(state: &State) -> Element<'_, Message> {
|
||||
.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");
|
||||
container(
|
||||
column([
|
||||
BlurHash::new(
|
||||
item.thumbnail
|
||||
.as_ref()
|
||||
.and_then(|t| t.blur_hash.as_ref())
|
||||
.map(|s| s.as_ref())
|
||||
.unwrap_or(""),
|
||||
)
|
||||
.width(200)
|
||||
.height(400)
|
||||
.into(),
|
||||
Text::new(name).size(16).into(),
|
||||
])
|
||||
.align_x(Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
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),
|
||||
)
|
||||
.padding(70)
|
||||
.width(Length::FillPortion(5))
|
||||
.height(Length::FillPortion(5))
|
||||
.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());
|
||||
@@ -291,7 +673,8 @@ fn init(config: impl Fn() -> api::JellyfinConfig + 'static) -> impl Fn() -> (Sta
|
||||
Ok(token) => Message::SetToken(token),
|
||||
Err(e) => Message::Error(format!("Authentication failed: {}", e)),
|
||||
},
|
||||
),
|
||||
)
|
||||
.chain(Task::done(Message::Refresh)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user