diff --git a/.gitignore b/.gitignore index e9b064d..9944834 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ build/ *.egg-info/ .pytest_cache/ +jellyfin-kiosk-rs/target \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8856a5e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# AI Agent & Contributor Guidelines: Rust Dependency & Feature Minimization + +## Core Principle: Minimal Features & Fast Compilation + +To ensure fast compilation times and prevent binary and dependency bloat on development and embedded/kiosk hardware: + +1. **Always use `default-features = false` by default** on all external crates unless explicitly proven unnecessary. +2. **Never enable meta-features or "all" feature flags**: + - ❌ Never use `tokio = { features = ["full"] }`. + - ❌ Never use `egui_extras = { features = ["all_loaders"] }`. + - ❌ Never enable default formats in the `image` crate (pulls in `rayon`, `exr`, `tiff`, `dds`, `hdr`, etc.). +3. **Avoid heavy rasterization / rendering stacks**: + - Do **NOT** enable `svg` or `resvg` in `egui_extras`. The `resvg`/`usvg`/`tiny-skia` dependency tree adds dozens of crates and heavily impacts compilation time. + - Do **NOT** enable `wgpu` in `eframe`. Use `glow` (OpenGL) only. +4. **Iterate with `cargo check`**: + - Always run `cargo check` instead of `cargo build` during development cycles to test type checking and borrow checking in seconds. + +--- + +## Approved Dependency Feature Matrix + +When editing or updating `jellyfin-kiosk-rs/Cargo.toml`, adhere strictly to the following configurations: + +| Crate | Approved Configuration | Rationale / Prohibited Features | +| :--- | :--- | :--- | +| `eframe` | `default-features = false, features = ["default_fonts", "glow", "wayland", "x11"]` | Keep `wgpu` disabled. Only build necessary windowing and OpenGL backends. | +| `egui_extras` | `default-features = false, features = ["http", "image"]` | Only needed for fetching/rendering HTTP posters & backdrops. **Never enable `all_loaders` or `svg`**. | +| `image` | `default-features = false, features = ["jpeg", "png"]` | Jellyfin images are JPEGs/PNGs. Disabling default features avoids `rayon` and heavy decoders. | +| `tokio` | `default-features = false, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"]` | Never use `"full"`. `fs`, `process`, and `signal` from Tokio are not used (standard library equivalents are used). | +| `axum` | `default-features = false, features = ["http1", "json", "tokio"]` | The REST API only handles JSON POST/GET requests and Tokio serving. No `form` or `query` parsers needed. | +| `reqwest` | `default-features = false, features = ["json", "default-tls", "http2"]` | Minimal HTTP client features required for communication with the Jellyfin server. | +| `tracing-subscriber`| `features = ["env-filter"]` | Standard lightweight log filtering and formatting. | +| `rumqttc` | `default-features = false` | MQTT client for Home Assistant discovery and state reporting. Avoids rustls/aws-lc-rs crypto bloat. | + +--- + +## Verification Before Committing + +Whenever dependencies or features are changed: +1. Run `cargo check` in `jellyfin-kiosk-rs` to ensure compilation without warnings/errors. +2. Check dependency count in `Cargo.lock` (`grep "name = " Cargo.lock | wc -l`) to confirm no unexpected transitive bloat was introduced. +3. Verify that `cargo build` links successfully. diff --git a/config.example.yaml b/config.example.yaml index 865c999..1784f2c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -47,3 +47,21 @@ youtube: max_height: 1080 # Automatically extract metadata (title, uploader) using yt-dlp before playback fetch_metadata: true + # Google OAuth 2.0 Client ID (Google Cloud Console -> APIs -> Credentials, type: TVs and Limited Input / Desktop) + client_id: "" + client_secret: "" + # Pad voor het opslaan van de ingelogde YouTube sessie + token_path: "youtube_token.json" + +mqtt: + # Enable MQTT client and Home Assistant auto-discovery + enabled: false + # MQTT broker host/IP + host: "localhost" + port: 1883 + username: "" + password: "" + # Home Assistant MQTT discovery prefix (default: homeassistant) + discovery_prefix: "homeassistant" + # Base topic prefix for state updates and commands + topic_prefix: "jellyfin-kiosk" diff --git a/docker-compose.yml b/docker-compose.yml index e405323..8502fc0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,14 +5,11 @@ services: dockerfile: Dockerfile container_name: jellyfin-client restart: unless-stopped - ports: - - "8080:8080" # Kodi JSON-RPC (Yatse) - - "9090:9090" # Kodi JSON-RPC WebSocket & TCP Socket - - "9777:9777/udp" # Kodi EventServer (UDP) - - "8097:8097" # Web Remote & REST API + network_mode: host + environment: - DISPLAY=${DISPLAY:-:0} - - MPV_VO=${MPV_VO:-null} # 'null' ensures stable background test; set to 'gpu' with xhost for video window + - MPV_VO=${MPV_VO:-gpu} # 'gpu' for video window, 'null' for headless - MPV_HWDEC=${MPV_HWDEC:-auto} # 'auto', 'vaapi', 'nvdec', or 'no' - MPV_FULLSCREEN=${MPV_FULLSCREEN:-false} # Windowed by default for desktop testing volumes: @@ -20,7 +17,8 @@ services: - ./config.yaml:/app/config.yaml:ro # X11 Display passthrough to show video window on host screen - /tmp/.X11-unix:/tmp/.X11-unix:ro - # Optional hardware acceleration devices (uncomment if available on Linux host) - # devices: - # - /dev/dri:/dev/dri - # - /dev/snd:/dev/snd + # Hardware acceleration devices + devices: + - /dev/dri:/dev/dri + - /dev/snd:/dev/snd + diff --git a/jellyfin-kiosk-rs/AGENTS.md b/jellyfin-kiosk-rs/AGENTS.md new file mode 120000 index 0000000..be77ac8 --- /dev/null +++ b/jellyfin-kiosk-rs/AGENTS.md @@ -0,0 +1 @@ +../AGENTS.md \ No newline at end of file diff --git a/jellyfin-kiosk-rs/Cargo.lock b/jellyfin-kiosk-rs/Cargo.lock new file mode 100644 index 0000000..aadce0e --- /dev/null +++ b/jellyfin-kiosk-rs/Cargo.lock @@ -0,0 +1,4193 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arboard" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" +dependencies = [ + "clipboard-win", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0481a0e032742109b1133a095184ee93d88f3dc9e0d28a5d033dc77a073f44f" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.6", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.6", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "clipboard-win" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "com" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" +dependencies = [ + "com_macros", +] + +[[package]] +name = "com_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" +dependencies = [ + "com_macros_support", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "com_macros_support" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775cfde491852059e386c4e1deb4aef381c617dc364184c6f6afee99b87c402b" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ac2645a9bf4826eb4e91488b1f17b8eaddeef09396706b2f14066461338e24f" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow 0.14.2", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "winapi", + "windows-sys 0.52.0", + "winit", +] + +[[package]] +name = "egui" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53eafabcce0cb2325a59a98736efe0bf060585b437763f8c476957fb274bb974" +dependencies = [ + "ahash", + "emath", + "epaint", + "log", + "nohash-hasher", +] + +[[package]] +name = "egui-wgpu" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d00fd5d06d8405397e64a928fa0ef3934b3c30273ea7603e3dc4627b1f7a1a82" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "thiserror 1.0.69", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9c430f4f816340e8e8c1b20eec274186b1be6bc4c7dfc467ed50d57abc36c6" +dependencies = [ + "ahash", + "arboard", + "egui", + "log", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_extras" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3c1f5cd8dfe2ade470a218696c66cf556fcfd701e7830fa2e9f4428292a2a1" +dependencies = [ + "ahash", + "egui", + "ehttp", + "enum-map", + "image", + "log", +] + +[[package]] +name = "egui_glow" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e39bccc683cd43adab530d8f21a13eb91e80de10bcc38c3f1c16601b6f62b26" +dependencies = [ + "ahash", + "bytemuck", + "egui", + "glow 0.14.2", + "log", + "memoffset", + "wasm-bindgen", + "web-sys", + "winit", +] + +[[package]] +name = "ehttp" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a81c221a1e4dad06cb9c9deb19aea1193a5eea084e8cd42d869068132bf876" +dependencies = [ + "document-features", + "js-sys", + "ureq", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "emath" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1fe0049ce51d0fb414d029e668dd72eb30bc2b739bf34296ed97bd33df544f3" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "enum-map" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +dependencies = [ + "enum-map-derive", + "serde", +] + +[[package]] +name = "enum-map-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "epaint" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a32af8da821bd4f43f2c137e295459ee2e1661d87ca8779dfa0eaf45d870e20f" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "log", + "nohash-hasher", + "parking_lot", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "483440db0b7993cf77a20314f08311dbe95675092405518c0677aa08c151a3ea" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.6", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glow" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d51fa363f025f5c111e03f13eda21162faeacb6911fe8caa0c0349f9cf0c4483" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases 0.2.2", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases 0.2.2", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.2", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "gpu-allocator" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd4240fc91d3433d5e5b0fc5b67672d771850dc19bbee03c1381e19322803d7" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "winapi", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.2", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hassle-rs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" +dependencies = [ + "bitflags 2.13.2", + "com", + "libc", + "libloading", + "thiserror 1.0.69", + "widestring", + "winapi", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jellyfin-kiosk-rs" +version = "0.1.0" +dependencies = [ + "axum", + "eframe", + "egui_extras", + "image", + "raw-window-handle", + "reqwest", + "rumqttc", + "serde", + "serde_json", + "serde_yaml", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.5", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.13.2", + "block", + "core-graphics-types", + "foreign-types 0.5.0", + "log", + "objc", + "paste", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bd5a652b6faf21496f2cfd88fc49989c8db0825d1f6746b1a71a6ede24a63ad" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg_aliases 0.1.1", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash 1.1.0", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2", + "dispatch", + "libc", + "objc2 0.5.3", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de8111e94a56adca7ebfe6c365b6d42f99deb69b74b6becacd4204e2f193aa2" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[package]] +name = "openssl-sys" +version = "0.9.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c413d6ec9ebaf5e1c829d355cb3e9f783778d700f135be0921d52fce3c60ae" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rumqttc" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0feff8d882bff0b2fddaf99355a10336d43dd3ed44204f85ece28cf9626ab519" +dependencies = [ + "bytes", + "fixedbitset", + "flume", + "futures-util", + "log", + "thiserror 2.0.21", + "tokio", + "tokio-stream", + "tokio-util", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.6", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.6", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.6", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.6", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe8d20d0214027baf11b8b0cc307c1da7657d8f4db9aa956f7de27a6fb1acbd5" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.11", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "wgpu" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d1c4ba43f80542cf63a0a6ed3134629ae73e8ab51e4b765a67f3aa062eb433" +dependencies = [ + "arrayvec", + "cfg_aliases 0.1.1", + "document-features", + "js-sys", + "log", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348c840d1051b8e86c3bcd31206080c5e71e5933dabd79be1ce732b0b2f089a" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.13.2", + "cfg_aliases 0.1.1", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6bbf4b4de8b2a83c0401d9e5ae0080a2792055f25859a02bf9be97952bbed4f" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bitflags 2.13.2", + "cfg_aliases 0.1.1", + "core-graphics-types", + "glow 0.13.1", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winapi", +] + +[[package]] +name = "wgpu-types" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9d91f0e2c4b51434dfa6db77846f2793149d8e73f800fa2e41f52b8eac3c5d" +dependencies = [ + "bitflags 2.13.2", + "js-sys", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases 0.2.2", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.6", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/jellyfin-kiosk-rs/Cargo.toml b/jellyfin-kiosk-rs/Cargo.toml new file mode 100644 index 0000000..7ff83f8 --- /dev/null +++ b/jellyfin-kiosk-rs/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "jellyfin-kiosk-rs" +version = "0.1.0" +edition = "2021" + +[dependencies] +eframe = { version = "0.29", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] } +egui_extras = { version = "0.29", default-features = false, features = ["http", "image"] } +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } +tokio = { version = "1.40", default-features = false, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] } +axum = { version = "0.7", default-features = false, features = ["http1", "json", "tokio"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" +reqwest = { version = "0.12", default-features = false, features = ["json", "default-tls", "http2"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +raw-window-handle = "0.6" +rumqttc = { version = "0.25", default-features = false } diff --git a/jellyfin-kiosk-rs/jellyfin-kiosk b/jellyfin-kiosk-rs/jellyfin-kiosk new file mode 100755 index 0000000..6fa72ca Binary files /dev/null and b/jellyfin-kiosk-rs/jellyfin-kiosk differ diff --git a/jellyfin-kiosk-rs/mpv-shot0001.jpg b/jellyfin-kiosk-rs/mpv-shot0001.jpg new file mode 100644 index 0000000..658151e Binary files /dev/null and b/jellyfin-kiosk-rs/mpv-shot0001.jpg differ diff --git a/jellyfin-kiosk-rs/src/app.rs b/jellyfin-kiosk-rs/src/app.rs new file mode 100644 index 0000000..ae498f6 --- /dev/null +++ b/jellyfin-kiosk-rs/src/app.rs @@ -0,0 +1,2821 @@ +use eframe::egui::{self, Color32, FontId, Pos2, Rect, Rounding, Stroke, Vec2}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; + +use crate::config::AppConfig; +use crate::jellyfin::{CardLayout, JellyfinClient, MediaItem, MediaLibrary, PlayableTarget}; +use crate::player::{PlaybackState, PlayerController}; +use crate::yatse::RemoteNavAction; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ViewMode { + Browser, + Player, +} + +pub const SEEK_STEPS: [f64; 5] = [10.0, 30.0, 60.0, 180.0, 300.0]; + +pub const OSD_MENU_ITEMS: [&str; 10] = [ + "⏯ Afspelen / Pauzeren", + "⏪ -10s Terugspoelen", + "⏩ +10s Vooruitspoelen", + "💬 Volgende Ondertitel", + "🔊 Volgende Audiostroom", + "🔉 Volume Zachter (-5%)", + "🔊 Volume Harder (+5%)", + "🔇 Dempen / Dempen Opheffen", + "📂 Terug naar Bibliotheek", + "⏹ Afspelen Stoppen", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SidebarSection { + Dashboard, + LiveTv, + Movies, + Shows, + Music, + Photos, + MusicVideos, + YouTube, + Favorites, +} + +impl SidebarSection { + pub const ALL: [SidebarSection; 9] = [ + SidebarSection::Dashboard, + SidebarSection::LiveTv, + SidebarSection::Movies, + SidebarSection::Shows, + SidebarSection::Music, + SidebarSection::Photos, + SidebarSection::MusicVideos, + SidebarSection::YouTube, + SidebarSection::Favorites, + ]; + + pub fn label(&self) -> &'static str { + match self { + SidebarSection::Dashboard => "🏠 Dashboard", + SidebarSection::LiveTv => "📡 Live TV", + SidebarSection::Movies => "🍿 Films", + SidebarSection::Shows => "📺 Series", + SidebarSection::Music => "🎵 Muziek", + SidebarSection::Photos => "📷 Foto's", + SidebarSection::MusicVideos => "🎬 Muziek Video's", + SidebarSection::YouTube => "▶️ YouTube", + SidebarSection::Favorites => "⭐ Favorieten", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FocusArea { + Sidebar, + Content, + Alphabet, +} + +pub const INDEX_CHARS: [char; 27] = [ + '#', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', + 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MusicMode { + Artists, + Albums, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum YouTubeTab { + Feed, + Subscriptions, + Search, +} + +pub enum AppEvent { + LibraryLoaded(Result), + FolderLoaded { + folder_id: String, + name: String, + items: Vec, + }, + ShowPhoto { + url: String, + title: String, + }, + ErrorMessage(String), + YouTubeLoggedInState(bool), + YouTubeAuthStarted(Result), + YouTubeAuthCompleted(Result<(), String>), + YouTubeFeedLoaded(Result, String>), + YouTubeSubscriptionsLoaded(Result, String>), + YouTubeChannelLoaded { + channel_title: String, + videos: Vec, + }, + YouTubeSearchResults(Result, String>), +} + +pub struct KioskApp { + config: AppConfig, + client: JellyfinClient, + player: Arc, + library: MediaLibrary, + is_loading: bool, + error_msg: Option, + player_error_msg: Option, + + // View mode & Playback OSD + view_mode: ViewMode, + was_playing: bool, + playback_osd_open: bool, + playback_osd_idx: usize, + last_seen_volume: f64, + volume_hud_until: Option, + osd_notice: Option<(String, Instant)>, + seek_step_level: usize, + seek_last_time: Option, + + // Navigation & focus + focus_area: FocusArea, + sidebar_index: usize, + music_mode: MusicMode, + selected_alpha_idx: usize, + + // Dashboard grid coordinates + selected_row: usize, + selected_col: usize, + + // Library grid index + selected_grid_idx: usize, + grid_cols: usize, + last_card_w: f32, + scroll_to_offset: Option, + + // Folder navigation (for photo albums, artist tracks, collections) + folder_stack: Vec<(String, String)>, + folder_items: Vec, + folder_loading: bool, + + // Fullscreen photo viewer + full_photo: Option<(String, String)>, + + // YouTube state + youtube_client: Arc, + youtube_tab: YouTubeTab, + youtube_is_logged_in: bool, + youtube_auth_code: Option, + youtube_auth_pending: bool, + youtube_auth_error: Option, + youtube_feed: Vec, + youtube_subscriptions: Vec, + youtube_channel_videos: Option<(String, Vec)>, + youtube_search_results: Vec, + youtube_loading: bool, + youtube_query: String, + + nav_rx: UnboundedReceiver, + event_tx: UnboundedSender, + event_rx: UnboundedReceiver, +} + +impl KioskApp { + pub fn new( + config: AppConfig, + client: JellyfinClient, + player: Arc, + nav_rx: UnboundedReceiver, + ) -> Self { + let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel(); + let client_clone = client.clone(); + let tx = event_tx.clone(); + + tokio::spawn(async move { + let res = client_clone.fetch_all_media().await.map_err(|e| e.to_string()); + let _ = tx.send(AppEvent::LibraryLoaded(res)); + }); + + let youtube_client = Arc::new(crate::youtube::YouTubeClient::new(config.youtube.clone())); + let yt = youtube_client.clone(); + let tx_yt = event_tx.clone(); + tokio::spawn(async move { + if yt.is_logged_in().await { + let _ = tx_yt.send(AppEvent::YouTubeLoggedInState(true)); + let feed = yt.fetch_feed().await; + let _ = tx_yt.send(AppEvent::YouTubeFeedLoaded(feed)); + let subs = yt.fetch_subscriptions().await; + let _ = tx_yt.send(AppEvent::YouTubeSubscriptionsLoaded(subs)); + } + }); + + Self { + config, + client, + player, + library: MediaLibrary::default(), + is_loading: true, + error_msg: None, + player_error_msg: None, + view_mode: ViewMode::Browser, + was_playing: false, + playback_osd_open: false, + playback_osd_idx: 0, + last_seen_volume: 100.0, + volume_hud_until: None, + osd_notice: None, + seek_step_level: 0, + seek_last_time: None, + focus_area: FocusArea::Content, + sidebar_index: 0, + music_mode: MusicMode::Artists, + selected_alpha_idx: 0, + selected_row: 0, + selected_col: 0, + selected_grid_idx: 0, + grid_cols: 5, + last_card_w: 160.0, + scroll_to_offset: None, + folder_stack: Vec::new(), + folder_items: Vec::new(), + folder_loading: false, + full_photo: None, + youtube_client, + youtube_tab: YouTubeTab::Feed, + youtube_is_logged_in: false, + youtube_auth_code: None, + youtube_auth_pending: false, + youtube_auth_error: None, + youtube_feed: Vec::new(), + youtube_subscriptions: Vec::new(), + youtube_channel_videos: None, + youtube_search_results: Vec::new(), + youtube_loading: false, + youtube_query: String::new(), + nav_rx, + event_tx, + event_rx, + } + } + + fn has_alphabet_bar(&self) -> bool { + if !self.folder_stack.is_empty() { + // If in an opened folder, check if it's an album (audio tracks). Album track table doesn't need alpha bar. + // But if it's a folder of subfolders or artists, alpha bar can be shown if needed. Let's hide it in folder view. + return false; + } + if self.current_section() == SidebarSection::YouTube { + return self.youtube_tab == YouTubeTab::Subscriptions && self.youtube_channel_videos.is_none(); + } + matches!( + self.current_section(), + SidebarSection::Movies + | SidebarSection::Shows + | SidebarSection::Music + | SidebarSection::Photos + | SidebarSection::MusicVideos + | SidebarSection::Favorites + ) + } + + fn is_in_album_view(&self) -> bool { + !self.folder_stack.is_empty() && self.folder_items.iter().any(|i| i.item_type == "Audio") + } + + fn current_section(&self) -> SidebarSection { + SidebarSection::ALL[self.sidebar_index.min(SidebarSection::ALL.len() - 1)] + } + + // Dashboard rows (Resume, Latest Movies, Latest Shows, Latest Music) + fn get_dashboard_rows(&self) -> Vec<(&'static str, CardLayout, &[MediaItem])> { + let mut rows = Vec::new(); + if !self.library.resume_items.is_empty() { + rows.push(("Verder kijken", CardLayout::LandscapeThumb, self.library.resume_items.as_slice())); + } + if !self.library.latest_movies.is_empty() { + rows.push(("Nieuwste films", CardLayout::Poster, self.library.latest_movies.as_slice())); + } + if !self.library.latest_shows.is_empty() { + rows.push(("Nieuwe tv shows", CardLayout::Poster, self.library.latest_shows.as_slice())); + } + if !self.library.latest_music.is_empty() { + rows.push(("Nieuwe muziek", CardLayout::Square, self.library.latest_music.as_slice())); + } + rows + } + + fn get_active_card_layout(&self) -> CardLayout { + if !self.folder_stack.is_empty() { + let section = self.current_section(); + return match section { + SidebarSection::Photos => CardLayout::PhotoLarge, + SidebarSection::Music => CardLayout::Square, + SidebarSection::MusicVideos => CardLayout::LandscapeThumb, + _ => CardLayout::Poster, + }; + } + + match self.current_section() { + SidebarSection::Dashboard => CardLayout::Poster, + SidebarSection::Movies | SidebarSection::Shows | SidebarSection::Favorites => CardLayout::Poster, + SidebarSection::Music => CardLayout::Square, + SidebarSection::Photos => CardLayout::PhotoLarge, + SidebarSection::MusicVideos | SidebarSection::LiveTv => CardLayout::LandscapeThumb, + SidebarSection::YouTube => { + if self.youtube_channel_videos.is_some() { + CardLayout::LandscapeThumb + } else { + match self.youtube_tab { + YouTubeTab::Feed | YouTubeTab::Search => CardLayout::LandscapeThumb, + YouTubeTab::Subscriptions => CardLayout::Square, + } + } + } + } + } + + // Current active items for library grid views or inside an opened folder + fn get_active_grid_items(&self) -> (&'static str, CardLayout, &[MediaItem]) { + if !self.folder_stack.is_empty() { + let section = self.current_section(); + let layout = match section { + SidebarSection::Photos => CardLayout::PhotoLarge, + SidebarSection::Music => CardLayout::Square, + SidebarSection::MusicVideos => CardLayout::LandscapeThumb, + _ => CardLayout::Poster, + }; + return ("Map Inhoud", layout, self.folder_items.as_slice()); + } + + match self.current_section() { + SidebarSection::Dashboard => ("Dashboard", CardLayout::Poster, &[]), + SidebarSection::Movies => ("Alle Films", CardLayout::Poster, self.library.all_movies.as_slice()), + SidebarSection::Shows => ("Alle Series", CardLayout::Poster, self.library.all_shows.as_slice()), + SidebarSection::Music => match self.music_mode { + MusicMode::Artists => ("Artiesten (A-Z)", CardLayout::Square, self.library.music_artists.as_slice()), + MusicMode::Albums => ("Albums", CardLayout::Square, self.library.music_albums.as_slice()), + }, + SidebarSection::Photos => ("Foto Albums & Video's", CardLayout::PhotoLarge, self.library.photos.as_slice()), + SidebarSection::MusicVideos => ("Muziek Video's", CardLayout::LandscapeThumb, self.library.music_videos.as_slice()), + SidebarSection::LiveTv => ("Live TV Zenders", CardLayout::LandscapeThumb, self.library.live_channels.as_slice()), + SidebarSection::Favorites => ("Favorieten", CardLayout::Poster, self.library.favorites.as_slice()), + SidebarSection::YouTube => { + if let Some((_, ref videos)) = self.youtube_channel_videos { + ("YouTube Kanaal Uploads", CardLayout::LandscapeThumb, videos.as_slice()) + } else { + match self.youtube_tab { + YouTubeTab::Feed => ("Nieuwste Video's van Abonnementen", CardLayout::LandscapeThumb, self.youtube_feed.as_slice()), + YouTubeTab::Subscriptions => ("Geabonneerde Kanalen", CardLayout::Square, self.youtube_subscriptions.as_slice()), + YouTubeTab::Search => ("Zoekresultaten & Snelle Link", CardLayout::LandscapeThumb, self.youtube_search_results.as_slice()), + } + } + } + } + } + + fn get_selected_item(&self) -> Option { + if !self.folder_stack.is_empty() { + return self.folder_items.get(self.selected_grid_idx).cloned(); + } + + if self.current_section() == SidebarSection::Dashboard { + let rows = self.get_dashboard_rows(); + if self.selected_row < rows.len() { + let (_, _, items) = rows[self.selected_row]; + if self.selected_col < items.len() { + return Some(items[self.selected_col].clone()); + } + } + return None; + } + + let (_, _, items) = self.get_active_grid_items(); + items.get(self.selected_grid_idx).cloned() + } + + fn play_selected(&mut self) { + if let Some(item) = self.get_selected_item() { + if self.current_section() == SidebarSection::YouTube { + if item.item_type == "YouTubeChannel" { + self.load_channel_videos(&item.id, &item.name); + return; + } + if item.item_type == "YouTubeVideo" || item.id.starts_with("http") { + let player = self.player.clone(); + let url = item.id.clone(); + let name = item.name.clone(); + let channel = item.series_name.clone(); + tokio::spawn(async move { + let _ = player.play_media(&url, &name, "stream", channel.as_deref(), None).await; + }); + return; + } + } + + let client = self.client.clone(); + let player = self.player.clone(); + let tx = self.event_tx.clone(); + + tokio::spawn(async move { + match client.resolve_playable_item(&item).await { + Ok(PlayableTarget::Stream { url, title, media_type, artist, genre }) => { + let _ = player.play_media(&url, &title, &media_type, artist.as_deref(), genre.as_deref()).await; + } + Ok(PlayableTarget::Photo { url, title }) => { + let _ = tx.send(AppEvent::ShowPhoto { url, title }); + } + Ok(PlayableTarget::OpenFolder { folder_id, name }) => { + let items = client.fetch_folder_items(&folder_id).await; + let _ = tx.send(AppEvent::FolderLoaded { + folder_id, + name, + items, + }); + } + Err(e) => { + let _ = tx.send(AppEvent::ErrorMessage(e.to_string())); + } + } + }); + } + } + + fn pop_folder(&mut self) { + self.folder_stack.pop(); + if let Some((parent_id, parent_name)) = self.folder_stack.last().cloned() { + self.folder_loading = true; + let client = self.client.clone(); + let tx = self.event_tx.clone(); + tokio::spawn(async move { + let items = client.fetch_folder_items(&parent_id).await; + let _ = tx.send(AppEvent::FolderLoaded { + folder_id: parent_id, + name: parent_name, + items, + }); + }); + } else { + self.folder_items.clear(); + self.selected_grid_idx = 0; + } + } + + fn refresh_youtube_data(&mut self) { + self.youtube_loading = true; + let yt = self.youtube_client.clone(); + let tx = self.event_tx.clone(); + tokio::spawn(async move { + let feed_res = yt.fetch_feed().await; + let _ = tx.send(AppEvent::YouTubeFeedLoaded(feed_res)); + let subs_res = yt.fetch_subscriptions().await; + let _ = tx.send(AppEvent::YouTubeSubscriptionsLoaded(subs_res)); + }); + } + + fn load_channel_videos(&mut self, channel_id: &str, channel_name: &str) { + self.youtube_loading = true; + let yt = self.youtube_client.clone(); + let tx = self.event_tx.clone(); + let cid = channel_id.to_string(); + let cname = channel_name.to_string(); + tokio::spawn(async move { + match yt.fetch_channel_videos(&cid).await { + Ok(videos) => { + let _ = tx.send(AppEvent::YouTubeChannelLoaded { + channel_title: cname, + videos, + }); + } + Err(e) => { + let _ = tx.send(AppEvent::ErrorMessage(e)); + } + } + }); + } + + fn start_youtube_auth(&mut self) { + self.youtube_auth_pending = true; + self.youtube_auth_error = None; + let yt = self.youtube_client.clone(); + let tx = self.event_tx.clone(); + tokio::spawn(async move { + let res = yt.start_device_auth().await; + match &res { + Ok(resp) => { + let device_code = resp.device_code.clone(); + let interval = resp.interval.max(5); + let _ = tx.send(AppEvent::YouTubeAuthStarted(res)); + + // Polling loop in background + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(interval)).await; + let poll_res = yt.poll_device_token(&device_code).await; + match poll_res { + crate::youtube::DevicePollResult::Pending => { + // Still waiting for authorization + } + crate::youtube::DevicePollResult::SlowDown => { + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + } + crate::youtube::DevicePollResult::Success => { + let _ = tx.send(AppEvent::YouTubeAuthCompleted(Ok(()))); + break; + } + crate::youtube::DevicePollResult::Error(err) => { + let _ = tx.send(AppEvent::YouTubeAuthCompleted(Err(err))); + break; + } + } + } + } + Err(e) => { + let _ = tx.send(AppEvent::YouTubeAuthStarted(Err(e.clone()))); + } + } + }); + } + + fn search_youtube(&mut self, query: &str) { + let q = query.trim().to_string(); + if q.is_empty() { + return; + } + if q.starts_with("http://") || q.starts_with("https://") { + let p = self.player.clone(); + tokio::spawn(async move { + let _ = p.play(&q, "YouTube").await; + }); + return; + } + self.youtube_loading = true; + let yt = self.youtube_client.clone(); + let tx = self.event_tx.clone(); + tokio::spawn(async move { + let res = yt.search(&q).await; + let _ = tx.send(AppEvent::YouTubeSearchResults(res)); + }); + } + + fn jump_to_letter(&mut self, letter: char) { + if !self.has_alphabet_bar() { + return; + } + let target = if letter.is_ascii_digit() { '#' } else { letter.to_ascii_uppercase() }; + let new_alpha_idx = INDEX_CHARS.iter().position(|&c| c == target); + + let pos = { + let (_, _, items) = self.get_active_grid_items(); + if items.is_empty() { + return; + } + if target == '#' { + items.iter().position(|a| a.sort_letter() == '#').or(Some(0)) + } else { + items.iter().position(|a| a.sort_letter() == target).or_else(|| { + items.iter().position(|a| { + let sl = a.sort_letter(); + sl != '#' && sl > target + }) + }) + } + }; + + if let Some(idx) = new_alpha_idx { + self.selected_alpha_idx = idx; + } + + if let Some(p) = pos { + self.selected_grid_idx = p; + self.focus_area = FocusArea::Content; + let cols = self.grid_cols.max(1); + let row = p / cols; + let layout = self.get_active_card_layout(); + let card_w = self.last_card_w.max(140.0); + let (card_h, _) = match layout { + CardLayout::Poster => (card_w * 1.5 + 46.0, card_w * 1.5), + CardLayout::Square => (card_w + 46.0, card_w), + CardLayout::LandscapeThumb => (card_w * 0.5625 + 46.0, card_w * 0.5625), + CardLayout::PhotoLarge => (card_w * 0.66 + 46.0, card_w * 0.66), + }; + let row_total = card_h + 16.0; + self.scroll_to_offset = Some((row as f32 * row_total).max(0.0)); + } + } + + fn handle_seek(&mut self, forward: bool) { + let now = Instant::now(); + let step_idx = if let Some(last_time) = self.seek_last_time { + if now.duration_since(last_time).as_millis() < 2200 { + (self.seek_step_level + 1).min(SEEK_STEPS.len() - 1) + } else { + 0 + } + } else { + 0 + }; + self.seek_step_level = step_idx; + self.seek_last_time = Some(now); + + let secs = SEEK_STEPS[step_idx]; + let delta = if forward { secs } else { -secs }; + let player = self.player.clone(); + tokio::spawn(async move { + player.seek(delta).await; + }); + + let icon = if forward { "⏩" } else { "⏪" }; + let sign = if forward { "+" } else { "-" }; + let notice = format!("{} {}{}s", icon, sign, secs as i32); + self.osd_notice = Some((notice, now + Duration::from_millis(1500))); + } + + fn execute_osd_action(&mut self, idx: usize) { + let player = self.player.clone(); + let now = Instant::now(); + match idx { + 0 => { + tokio::spawn(async move { + player.play_pause().await; + }); + } + 1 => { + tokio::spawn(async move { + player.seek(-10.0).await; + }); + self.osd_notice = Some(("⏪ -10s".to_string(), now + Duration::from_millis(1500))); + } + 2 => { + tokio::spawn(async move { + player.seek(10.0).await; + }); + self.osd_notice = Some(("⏩ +10s".to_string(), now + Duration::from_millis(1500))); + } + 3 => { + tokio::spawn(async move { + player.cycle_subtitle().await; + }); + self.osd_notice = Some(("💬 Volgende Ondertitel".to_string(), now + Duration::from_millis(1800))); + } + 4 => { + tokio::spawn(async move { + player.cycle_audio().await; + }); + self.osd_notice = Some(("🔊 Volgende Audiostroom".to_string(), now + Duration::from_millis(1800))); + } + 5 => { + tokio::spawn(async move { + player.volume_down().await; + }); + self.volume_hud_until = Some(now + Duration::from_millis(2500)); + } + 6 => { + tokio::spawn(async move { + player.volume_up().await; + }); + self.volume_hud_until = Some(now + Duration::from_millis(2500)); + } + 7 => { + tokio::spawn(async move { + player.toggle_mute().await; + }); + } + 8 => { + self.view_mode = ViewMode::Browser; + self.playback_osd_open = false; + } + 9 => { + tokio::spawn(async move { + player.stop().await; + }); + self.view_mode = ViewMode::Browser; + self.playback_osd_open = false; + } + _ => {} + } + } + + fn handle_nav_action(&mut self, action: RemoteNavAction) { + // Full photo active: Back or Select closes it + if self.full_photo.is_some() { + if matches!(action, RemoteNavAction::Back | RemoteNavAction::Select) { + self.full_photo = None; + } + return; + } + + // Global actions: Window activations, View toggling, Subtitles, Volume, Stop + match action { + RemoteNavAction::ActivateSection(idx) => { + self.sidebar_index = idx.min(SidebarSection::ALL.len() - 1); + self.focus_area = FocusArea::Content; + self.selected_row = 0; + self.selected_col = 0; + self.selected_grid_idx = 0; + self.scroll_to_offset = Some(0.0); + self.folder_stack.clear(); + self.folder_items.clear(); + self.view_mode = ViewMode::Browser; + self.playback_osd_open = false; + return; + } + RemoteNavAction::TogglePlaybackView => { + let is_playing = if let Ok(st) = self.player.status.try_lock() { + st.state != PlaybackState::Stopped + } else { + false + }; + if is_playing { + self.view_mode = match self.view_mode { + ViewMode::Player => ViewMode::Browser, + ViewMode::Browser => ViewMode::Player, + }; + self.playback_osd_open = false; + } + return; + } + RemoteNavAction::ToggleOSD => { + let is_playing = if let Ok(st) = self.player.status.try_lock() { + st.state != PlaybackState::Stopped + } else { + false + }; + if is_playing { + if self.view_mode != ViewMode::Player { + self.view_mode = ViewMode::Player; + self.playback_osd_open = true; + self.playback_osd_idx = 0; + } else { + self.playback_osd_open = !self.playback_osd_open; + if self.playback_osd_open { + self.playback_osd_idx = 0; + } + } + } + return; + } + RemoteNavAction::CycleSubtitle => { + let p = self.player.clone(); + tokio::spawn(async move { + p.cycle_subtitle().await; + }); + self.osd_notice = Some(("💬 Volgende Ondertitel".to_string(), Instant::now() + Duration::from_millis(1800))); + return; + } + RemoteNavAction::VolumeUp => { + let p = self.player.clone(); + tokio::spawn(async move { + p.volume_up().await; + }); + self.volume_hud_until = Some(Instant::now() + Duration::from_millis(2500)); + return; + } + RemoteNavAction::VolumeDown => { + let p = self.player.clone(); + tokio::spawn(async move { + p.volume_down().await; + }); + self.volume_hud_until = Some(Instant::now() + Duration::from_millis(2500)); + return; + } + RemoteNavAction::Stop => { + let p = self.player.clone(); + tokio::spawn(async move { + p.stop().await; + }); + self.view_mode = ViewMode::Browser; + self.playback_osd_open = false; + return; + } + _ => {} + } + + // Navigation when in Player view + if self.view_mode == ViewMode::Player { + if self.playback_osd_open { + match action { + RemoteNavAction::Up => { + if self.playback_osd_idx > 0 { + self.playback_osd_idx -= 1; + } else { + self.playback_osd_idx = OSD_MENU_ITEMS.len() - 1; + } + } + RemoteNavAction::Down => { + if self.playback_osd_idx + 1 < OSD_MENU_ITEMS.len() { + self.playback_osd_idx += 1; + } else { + self.playback_osd_idx = 0; + } + } + RemoteNavAction::Left => { + if self.playback_osd_idx == 5 || self.playback_osd_idx == 6 { + self.execute_osd_action(5); + } else if self.playback_osd_idx == 1 || self.playback_osd_idx == 2 { + self.execute_osd_action(1); + } + } + RemoteNavAction::Right => { + if self.playback_osd_idx == 5 || self.playback_osd_idx == 6 { + self.execute_osd_action(6); + } else if self.playback_osd_idx == 1 || self.playback_osd_idx == 2 { + self.execute_osd_action(2); + } + } + RemoteNavAction::Select => { + self.execute_osd_action(self.playback_osd_idx); + } + RemoteNavAction::Back => { + self.playback_osd_open = false; + } + _ => {} + } + } else { + match action { + RemoteNavAction::Left => { + self.handle_seek(false); + } + RemoteNavAction::Right => { + self.handle_seek(true); + } + RemoteNavAction::Up => { + let p = self.player.clone(); + tokio::spawn(async move { + p.volume_up().await; + }); + self.volume_hud_until = Some(Instant::now() + Duration::from_millis(2500)); + } + RemoteNavAction::Down => { + let p = self.player.clone(); + tokio::spawn(async move { + p.volume_down().await; + }); + self.volume_hud_until = Some(Instant::now() + Duration::from_millis(2500)); + } + RemoteNavAction::Select => { + self.playback_osd_open = true; + self.playback_osd_idx = 0; + } + RemoteNavAction::Back => { + let p = self.player.clone(); + tokio::spawn(async move { + p.stop().await; + }); + self.view_mode = ViewMode::Browser; + self.playback_osd_open = false; + } + _ => {} + } + } + return; + } + + match self.focus_area { + FocusArea::Sidebar => match action { + RemoteNavAction::Up => { + if self.sidebar_index > 0 { + self.sidebar_index -= 1; + self.selected_row = 0; + self.selected_col = 0; + self.selected_grid_idx = 0; + self.scroll_to_offset = Some(0.0); + self.folder_stack.clear(); + self.folder_items.clear(); + } + } + RemoteNavAction::Down => { + if self.sidebar_index + 1 < SidebarSection::ALL.len() { + self.sidebar_index += 1; + self.selected_row = 0; + self.selected_col = 0; + self.selected_grid_idx = 0; + self.scroll_to_offset = Some(0.0); + self.folder_stack.clear(); + self.folder_items.clear(); + } + } + RemoteNavAction::Right | RemoteNavAction::Select => { + self.focus_area = FocusArea::Content; + } + RemoteNavAction::Back => {} + _ => {} + }, + FocusArea::Content => { + // Dashboard Section (Horizontal rows) + if self.current_section() == SidebarSection::Dashboard && self.folder_stack.is_empty() { + let rows = self.get_dashboard_rows(); + let row_lens: Vec = rows.iter().map(|(_, _, items)| items.len()).collect(); + + match action { + RemoteNavAction::Up => { + if self.selected_row > 0 { + self.selected_row -= 1; + let row_len = row_lens[self.selected_row]; + if self.selected_col >= row_len { + self.selected_col = row_len.saturating_sub(1); + } + } + } + RemoteNavAction::Down => { + if self.selected_row + 1 < row_lens.len() { + self.selected_row += 1; + let row_len = row_lens[self.selected_row]; + if self.selected_col >= row_len { + self.selected_col = row_len.saturating_sub(1); + } + } + } + RemoteNavAction::Left => { + if self.selected_col > 0 { + self.selected_col -= 1; + } else { + self.focus_area = FocusArea::Sidebar; + } + } + RemoteNavAction::Right => { + if !row_lens.is_empty() { + let row_len = row_lens[self.selected_row]; + if self.selected_col + 1 < row_len { + self.selected_col += 1; + } + } + } + RemoteNavAction::Select => { + self.play_selected(); + } + RemoteNavAction::Back => { + self.focus_area = FocusArea::Sidebar; + } + } + } else if self.is_in_album_view() { + // Album Detail View (Track table navigation: 0 is Play All button, 1..=tracks.len() are tracks) + let total_rows = self.folder_items.len() + 1; // 0 = play all, 1..=N = tracks + match action { + RemoteNavAction::Up => { + if self.selected_grid_idx > 0 { + self.selected_grid_idx -= 1; + } + } + RemoteNavAction::Down => { + if self.selected_grid_idx + 1 < total_rows { + self.selected_grid_idx += 1; + } + } + RemoteNavAction::Left => { + self.focus_area = FocusArea::Sidebar; + } + RemoteNavAction::Right => {} + RemoteNavAction::Select => { + if self.selected_grid_idx == 0 { + // Play first track or all tracks + if let Some(first_track) = self.folder_items.first() { + let client = self.client.clone(); + let player = self.player.clone(); + let item = first_track.clone(); + tokio::spawn(async move { + if let Ok(PlayableTarget::Stream { url, title, media_type, artist, genre }) = client.resolve_playable_item(&item).await { + let _ = player.play_media(&url, &title, &media_type, artist.as_deref(), genre.as_deref()).await; + } + }); + } + } else { + let track_idx = self.selected_grid_idx - 1; + if let Some(track) = self.folder_items.get(track_idx) { + let client = self.client.clone(); + let player = self.player.clone(); + let item = track.clone(); + tokio::spawn(async move { + if let Ok(PlayableTarget::Stream { url, title, media_type, artist, genre }) = client.resolve_playable_item(&item).await { + let _ = player.play_media(&url, &title, &media_type, artist.as_deref(), genre.as_deref()).await; + } + }); + } + } + } + RemoteNavAction::Back => { + self.pop_folder(); + } + } + } else { + // Library Grid Section or Normal Folder View + let (_, _, items) = self.get_active_grid_items(); + let total = items.len(); + let cols = self.grid_cols.max(1); + + match action { + RemoteNavAction::Up => { + if self.selected_grid_idx >= cols { + self.selected_grid_idx -= cols; + } + } + RemoteNavAction::Down => { + if self.selected_grid_idx + cols < total { + self.selected_grid_idx += cols; + } else if total > 0 { + self.selected_grid_idx = total - 1; + } + } + RemoteNavAction::Left => { + if self.selected_grid_idx % cols == 0 { + self.focus_area = FocusArea::Sidebar; + } else if self.selected_grid_idx > 0 { + self.selected_grid_idx -= 1; + } + } + RemoteNavAction::Right => { + if (self.selected_grid_idx + 1) % cols == 0 && self.has_alphabet_bar() { + self.focus_area = FocusArea::Alphabet; + } else if self.selected_grid_idx + 1 < total { + self.selected_grid_idx += 1; + } + } + RemoteNavAction::Select => { + self.play_selected(); + } + RemoteNavAction::Back => { + if self.current_section() == SidebarSection::YouTube && self.youtube_channel_videos.is_some() { + self.youtube_channel_videos = None; + self.selected_grid_idx = 0; + } else if !self.folder_stack.is_empty() { + self.pop_folder(); + } else { + self.focus_area = FocusArea::Sidebar; + } + } + } + } + } + FocusArea::Alphabet => match action { + RemoteNavAction::Up => { + if self.selected_alpha_idx > 0 { + self.selected_alpha_idx -= 1; + let ch = INDEX_CHARS[self.selected_alpha_idx]; + self.jump_to_letter(ch); + self.focus_area = FocusArea::Alphabet; + } + } + RemoteNavAction::Down => { + if self.selected_alpha_idx + 1 < INDEX_CHARS.len() { + self.selected_alpha_idx += 1; + let ch = INDEX_CHARS[self.selected_alpha_idx]; + self.jump_to_letter(ch); + self.focus_area = FocusArea::Alphabet; + } + } + RemoteNavAction::Left => { + self.focus_area = FocusArea::Content; + } + RemoteNavAction::Right => {} + RemoteNavAction::Select => { + let ch = INDEX_CHARS[self.selected_alpha_idx]; + self.jump_to_letter(ch); + self.focus_area = FocusArea::Content; + } + RemoteNavAction::Back => { + self.focus_area = FocusArea::Content; + } + }, + } + } +} + +impl eframe::App for KioskApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Drain incoming background events + while let Ok(event) = self.event_rx.try_recv() { + match event { + AppEvent::LibraryLoaded(res) => { + match res { + Ok(lib) => { + self.library = lib; + self.is_loading = false; + } + Err(e) => { + self.error_msg = Some(e); + self.is_loading = false; + } + } + ctx.request_repaint(); + } + AppEvent::FolderLoaded { + folder_id, + name, + items, + } => { + self.folder_loading = false; + if self.folder_stack.last().map(|(id, _)| id) != Some(&folder_id) { + self.folder_stack.push((folder_id, name)); + } + self.folder_items = items; + self.selected_grid_idx = 0; + ctx.request_repaint(); + } + AppEvent::ShowPhoto { url, title } => { + self.full_photo = Some((url, title)); + ctx.request_repaint(); + } + AppEvent::ErrorMessage(e) => { + self.player_error_msg = Some(e); + ctx.request_repaint(); + } + AppEvent::YouTubeLoggedInState(logged_in) => { + self.youtube_is_logged_in = logged_in; + ctx.request_repaint(); + } + AppEvent::YouTubeAuthStarted(res) => { + match res { + Ok(resp) => { + self.youtube_auth_code = Some(resp); + self.youtube_auth_error = None; + } + Err(e) => { + self.youtube_auth_error = Some(e); + self.youtube_auth_pending = false; + } + } + ctx.request_repaint(); + } + AppEvent::YouTubeAuthCompleted(res) => { + self.youtube_auth_pending = false; + self.youtube_auth_code = None; + match res { + Ok(()) => { + self.youtube_is_logged_in = true; + self.refresh_youtube_data(); + } + Err(e) => { + self.youtube_auth_error = Some(e); + } + } + ctx.request_repaint(); + } + AppEvent::YouTubeFeedLoaded(res) => { + self.youtube_loading = false; + match res { + Ok(feed) => { + self.youtube_feed = feed; + } + Err(e) => { + tracing::warn!("Failed to load YouTube feed: {}", e); + } + } + ctx.request_repaint(); + } + AppEvent::YouTubeSubscriptionsLoaded(res) => { + self.youtube_loading = false; + match res { + Ok(subs) => { + self.youtube_subscriptions = subs; + } + Err(e) => { + tracing::warn!("Failed to load YouTube subscriptions: {}", e); + } + } + ctx.request_repaint(); + } + AppEvent::YouTubeChannelLoaded { channel_title, videos } => { + self.youtube_loading = false; + self.youtube_channel_videos = Some((channel_title, videos)); + self.selected_grid_idx = 0; + ctx.request_repaint(); + } + AppEvent::YouTubeSearchResults(res) => { + self.youtube_loading = false; + match res { + Ok(results) => { + self.youtube_search_results = results; + self.selected_grid_idx = 0; + } + Err(e) => { + self.player_error_msg = Some(e); + } + } + ctx.request_repaint(); + } + } + } + + // Check player status + // Check player status + let (is_playing, current_vol, is_muted) = if let Ok(st) = self.player.status.try_lock() { + if st.error_message != self.player_error_msg { + self.player_error_msg = st.error_message.clone(); + } + (st.state != PlaybackState::Stopped, st.volume, st.is_muted) + } else { + (false, self.last_seen_volume, false) + }; + + if (current_vol - self.last_seen_volume).abs() > 0.5 { + self.last_seen_volume = current_vol; + self.volume_hud_until = Some(Instant::now() + Duration::from_millis(2500)); + } + + if is_playing && !self.was_playing { + self.view_mode = ViewMode::Player; + self.playback_osd_open = false; + } else if !is_playing && self.was_playing { + self.view_mode = ViewMode::Browser; + self.playback_osd_open = false; + } + self.was_playing = is_playing; + + // Yatse remote actions + while let Ok(action) = self.nav_rx.try_recv() { + self.handle_nav_action(action); + ctx.request_repaint(); + } + + // Keyboard navigation + let mut nav_action: Option = None; + let mut char_pressed: Option = None; + + ctx.input(|i| { + if i.key_pressed(egui::Key::ArrowUp) { + nav_action = Some(RemoteNavAction::Up); + } else if i.key_pressed(egui::Key::ArrowDown) { + nav_action = Some(RemoteNavAction::Down); + } else if i.key_pressed(egui::Key::ArrowLeft) { + nav_action = Some(RemoteNavAction::Left); + } else if i.key_pressed(egui::Key::ArrowRight) { + nav_action = Some(RemoteNavAction::Right); + } else if i.key_pressed(egui::Key::Enter) { + nav_action = Some(RemoteNavAction::Select); + } else if i.key_pressed(egui::Key::Escape) + || i.key_pressed(egui::Key::Backspace) + || i.key_pressed(egui::Key::Q) + { + nav_action = Some(RemoteNavAction::Back); + } else if i.key_pressed(egui::Key::M) || i.key_pressed(egui::Key::O) { + if is_playing { + nav_action = Some(RemoteNavAction::ToggleOSD); + } + } else if i.key_pressed(egui::Key::S) { + if is_playing { + nav_action = Some(RemoteNavAction::CycleSubtitle); + } + } else if i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals) { + nav_action = Some(RemoteNavAction::VolumeUp); + } else if i.key_pressed(egui::Key::Minus) { + nav_action = Some(RemoteNavAction::VolumeDown); + } else if i.key_pressed(egui::Key::Space) { + if is_playing && self.view_mode == ViewMode::Player && !self.playback_osd_open { + let p = self.player.clone(); + tokio::spawn(async move { + p.play_pause().await; + }); + } else { + nav_action = Some(RemoteNavAction::Select); + } + } else if i.key_pressed(egui::Key::Tab) { + // Tab cycles focus: Sidebar -> Content -> Alphabet (if available) -> Sidebar + self.focus_area = match self.focus_area { + FocusArea::Sidebar => FocusArea::Content, + FocusArea::Content => { + if self.has_alphabet_bar() { + FocusArea::Alphabet + } else { + FocusArea::Sidebar + } + } + FocusArea::Alphabet => FocusArea::Sidebar, + }; + } + + // Alphanumeric key jump for libraries with alphabet index bar + for ev in &i.events { + if let egui::Event::Text(t) = ev { + if let Some(c) = t.chars().next() { + if c.is_alphabetic() || c.is_numeric() || c == '#' { + char_pressed = Some(c); + } + } + } + } + }); + + if let Some(act) = nav_action { + self.handle_nav_action(act); + } + if let Some(c) = char_pressed { + let target_char = if c.is_numeric() { '#' } else { c.to_ascii_uppercase() }; + self.jump_to_letter(target_char); + } + + // Dark modern TV theme + let mut visuals = egui::Visuals::dark(); + visuals.override_text_color = Some(Color32::from_rgb(240, 244, 248)); + visuals.panel_fill = Color32::from_rgb(12, 16, 22); + ctx.set_visuals(visuals); + + // --- FULLSCREEN PHOTO VIEWER --- + let mut close_photo = false; + if let Some((photo_url, photo_title)) = self.full_photo.clone() { + egui::CentralPanel::default() + .frame(egui::Frame::none().fill(Color32::BLACK)) + .show(ctx, |ui| { + ui.horizontal(|ui| { + if ui + .button( + egui::RichText::new("⬅ Sluiten (Esc / Back)") + .font(FontId::proportional(15.0)) + .color(Color32::WHITE), + ) + .clicked() + { + close_photo = true; + } + ui.add_space(16.0); + ui.label( + egui::RichText::new(&photo_title) + .font(FontId::proportional(20.0)) + .strong() + .color(Color32::WHITE), + ); + }); + ui.add_space(10.0); + + let avail = ui.available_size(); + let (rect, resp) = ui.allocate_exact_size(avail, egui::Sense::click()); + let img = egui::Image::new(&photo_url) + .fit_to_exact_size(avail) + .rounding(Rounding::ZERO); + img.paint_at(ui, rect); + + if resp.clicked() { + close_photo = true; + } + }); + if close_photo { + self.full_photo = None; + } + return; + } + + // --- IN-WINDOW PLAYBACK OVERLAY --- + if is_playing && self.view_mode == ViewMode::Player { + egui::CentralPanel::default() + .frame(egui::Frame::none().fill(Color32::from_rgba_premultiplied(0, 0, 0, 160))) + .show(ctx, |ui| { + let (title, time_pos, duration) = if let Ok(st) = self.player.status.try_lock() { + let t = if !st.stream_title.is_empty() { + st.stream_title.clone() + } else if !st.title.is_empty() { + st.title.clone() + } else { + "Media afspelen".to_string() + }; + (t, st.time_pos, st.duration) + } else { + ("Media afspelen".to_string(), 0.0, 0.0) + }; + + // TOP BAR + ui.add_space(16.0); + ui.horizontal(|ui| { + ui.add_space(20.0); + if ui + .button( + egui::RichText::new("⬅ Bibliotheek (btn=title / Esc)") + .font(FontId::proportional(14.0)) + .color(Color32::WHITE), + ) + .clicked() + { + self.view_mode = ViewMode::Browser; + self.playback_osd_open = false; + } + ui.add_space(16.0); + ui.label( + egui::RichText::new(&title) + .font(FontId::proportional(18.0)) + .strong() + .color(Color32::WHITE), + ); + + // Top-right badges: Mute indicator & Volume HUD + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(20.0); + if is_muted { + let mute_rect = ui.allocate_space(Vec2::new(115.0, 30.0)).1; + ui.painter().rect_filled(mute_rect, Rounding::same(6.0), Color32::from_rgb(200, 35, 35)); + ui.painter().text( + mute_rect.center(), + egui::Align2::CENTER_CENTER, + "🔇 GEDEMPT", + FontId::proportional(13.0), + Color32::WHITE, + ); + ui.add_space(10.0); + } + + if let Some(hud_until) = self.volume_hud_until { + if Instant::now() < hud_until { + let vol_text = format!("🔊 {}%", current_vol as i32); + let vol_rect = ui.allocate_space(Vec2::new(95.0, 30.0)).1; + ui.painter().rect_filled( + vol_rect, + Rounding::same(6.0), + Color32::from_rgba_premultiplied(30, 40, 55, 230), + ); + ui.painter().rect_stroke( + vol_rect, + Rounding::same(6.0), + Stroke::new(1.0, Color32::from_rgb(0, 164, 220)), + ); + ui.painter().text( + vol_rect.center(), + egui::Align2::CENTER_CENTER, + vol_text, + FontId::proportional(13.0), + Color32::WHITE, + ); + ui.add_space(10.0); + } + } + }); + }); + + // CENTER FLOATING OSD / NOTICES + let avail_h = ui.available_height(); + let bottom_bar_h = 130.0; + let center_area_h = (avail_h - bottom_bar_h).max(100.0); + + ui.allocate_ui(Vec2::new(ui.available_width(), center_area_h), |ui| { + // Floating seek/action notice + if let Some((ref notice, notice_until)) = self.osd_notice { + if Instant::now() < notice_until { + let center_pos = Pos2::new(ui.available_width() * 0.5, ui.available_height() * 0.35); + let notice_rect = Rect::from_center_size(center_pos, Vec2::new(260.0, 52.0)); + ui.painter().rect_filled( + notice_rect, + Rounding::same(12.0), + Color32::from_rgba_premultiplied(15, 20, 30, 240), + ); + ui.painter().rect_stroke( + notice_rect, + Rounding::same(12.0), + Stroke::new(2.0, Color32::from_rgb(0, 184, 245)), + ); + ui.painter().text( + notice_rect.center(), + egui::Align2::CENTER_CENTER, + notice, + FontId::proportional(19.0), + Color32::WHITE, + ); + } + } + + // Playback OSD Menu + if self.playback_osd_open { + let menu_w = 460.0; + let menu_h = 420.0; + let center_pos = Pos2::new(ui.available_width() * 0.5, ui.available_height() * 0.5); + let menu_rect = Rect::from_center_size(center_pos, Vec2::new(menu_w, menu_h)); + + ui.painter().rect_filled( + menu_rect, + Rounding::same(14.0), + Color32::from_rgba_premultiplied(16, 22, 32, 245), + ); + ui.painter().rect_stroke( + menu_rect, + Rounding::same(14.0), + Stroke::new(1.5, Color32::from_rgb(0, 184, 245)), + ); + + ui.allocate_ui_at_rect(menu_rect.shrink(18.0), |ui| { + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new("⚙ AFSPEELMENU") + .font(FontId::proportional(19.0)) + .strong() + .color(Color32::from_rgb(0, 200, 255)), + ); + ui.label( + egui::RichText::new("Navigeer met ↑ / ↓ / ← / →, bevestig met Enter, sluit met Esc") + .font(FontId::proportional(11.5)) + .color(Color32::from_rgb(160, 175, 195)), + ); + ui.add_space(8.0); + + for (idx, label) in OSD_MENU_ITEMS.iter().enumerate() { + let is_sel = idx == self.playback_osd_idx; + let (opt_rect, opt_resp) = ui.allocate_exact_size( + Vec2::new(ui.available_width(), 31.0), + egui::Sense::click(), + ); + if opt_resp.clicked() { + self.playback_osd_idx = idx; + self.execute_osd_action(idx); + } + let bg_col = if is_sel { + Color32::from_rgb(0, 150, 225) + } else if opt_resp.hovered() { + Color32::from_rgba_premultiplied(40, 55, 75, 200) + } else { + Color32::from_rgba_premultiplied(25, 34, 48, 180) + }; + ui.painter().rect_filled(opt_rect, Rounding::same(6.0), bg_col); + ui.painter().text( + Pos2::new(opt_rect.min.x + 14.0, opt_rect.center().y), + egui::Align2::LEFT_CENTER, + *label, + FontId::proportional(13.5), + if is_sel { Color32::WHITE } else { Color32::from_rgb(220, 230, 240) }, + ); + ui.add_space(2.0); + } + }); + }); + } + }); + + // BOTTOM BAR (OSD Card) + ui.vertical_centered(|ui| { + let osd_rect = ui.allocate_space(Vec2::new(ui.available_width() * 0.88, 96.0)).1; + ui.painter().rect_filled( + osd_rect, + Rounding::same(12.0), + Color32::from_rgba_premultiplied(20, 26, 36, 235), + ); + ui.painter().rect_stroke( + osd_rect, + Rounding::same(12.0), + Stroke::new(1.0, Color32::from_rgba_premultiplied(255, 255, 255, 30)), + ); + + let inner = osd_rect.shrink(14.0); + ui.painter().text( + Pos2::new(inner.min.x, inner.min.y), + egui::Align2::LEFT_TOP, + &title, + FontId::proportional(20.0), + Color32::WHITE, + ); + + // Progress Bar + let bar_rect = Rect::from_min_size( + Pos2::new(inner.min.x, inner.min.y + 32.0), + Vec2::new(inner.width(), 8.0), + ); + let pct = if duration > 0.0 { + (time_pos / duration).clamp(0.0, 1.0) as f32 + } else { + 0.0 + }; + let fill_rect = Rect::from_min_size(bar_rect.min, Vec2::new(bar_rect.width() * pct, 8.0)); + ui.painter().rect_filled( + bar_rect, + Rounding::same(4.0), + Color32::from_rgba_premultiplied(255, 255, 255, 30), + ); + ui.painter().rect_filled(fill_rect, Rounding::same(4.0), Color32::from_rgb(0, 184, 245)); + + // Time stamps + let time_str = format!("{} / {}", format_sec(time_pos), format_sec(duration)); + ui.painter().text( + Pos2::new(inner.max.x, inner.min.y + 44.0), + egui::Align2::RIGHT_TOP, + time_str, + FontId::proportional(13.0), + Color32::from_rgb(180, 190, 205), + ); + + // Clickable Stop button + let stop_rect = Rect::from_min_size( + Pos2::new(inner.min.x, inner.min.y + 44.0), + Vec2::new(115.0, 28.0), + ); + let stop_resp = ui.interact(stop_rect, ui.id().with("stop_btn"), egui::Sense::click()); + let stop_bg = if stop_resp.hovered() { + Color32::from_rgb(220, 50, 50) + } else { + Color32::from_rgb(180, 40, 40) + }; + ui.painter().rect_filled(stop_rect, Rounding::same(6.0), stop_bg); + ui.painter().text( + stop_rect.center(), + egui::Align2::CENTER_CENTER, + "⏹ Stop (Esc)", + FontId::proportional(12.5), + Color32::WHITE, + ); + if stop_resp.clicked() { + let p = self.player.clone(); + tokio::spawn(async move { + p.stop().await; + }); + self.view_mode = ViewMode::Browser; + self.playback_osd_open = false; + } + + // Clickable Pause button + let pause_rect = Rect::from_min_size( + Pos2::new(inner.min.x + 125.0, inner.min.y + 44.0), + Vec2::new(125.0, 28.0), + ); + let pause_resp = ui.interact(pause_rect, ui.id().with("pause_btn"), egui::Sense::click()); + let pause_bg = if pause_resp.hovered() { + Color32::from_rgb(0, 190, 250) + } else { + Color32::from_rgb(0, 164, 220) + }; + ui.painter().rect_filled(pause_rect, Rounding::same(6.0), pause_bg); + ui.painter().text( + pause_rect.center(), + egui::Align2::CENTER_CENTER, + "⏸ Pauze (Spatie)", + FontId::proportional(12.5), + Color32::WHITE, + ); + if pause_resp.clicked() { + let p = self.player.clone(); + tokio::spawn(async move { + p.play_pause().await; + }); + } + + // Clickable Afspeelmenu button + let menu_btn_rect = Rect::from_min_size( + Pos2::new(inner.min.x + 260.0, inner.min.y + 44.0), + Vec2::new(135.0, 28.0), + ); + let menu_btn_resp = ui.interact(menu_btn_rect, ui.id().with("osd_btn"), egui::Sense::click()); + let menu_btn_bg = if menu_btn_resp.hovered() || self.playback_osd_open { + Color32::from_rgb(60, 80, 110) + } else { + Color32::from_rgb(38, 50, 70) + }; + ui.painter().rect_filled(menu_btn_rect, Rounding::same(6.0), menu_btn_bg); + ui.painter().text( + menu_btn_rect.center(), + egui::Align2::CENTER_CENTER, + "⚙ Afspeelmenu (M)", + FontId::proportional(12.5), + Color32::WHITE, + ); + if menu_btn_resp.clicked() { + self.playback_osd_open = !self.playback_osd_open; + } + }); + }); + return; + } + + // --- MAIN LAYOUT: KODI SIDEBAR (Left) + CONTENT (Right) --- + egui::SidePanel::left("kodi_sidebar") + .exact_width(220.0) + .frame( + egui::Frame::none() + .fill(Color32::from_rgb(16, 20, 28)) + .inner_margin(egui::Margin::symmetric(14.0, 24.0)), + ) + .show(ctx, |ui| { + ui.label( + egui::RichText::new("JELLYFIN") + .font(FontId::proportional(22.0)) + .strong() + .color(Color32::from_rgb(0, 184, 245)), + ); + ui.add_space(20.0); + + let is_sidebar_focused = self.focus_area == FocusArea::Sidebar; + + for (idx, section) in SidebarSection::ALL.iter().enumerate() { + let is_active = self.sidebar_index == idx; + let (btn_rect, btn_resp) = + ui.allocate_exact_size(Vec2::new(ui.available_width(), 42.0), egui::Sense::click()); + + if btn_resp.clicked() { + self.sidebar_index = idx; + self.selected_row = 0; + self.selected_col = 0; + self.selected_grid_idx = 0; + self.scroll_to_offset = Some(0.0); + self.folder_stack.clear(); + self.folder_items.clear(); + self.focus_area = FocusArea::Content; + } + + if is_active { + let fill_col = if is_sidebar_focused { + Color32::from_rgb(0, 164, 220) + } else { + Color32::from_rgba_premultiplied(0, 164, 220, 70) + }; + ui.painter().rect_filled(btn_rect, Rounding::same(8.0), fill_col); + } + + let text_col = if is_active && is_sidebar_focused { + Color32::WHITE + } else if is_active { + Color32::from_rgb(0, 200, 255) + } else { + Color32::from_rgb(180, 190, 205) + }; + + ui.painter().text( + Pos2::new(btn_rect.min.x + 14.0, btn_rect.center().y), + egui::Align2::LEFT_CENTER, + section.label(), + FontId::proportional(15.0), + text_col, + ); + + ui.add_space(6.0); + } + }); + + // --- CONTENT PANEL (Right) --- + egui::CentralPanel::default() + .frame( + egui::Frame::none() + .fill(Color32::from_rgb(10, 14, 20)) + .inner_margin(egui::Margin::symmetric(28.0, 20.0)), + ) + .show(ctx, |ui| { + if self.is_loading { + ui.centered_and_justified(|ui| { + ui.vertical_centered(|ui| { + ui.spinner(); + ui.add_space(16.0); + ui.label( + egui::RichText::new("Bibliotheken synchroniseren...") + .font(FontId::proportional(22.0)) + .color(Color32::from_rgb(0, 174, 240)), + ); + }); + }); + return; + } + + if let Some(ref err) = self.error_msg { + ui.centered_and_justified(|ui| { + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new("Fout bij laden van Jellyfin") + .font(FontId::proportional(24.0)) + .color(Color32::from_rgb(239, 68, 68)), + ); + ui.add_space(8.0); + ui.label(err); + }); + }); + return; + } + + // Error banner + if let Some(ref p_err) = self.player_error_msg { + let err_rect = ui.allocate_space(Vec2::new(ui.available_width(), 40.0)).1; + ui.painter() + .rect_filled(err_rect, Rounding::same(8.0), Color32::from_rgb(180, 40, 40)); + ui.painter().text( + err_rect.center(), + egui::Align2::CENTER_CENTER, + format!("⚠️ {}", p_err), + FontId::proportional(15.0), + Color32::WHITE, + ); + ui.add_space(10.0); + } + + // Background playback bar + if is_playing { + let (now_title, time_pos, duration, is_muted) = if let Ok(st) = self.player.status.try_lock() { + let t = if !st.stream_title.is_empty() { + st.stream_title.clone() + } else if !st.title.is_empty() { + st.title.clone() + } else { + "Media".to_string() + }; + (t, st.time_pos, st.duration, st.is_muted) + } else { + ("Media".to_string(), 0.0, 0.0, false) + }; + + let banner_rect = ui.allocate_space(Vec2::new(ui.available_width(), 44.0)).1; + ui.painter().rect_filled(banner_rect, Rounding::same(8.0), Color32::from_rgb(18, 28, 44)); + ui.painter().rect_stroke( + banner_rect, + Rounding::same(8.0), + Stroke::new(1.0, Color32::from_rgb(0, 164, 220)), + ); + + ui.allocate_ui_at_rect(banner_rect.shrink(8.0), |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("▶ Nu aan het afspelen:") + .font(FontId::proportional(14.5)) + .color(Color32::from_rgb(0, 200, 255)) + .strong(), + ); + ui.label( + egui::RichText::new(&now_title) + .font(FontId::proportional(14.5)) + .color(Color32::WHITE) + .strong(), + ); + ui.label( + egui::RichText::new(format!("({} / {})", format_sec(time_pos), format_sec(duration))) + .font(FontId::proportional(13.0)) + .color(Color32::from_rgb(170, 185, 205)), + ); + if is_muted { + ui.label( + egui::RichText::new("🔇 GEDEMPT") + .font(FontId::proportional(12.0)) + .color(Color32::from_rgb(250, 100, 100)) + .strong(), + ); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .button( + egui::RichText::new("⏹ Stop") + .font(FontId::proportional(13.0)) + .color(Color32::from_rgb(255, 100, 100)), + ) + .clicked() + { + let p = self.player.clone(); + tokio::spawn(async move { + p.stop().await; + }); + } + if ui + .button( + egui::RichText::new("📺 Afspeelscherm") + .font(FontId::proportional(13.0)) + .color(Color32::WHITE), + ) + .clicked() + { + self.view_mode = ViewMode::Player; + } + }); + }); + }); + ui.add_space(10.0); + } + + // Breadcrumb for opened folder + if !self.folder_stack.is_empty() { + ui.horizontal(|ui| { + if ui + .button( + egui::RichText::new("⬅ Terug (Esc / Back)") + .font(FontId::proportional(14.0)) + .color(Color32::WHITE), + ) + .clicked() + { + self.pop_folder(); + } + ui.add_space(12.0); + let folder_name = self.folder_stack.last().map(|(_, n)| n.as_str()).unwrap_or(""); + ui.label( + egui::RichText::new(format!("📂 {}", folder_name)) + .font(FontId::proportional(20.0)) + .strong() + .color(Color32::from_rgb(0, 184, 245)), + ); + if self.folder_loading { + ui.add_space(12.0); + ui.spinner(); + } + }); + ui.add_space(12.0); + } + + // Music Toggle Bar (Artiesten / Albums) + if self.current_section() == SidebarSection::Music && self.folder_stack.is_empty() { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("Weergave:") + .font(FontId::proportional(16.0)) + .color(Color32::from_rgb(150, 160, 175)), + ); + ui.add_space(8.0); + + let is_artists = self.music_mode == MusicMode::Artists; + if ui.selectable_label(is_artists, "🎤 Artiesten (A-Z)").clicked() { + self.music_mode = MusicMode::Artists; + self.selected_grid_idx = 0; + } + if ui.selectable_label(!is_artists, "💿 Albums").clicked() { + self.music_mode = MusicMode::Albums; + self.selected_grid_idx = 0; + } + ui.add_space(16.0); + ui.label( + egui::RichText::new("• Druk op een letter op je toetsenbord om te springen, Tab om te wisselen") + .font(FontId::proportional(13.0)) + .color(Color32::from_rgb(120, 130, 145)), + ); + }); + ui.add_space(12.0); + } + + // YouTube View Header & Controls + if self.current_section() == SidebarSection::YouTube { + let channel_title_opt = self.youtube_channel_videos.as_ref().map(|(t, _)| t.clone()); + ui.horizontal(|ui| { + if let Some(title) = channel_title_opt { + if ui.button("⬅ Terug naar kanalen").clicked() { + self.youtube_channel_videos = None; + self.selected_grid_idx = 0; + } + ui.add_space(8.0); + ui.heading(format!("📺 Uploads van {}", title)); + } else { + let is_feed = self.youtube_tab == YouTubeTab::Feed; + let is_subs = self.youtube_tab == YouTubeTab::Subscriptions; + let is_search = self.youtube_tab == YouTubeTab::Search; + + if ui.selectable_label(is_feed, "📺 Nieuwste Video's").clicked() { + self.youtube_tab = YouTubeTab::Feed; + self.selected_grid_idx = 0; + if self.youtube_feed.is_empty() && self.youtube_is_logged_in { + self.refresh_youtube_data(); + } + } + if ui.selectable_label(is_subs, "🔔 Mijn Kanalen").clicked() { + self.youtube_tab = YouTubeTab::Subscriptions; + self.selected_grid_idx = 0; + if self.youtube_subscriptions.is_empty() && self.youtube_is_logged_in { + self.refresh_youtube_data(); + } + } + if ui.selectable_label(is_search, "🔍 Zoeken / URL").clicked() { + self.youtube_tab = YouTubeTab::Search; + self.selected_grid_idx = 0; + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if self.youtube_is_logged_in { + if ui.button("🚪 Uitloggen").clicked() { + let yt = self.youtube_client.clone(); + tokio::spawn(async move { + yt.logout().await; + }); + self.youtube_is_logged_in = false; + self.youtube_feed.clear(); + self.youtube_subscriptions.clear(); + self.youtube_channel_videos = None; + } + if ui.button("🔄 Verversen").clicked() { + self.refresh_youtube_data(); + } + } else if self.youtube_client.is_configured() { + if self.youtube_auth_code.is_none() { + if ui.button("🔐 Koppel YouTube Account").clicked() { + self.start_youtube_auth(); + } + } else { + if ui.button("❌ Annuleer koppelen").clicked() { + self.youtube_auth_code = None; + self.youtube_auth_pending = false; + } + } + } + }); + } + }); + + ui.add_space(8.0); + + // Search / direct link bar + if self.youtube_channel_videos.is_none() && self.youtube_tab == YouTubeTab::Search { + ui.horizontal(|ui| { + ui.label("Zoekterm of YouTube URL:"); + let resp = ui.text_edit_singleline(&mut self.youtube_query); + let enter_pressed = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if (ui.button("🔍 Zoeken / Afspelen").clicked() || enter_pressed) && !self.youtube_query.is_empty() { + self.search_youtube(&self.youtube_query.clone()); + } + }); + ui.add_space(8.0); + } + + // Auth banner / instructions + if let Some(ref code_info) = self.youtube_auth_code { + ui.group(|ui| { + ui.vertical(|ui| { + ui.heading("📱 YouTube Account Koppelen"); + ui.add_space(4.0); + ui.label(egui::RichText::new("1. Ga op je telefoon of computer naar:").size(15.0)); + ui.label( + egui::RichText::new(&code_info.verification_url) + .color(Color32::from_rgb(0, 190, 250)) + .size(16.0) + .strong(), + ); + ui.add_space(6.0); + ui.label(egui::RichText::new("2. Voer deze koppelcode in:").size(15.0)); + ui.label( + egui::RichText::new(&code_info.user_code) + .color(Color32::from_rgb(255, 215, 0)) + .size(28.0) + .strong(), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new("⏳ Wachten op autorisatie in je browser... Zodra je toestemming geeft, laadt de kiosk direct je abonnementen!") + .italics() + .color(Color32::LIGHT_GRAY), + ); + }); + }); + ui.add_space(10.0); + } else if !self.youtube_client.is_configured() { + ui.group(|ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("ℹ️ Om in te loggen op YouTube, vul 'client_id' in onder 'youtube:' in config.yaml.") + .color(Color32::from_rgb(170, 185, 205)), + ); + }); + }); + ui.add_space(8.0); + } else if !self.youtube_is_logged_in { + ui.group(|ui| { + ui.horizontal(|ui| { + ui.label("Nog niet ingelogd op YouTube. Klik hierboven op '🔐 Koppel YouTube Account' om te verbinden."); + }); + }); + ui.add_space(8.0); + } + + if self.youtube_loading { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("YouTube gegevens laden..."); + }); + ui.add_space(8.0); + } + } + + // --- HERO / DETAILS BANNER --- + let selected_item = self.get_selected_item(); + let hero_height = 200.0; + let (hero_rect, _) = + ui.allocate_exact_size(Vec2::new(ui.available_width(), hero_height), egui::Sense::hover()); + + ui.painter() + .rect_filled(hero_rect, Rounding::same(12.0), Color32::from_rgb(18, 24, 34)); + ui.painter().rect_stroke( + hero_rect, + Rounding::same(12.0), + Stroke::new(1.0, Color32::from_rgba_premultiplied(255, 255, 255, 12)), + ); + + if let Some(ref item) = selected_item { + let inner = hero_rect.shrink(16.0); + let backdrop_w = 340.0; + let backdrop_h = 168.0; + let backdrop_rect = Rect::from_min_size( + Pos2::new(inner.max.x - backdrop_w, inner.min.y), + Vec2::new(backdrop_w, backdrop_h), + ); + + let backdrop_url = item.backdrop_url(&self.config.jellyfin.server_url); + let img = egui::Image::new(&backdrop_url) + .fit_to_exact_size(Vec2::new(backdrop_w, backdrop_h)) + .rounding(Rounding::same(8.0)) + .show_loading_spinner(false); + img.paint_at(ui, backdrop_rect); + + let mut text_y = inner.min.y; + let text_x = inner.min.x; + + if let Some(ref s) = item.series_name { + let s_shape = ui.painter().text( + Pos2::new(text_x, text_y), + egui::Align2::LEFT_TOP, + s.to_uppercase(), + FontId::proportional(13.0), + Color32::from_rgb(0, 184, 245), + ); + text_y += s_shape.height() + 4.0; + } else if let Some(ref a) = item.artist_name { + let a_shape = ui.painter().text( + Pos2::new(text_x, text_y), + egui::Align2::LEFT_TOP, + a.to_uppercase(), + FontId::proportional(13.0), + Color32::from_rgb(0, 184, 245), + ); + text_y += a_shape.height() + 4.0; + } + + let title_shape = ui.painter().text( + Pos2::new(text_x, text_y), + egui::Align2::LEFT_TOP, + &item.name, + FontId::proportional(24.0), + Color32::WHITE, + ); + text_y += title_shape.height() + 6.0; + + // Badges + let mut bx = text_x; + if let Some(y) = item.year { + bx = draw_badge( + ui, + bx, + text_y, + 20.0, + &format!("{}", y), + Color32::from_rgb(45, 55, 72), + Color32::from_rgb(226, 232, 240), + ); + } + if let Some(r) = item.rating { + bx = draw_badge( + ui, + bx, + text_y, + 20.0, + &format!("★ {:.1}", r), + Color32::from_rgb(180, 83, 9), + Color32::from_rgb(254, 243, 199), + ); + } + let rt = item.formatted_runtime(); + if !rt.is_empty() { + bx = draw_badge( + ui, + bx, + text_y, + 20.0, + &rt, + Color32::from_rgb(45, 55, 72), + Color32::from_rgb(226, 232, 240), + ); + } + draw_badge( + ui, + bx, + text_y, + 20.0, + &item.item_type, + Color32::from_rgb(20, 83, 115), + Color32::from_rgb(186, 230, 253), + ); + text_y += 28.0; + + if let Some(ref ov) = item.overview { + let max_chars = 140; + let short_ov = if ov.len() > max_chars { + format!("{}...", &ov[..max_chars.saturating_sub(3)]) + } else { + ov.clone() + }; + let ov_s = ui.painter().text( + Pos2::new(text_x, text_y), + egui::Align2::LEFT_TOP, + short_ov, + FontId::proportional(13.0), + Color32::from_rgb(190, 200, 215), + ); + text_y += ov_s.height() + 10.0; + } + + // Action Pill + let btn_rect = Rect::from_min_size(Pos2::new(text_x, text_y), Vec2::new(220.0, 30.0)); + ui.painter() + .rect_filled(btn_rect, Rounding::same(15.0), Color32::from_rgb(0, 164, 220)); + let btn_label = if item.item_type == "Photo" { + "🔍 Foto Bekijken (Enter / OK)" + } else if item.is_folder || item.item_type == "PhotoAlbum" || item.item_type == "MusicArtist" { + "📂 Map Openen (Enter / OK)" + } else { + "▶ Afspelen (Enter / OK)" + }; + ui.painter().text( + btn_rect.center(), + egui::Align2::CENTER_CENTER, + btn_label, + FontId::proportional(13.0), + Color32::WHITE, + ); + } + + ui.add_space(16.0); + + // --- MAIN CONTENT AREA --- + let is_content_focused = self.focus_area == FocusArea::Content; + + if self.current_section() == SidebarSection::Dashboard && self.folder_stack.is_empty() { + // Dashboard horizontal carousels + let mut clicked_dashboard_item: Option<(usize, usize)> = None; + + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + let rows = self.get_dashboard_rows(); + for (r_idx, (title, layout, items)) in rows.iter().enumerate() { + let is_active_row = is_content_focused && self.selected_row == r_idx; + + ui.label( + egui::RichText::new(*title) + .font(FontId::proportional(20.0)) + .strong() + .color(if is_active_row { + Color32::from_rgb(0, 184, 245) + } else { + Color32::WHITE + }), + ); + ui.add_space(6.0); + + let (card_w, card_h, img_h) = match layout { + CardLayout::Poster => (140.0, 245.0, 195.0), + CardLayout::Square => (160.0, 210.0, 160.0), + CardLayout::LandscapeThumb => (250.0, 185.0, 140.0), + CardLayout::PhotoLarge => (280.0, 215.0, 175.0), + }; + + egui::ScrollArea::horizontal() + .id_salt(format!("dash_row_{}", r_idx)) + .show(ui, |ui| { + ui.horizontal(|ui| { + for (c_idx, item) in items.iter().enumerate() { + let is_focused = is_active_row && self.selected_col == c_idx; + + let (card_rect, card_resp) = ui.allocate_exact_size( + Vec2::new(card_w, card_h), + egui::Sense::click(), + ); + if card_resp.clicked() { + clicked_dashboard_item = Some((r_idx, c_idx)); + } + + if is_focused { + card_resp.scroll_to_me(Some(egui::Align::Center)); + } + + render_card( + ui, + item, + *layout, + card_rect, + img_h, + card_w, + is_focused, + &self.config.jellyfin.server_url, + ); + + ui.add_space(10.0); + } + }); + }); + ui.add_space(16.0); + } + }); + + if let Some((r, c)) = clicked_dashboard_item { + self.selected_row = r; + self.selected_col = c; + self.focus_area = FocusArea::Content; + self.play_selected(); + } + } else if self.is_in_album_view() { + // --- ALBUM DETAIL VIEW (Cover on left, metadata + tracks table on right) --- + let mut clicked_track_idx: Option = None; + let album_name = self.folder_stack.last().map(|(_, n)| n.clone()).unwrap_or_else(|| "Album".to_string()); + let first_track = self.folder_items.first(); + let artist_name = first_track.and_then(|t| t.artist_name.clone()).unwrap_or_default(); + let year_str = first_track.and_then(|t| t.year).map(|y| format!("{}", y)).unwrap_or_default(); + let cover_url = first_track.map(|t| t.square_url(&self.config.jellyfin.server_url)).unwrap_or_default(); + let total_tracks = self.folder_items.len(); + let total_ticks: u64 = self.folder_items.iter().filter_map(|t| t.runtime_ticks).sum(); + let total_mins = total_ticks / (10_000_000 * 60); + + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.horizontal_top(|ui| { + // Left: 220x220 Album Cover + Meta + ui.vertical(|ui| { + let cover_size = Vec2::new(200.0, 200.0); + let (cover_rect, _) = ui.allocate_exact_size(cover_size, egui::Sense::hover()); + if !cover_url.is_empty() { + let img = egui::Image::new(&cover_url) + .fit_to_exact_size(cover_size) + .rounding(Rounding::same(8.0)) + .show_loading_spinner(false); + img.paint_at(ui, cover_rect); + } else { + ui.painter().rect_filled(cover_rect, Rounding::same(8.0), Color32::from_rgb(28, 36, 48)); + } + + ui.add_space(10.0); + ui.label( + egui::RichText::new(&album_name) + .font(FontId::proportional(20.0)) + .strong() + .color(Color32::WHITE), + ); + if !artist_name.is_empty() { + ui.label( + egui::RichText::new(&artist_name) + .font(FontId::proportional(15.0)) + .color(Color32::from_rgb(0, 184, 245)), + ); + } + let mut info_line = format!("{} nummers • {} min", total_tracks, total_mins); + if !year_str.is_empty() { + info_line = format!("{} • {}", year_str, info_line); + } + ui.label( + egui::RichText::new(info_line) + .font(FontId::proportional(13.0)) + .color(Color32::from_rgb(150, 160, 175)), + ); + + ui.add_space(12.0); + let is_play_all_focused = is_content_focused && self.selected_grid_idx == 0; + let play_btn_rect = ui.allocate_space(Vec2::new(180.0, 36.0)).1; + let play_bg = if is_play_all_focused { + Color32::from_rgb(0, 190, 250) + } else { + Color32::from_rgb(0, 150, 210) + }; + ui.painter().rect_filled(play_btn_rect, Rounding::same(18.0), play_bg); + if is_play_all_focused { + ui.painter().rect_stroke(play_btn_rect, Rounding::same(18.0), Stroke::new(2.5, Color32::WHITE)); + } + ui.painter().text( + play_btn_rect.center(), + egui::Align2::CENTER_CENTER, + "▶ Speel Alles Af", + FontId::proportional(14.0), + Color32::WHITE, + ); + let play_resp = ui.interact(play_btn_rect, ui.id().with("play_all_btn"), egui::Sense::click()); + if play_resp.clicked() { + clicked_track_idx = Some(0); // 0 corresponds to first track + } + }); + + ui.add_space(24.0); + + // Right: Tracks Table + ui.vertical(|ui| { + let table_w = ui.available_width().max(300.0); + // Header + ui.horizontal(|ui| { + ui.allocate_ui(Vec2::new(36.0, 24.0), |ui| { + ui.label(egui::RichText::new("#").color(Color32::from_rgb(120, 130, 145)).font(FontId::proportional(13.0))); + }); + ui.allocate_ui(Vec2::new(table_w - 120.0, 24.0), |ui| { + ui.label(egui::RichText::new("TITEL").color(Color32::from_rgb(120, 130, 145)).font(FontId::proportional(13.0))); + }); + ui.allocate_ui(Vec2::new(60.0, 24.0), |ui| { + ui.label(egui::RichText::new("TIJD").color(Color32::from_rgb(120, 130, 145)).font(FontId::proportional(13.0))); + }); + }); + ui.add_space(4.0); + ui.painter().line_segment( + [Pos2::new(ui.cursor().min.x, ui.cursor().min.y), Pos2::new(ui.cursor().min.x + table_w, ui.cursor().min.y)], + Stroke::new(1.0, Color32::from_rgba_premultiplied(255, 255, 255, 15)), + ); + ui.add_space(6.0); + + for (t_idx, track) in self.folder_items.iter().enumerate() { + let is_focused = is_content_focused && self.selected_grid_idx == (t_idx + 1); + let track_h = 36.0; + let (row_rect, row_resp) = ui.allocate_exact_size(Vec2::new(table_w, track_h), egui::Sense::click()); + + if row_resp.clicked() { + clicked_track_idx = Some(t_idx); + } + + if is_focused { + ui.painter().rect_filled(row_rect, Rounding::same(6.0), Color32::from_rgba_premultiplied(0, 164, 220, 60)); + ui.painter().rect_stroke(row_rect, Rounding::same(6.0), Stroke::new(1.5, Color32::from_rgb(0, 190, 250))); + row_resp.scroll_to_me(Some(egui::Align::Center)); + } else if row_resp.hovered() { + ui.painter().rect_filled(row_rect, Rounding::same(6.0), Color32::from_rgba_premultiplied(255, 255, 255, 10)); + } + + let text_col = if is_focused { Color32::WHITE } else { Color32::from_rgb(220, 225, 235) }; + let num_str = track.index_number.map(|n| format!("{}", n)).unwrap_or_else(|| format!("{}", t_idx + 1)); + + // Track number + ui.painter().text( + Pos2::new(row_rect.min.x + 12.0, row_rect.center().y), + egui::Align2::LEFT_CENTER, + num_str, + FontId::proportional(14.0), + if is_focused { Color32::from_rgb(0, 190, 250) } else { Color32::from_rgb(140, 150, 165) }, + ); + + // Title + ui.painter().text( + Pos2::new(row_rect.min.x + 48.0, row_rect.center().y), + egui::Align2::LEFT_CENTER, + &track.name, + FontId::proportional(14.0), + text_col, + ); + + // Duration + let dur_str = track.formatted_runtime(); + ui.painter().text( + Pos2::new(row_rect.max.x - 12.0, row_rect.center().y), + egui::Align2::RIGHT_CENTER, + dur_str, + FontId::proportional(13.0), + Color32::from_rgb(140, 150, 165), + ); + + ui.add_space(2.0); + } + }); + }); + }); + + if let Some(t_idx) = clicked_track_idx { + self.selected_grid_idx = t_idx + 1; + if let Some(track) = self.folder_items.get(t_idx) { + let client = self.client.clone(); + let player = self.player.clone(); + let item = track.clone(); + tokio::spawn(async move { + if let Ok(PlayableTarget::Stream { url, title, media_type, artist, genre }) = client.resolve_playable_item(&item).await { + let _ = player.play_media(&url, &title, &media_type, artist.as_deref(), genre.as_deref()).await; + } + }); + } + } + } else { + // Multi-column Responsive Vertical Grid for Library Sections and Folders + let layout = self.get_active_card_layout(); + let target_w = match layout { + CardLayout::Poster => 150.0, + CardLayout::Square => 160.0, + CardLayout::LandscapeThumb => 250.0, + CardLayout::PhotoLarge => 280.0, + }; + + let show_alpha_bar = self.has_alphabet_bar(); + + let mut clicked_grid_item: Option = None; + let mut clicked_letter: Option = None; + let mut any_loading = false; + + // Get current item's sort letter to highlight in alphabet bar + let (_, _, current_items) = self.get_active_grid_items(); + let current_sort_letter = current_items + .get(self.selected_grid_idx) + .map(|a| a.sort_letter()); + + // Main Content: Horizontal container with Grid (left) and Alphabet Bar (right) + let alpha_width = if show_alpha_bar { 36.0 } else { 0.0 }; + let avail_w = (ui.available_width() - alpha_width - 8.0).max(200.0); + let gap = 16.0; + // Responsive column calculation: auto-fill available grid width + let cols = ((avail_w + gap) / (target_w + gap)).floor().max(1.0) as usize; + let card_w = ((avail_w - (cols - 1) as f32 * gap) / cols as f32).floor(); + let (card_h, img_h) = match layout { + CardLayout::Poster => (card_w * 1.5 + 46.0, card_w * 1.5), + CardLayout::Square => (card_w + 46.0, card_w), + CardLayout::LandscapeThumb => (card_w * 0.5625 + 46.0, card_w * 0.5625), + CardLayout::PhotoLarge => (card_w * 0.66 + 46.0, card_w * 0.66), + }; + self.grid_cols = cols; + let target_offset = self.scroll_to_offset.take(); + let (_, _, items) = self.get_active_grid_items(); + + ui.horizontal_top(|ui| { + // Left: Vertical scrolling grid (strictly constrained to avail_w, virtualized with show_rows) + ui.allocate_ui_with_layout( + Vec2::new(avail_w, ui.available_height()), + egui::Layout::top_down(egui::Align::LEFT), + |ui| { + let num_rows = (items.len() + cols - 1) / cols; + let row_height_total = card_h + gap; + + let mut scroll_area = egui::ScrollArea::vertical().auto_shrink([false, false]); + if let Some(offset) = target_offset { + scroll_area = scroll_area.vertical_scroll_offset(offset); + } + + scroll_area.show_rows(ui, row_height_total, num_rows, |ui, row_range| { + if items.is_empty() { + ui.add_space(40.0); + ui.label( + egui::RichText::new("Geen items gevonden in deze weergave.") + .font(FontId::proportional(16.0)) + .color(Color32::from_rgb(150, 160, 175)), + ); + } + + for r_idx in row_range { + let start = r_idx * cols; + let end = (start + cols).min(items.len()); + let chunk = &items[start..end]; + + ui.horizontal(|ui| { + for (c_idx, item) in chunk.iter().enumerate() { + let item_idx = start + c_idx; + let is_focused = is_content_focused && self.selected_grid_idx == item_idx; + + let (card_rect, card_resp) = ui.allocate_exact_size( + Vec2::new(card_w, card_h), + egui::Sense::click(), + ); + if card_resp.clicked() { + clicked_grid_item = Some(item_idx); + } + + if is_focused { + card_resp.scroll_to_me(Some(egui::Align::Center)); + } + + let loading = render_card( + ui, + item, + layout, + card_rect, + img_h, + card_w, + is_focused, + &self.config.jellyfin.server_url, + ); + if loading { + any_loading = true; + } + + if c_idx + 1 < cols { + ui.add_space(gap); + } + } + }); + ui.add_space(gap); + } + }); + }, + ); + + // Right: Dedicated Alphabet Index Bar (# + A-Z) + if show_alpha_bar { + let is_alpha_focused = self.focus_area == FocusArea::Alphabet; + let avail_h = ui.available_height(); + let letter_spacing = 1.0; + let total_spacing = 26.0 * letter_spacing; + // Dynamically scale letter height so all 27 letters fit on any screen height: + let item_h = ((avail_h - total_spacing - 8.0) / 27.0).clamp(11.0, 18.0); + let font_sz = (item_h * 0.72).clamp(8.5, 12.5); + let total_needed = 27.0 * item_h + total_spacing; + let top_padding = if avail_h > total_needed { + ((avail_h - total_needed) * 0.5).max(2.0) + } else { + 2.0 + }; + + ui.allocate_ui_with_layout( + Vec2::new(alpha_width, avail_h), + egui::Layout::top_down(egui::Align::Center), + |ui| { + ui.spacing_mut().item_spacing.y = letter_spacing; + ui.add_space(top_padding); + for (idx, &ch) in INDEX_CHARS.iter().enumerate() { + let is_selected_by_nav = is_alpha_focused && self.selected_alpha_idx == idx; + let is_matching_current = current_sort_letter == Some(ch); + + let (lbl_rect, lbl_resp) = + ui.allocate_exact_size(Vec2::new(28.0, item_h), egui::Sense::click()); + if lbl_resp.clicked() { + clicked_letter = Some(ch); + } + + if is_selected_by_nav { + ui.painter().rect_filled( + lbl_rect, + Rounding::same(3.0), + Color32::from_rgb(0, 164, 220), + ); + } + + let col = if is_selected_by_nav { + Color32::WHITE + } else if is_matching_current { + Color32::from_rgb(255, 255, 255) + } else { + Color32::from_rgb(0, 184, 245) + }; + + ui.painter().text( + lbl_rect.center(), + egui::Align2::CENTER_CENTER, + format!("{}", ch), + FontId::proportional(if is_selected_by_nav { font_sz + 1.5 } else { font_sz }), + col, + ); + } + }, + ); + } + }); + + if any_loading { + ctx.request_repaint_after(std::time::Duration::from_millis(60)); + } + + if let Some(ch) = clicked_letter { + self.jump_to_letter(ch); + } + + if let Some(idx) = clicked_grid_item { + self.selected_grid_idx = idx; + self.focus_area = FocusArea::Content; + self.play_selected(); + } + } + }); + } +} + +fn render_card( + ui: &mut egui::Ui, + item: &MediaItem, + layout: CardLayout, + card_rect: Rect, + img_h: f32, + card_w: f32, + is_focused: bool, + server_url: &str, +) -> bool { + let mut is_loading = false; + // Poster rectangle (clean floating poster, no card background box) + let img_rect = Rect::from_min_size(card_rect.min, Vec2::new(card_w, img_h)); + + // Focus ring on the image poster itself (like Jellyfin Web/TV) + if is_focused { + let focus_rect = img_rect.expand(2.5); + ui.painter().rect_stroke( + focus_rect, + Rounding::same(8.0), + Stroke::new(3.0, Color32::from_rgb(0, 190, 250)), + ); + } + + let has_image = item.has_primary_image + || item.parent_primary_image_item_id.is_some() + || item.parent_backdrop_item_id.is_some(); + + // Subtle dark placeholder card background while loading + ui.painter().rect_filled(img_rect, Rounding::same(6.0), Color32::from_rgb(22, 28, 38)); + + if has_image { + let img_url = match layout { + CardLayout::Poster => item.poster_url(server_url), + CardLayout::Square => item.square_url(server_url), + CardLayout::LandscapeThumb => item.thumb_url(server_url), + CardLayout::PhotoLarge => item.photo_url(server_url), + }; + + let img = egui::Image::new(&img_url) + .fit_to_exact_size(img_rect.size()) + .rounding(Rounding::same(6.0)) + .show_loading_spinner(false); + + is_loading = matches!(img.load_for_size(ui.ctx(), img_rect.size()), Ok(egui::load::TexturePoll::Pending { .. })); + img.paint_at(ui, img_rect); + // If image is still loading in egui texture manager, draw a neat subtle spinner in center + if is_loading { + // Draw a subtle small spinner placeholder in the center of the card + let spinner_radius = (card_w.min(img_h) * 0.12).clamp(10.0, 18.0); + let time = ui.input(|i| i.time); + let angle = (time * 6.0) as f32; + let center = img_rect.center(); + let p1 = center + Vec2::angled(angle) * spinner_radius; + let p2 = center + Vec2::angled(angle + 2.1) * spinner_radius; + let p3 = center + Vec2::angled(angle + 4.2) * spinner_radius; + ui.painter().circle_filled(p1, 2.5, Color32::from_rgb(0, 184, 245)); + ui.painter().circle_filled(p2, 2.0, Color32::from_rgba_premultiplied(0, 184, 245, 160)); + ui.painter().circle_filled(p3, 1.5, Color32::from_rgba_premultiplied(0, 184, 245, 90)); + } + } else { + // Fallback icon placeholder for items without image + let icon = match item.item_type.as_str() { + "Audio" => "🎵", + "MusicArtist" => "🎤", + "MusicAlbum" => "💿", + "Photo" | "PhotoAlbum" => "📷", + "Series" | "Episode" => "📺", + _ => "🎬", + }; + ui.painter().text( + img_rect.center(), + egui::Align2::CENTER_CENTER, + icon, + FontId::proportional(34.0), + Color32::from_rgba_premultiplied(255, 255, 255, 120), + ); + } + + // Progress bar for resumable media + if let Some(pct) = item.played_percentage { + if layout == CardLayout::LandscapeThumb { + let bar_rect = Rect::from_min_size( + Pos2::new(img_rect.min.x, img_rect.max.y - 4.0), + Vec2::new(img_rect.width(), 4.0), + ); + let fill_w = img_rect.width() * (pct / 100.0).clamp(0.0, 1.0); + ui.painter().rect_filled( + bar_rect, + Rounding::ZERO, + Color32::from_rgba_premultiplied(0, 0, 0, 160), + ); + ui.painter().rect_filled( + Rect::from_min_size(bar_rect.min, Vec2::new(fill_w, 4.0)), + Rounding::ZERO, + Color32::from_rgb(0, 184, 245), + ); + } + } + + // Centered Title and Subtitle underneath poster (Jellyfin TV style) + let title_text = if item.name.len() > 24 { + format!("{}...", &item.name[..22]) + } else { + item.name.clone() + }; + + let title_y = img_rect.max.y + 6.0; + ui.painter().text( + Pos2::new(card_rect.center().x, title_y), + egui::Align2::CENTER_TOP, + title_text, + FontId::proportional(13.0), + if is_focused { + Color32::WHITE + } else { + Color32::from_rgb(215, 225, 235) + }, + ); + + // Subtitle (artist name or year) + let subtitle = item.artist_name.as_deref().or_else(|| { + None + }); + let sub_str = if let Some(a) = subtitle { + Some(a.to_string()) + } else if let Some(y) = item.year { + Some(format!("{}", y)) + } else { + None + }; + + if let Some(sub) = sub_str { + let sub_text = if sub.len() > 24 { + format!("{}...", &sub[..22]) + } else { + sub + }; + ui.painter().text( + Pos2::new(card_rect.center().x, title_y + 16.0), + egui::Align2::CENTER_TOP, + sub_text, + FontId::proportional(11.5), + Color32::from_rgb(135, 145, 160), + ); + } + + is_loading +} + +fn draw_badge(ui: &mut egui::Ui, x: f32, y: f32, h: f32, text: &str, bg: Color32, fg: Color32) -> f32 { + let font = FontId::proportional(12.0); + let galley = ui.painter().layout_no_wrap(text.to_string(), font.clone(), fg); + let pad_x = 8.0; + let badge_w = galley.size().x + pad_x * 2.0; + + let rect = Rect::from_min_size(Pos2::new(x, y), Vec2::new(badge_w, h)); + ui.painter().rect_filled(rect, Rounding::same(5.0), bg); + ui.painter() + .galley(Pos2::new(x + pad_x, y + (h - galley.size().y) / 2.0), galley, fg); + + x + badge_w + 6.0 +} + +fn format_sec(seconds: f64) -> String { + let s = seconds.max(0.0) as u64; + let h = s / 3600; + let m = (s % 3600) / 60; + let sec = s % 60; + if h > 0 { + format!("{}:{:02}:{:02}", h, m, sec) + } else { + format!("{:02}:{:02}", m, sec) + } +} diff --git a/jellyfin-kiosk-rs/src/config.rs b/jellyfin-kiosk-rs/src/config.rs new file mode 100644 index 0000000..074b4a1 --- /dev/null +++ b/jellyfin-kiosk-rs/src/config.rs @@ -0,0 +1,210 @@ +use serde::Deserialize; +use std::path::PathBuf; + +#[derive(Debug, Clone, Deserialize)] +pub struct AppConfig { + pub jellyfin: JellyfinConfig, + #[serde(default)] + pub player: PlayerConfig, + #[serde(default)] + pub kodi_rpc: KodiRpcConfig, + #[serde(default)] + pub mqtt: MqttConfig, + #[serde(default)] + pub youtube: YouTubeConfig, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct JellyfinConfig { + pub server_url: String, + pub username: String, + pub password: Option, + #[serde(default = "default_client_name")] + pub client_name: String, + #[serde(default = "default_device_name")] + pub device_name: String, + #[serde(default = "default_device_id")] + pub device_id: String, +} + +fn default_client_name() -> String { + "Jellyfin HTPC Kiosk".to_string() +} +fn default_device_name() -> String { + "HTPC Kiosk".to_string() +} +fn default_device_id() -> String { + "htpc-kiosk-01".to_string() +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PlayerConfig { + #[serde(default = "default_mpv_path")] + pub mpv_path: String, + #[serde(default = "default_ipc_socket")] + pub ipc_socket: String, + #[serde(default = "default_hwdec")] + pub hwdec: String, + #[serde(default = "default_vo")] + pub vo: String, + #[serde(default = "default_fullscreen")] + pub fullscreen: bool, +} + +fn default_mpv_path() -> String { + "mpv".to_string() +} +fn default_ipc_socket() -> String { + "/tmp/mpv-kiosk.sock".to_string() +} +fn default_hwdec() -> String { + "auto".to_string() +} +fn default_vo() -> String { + "gpu".to_string() +} +fn default_fullscreen() -> bool { + true +} + +impl Default for PlayerConfig { + fn default() -> Self { + Self { + mpv_path: default_mpv_path(), + ipc_socket: default_ipc_socket(), + hwdec: default_hwdec(), + vo: default_vo(), + fullscreen: default_fullscreen(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct KodiRpcConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_http_port")] + pub http_port: u16, +} + +fn default_true() -> bool { + true +} +fn default_http_port() -> u16 { + 8080 +} + +impl Default for KodiRpcConfig { + fn default() -> Self { + Self { + enabled: default_true(), + http_port: default_http_port(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MqttConfig { + #[serde(default = "default_false")] + pub enabled: bool, + #[serde(default = "default_mqtt_host")] + pub host: String, + #[serde(default = "default_mqtt_port")] + pub port: u16, + pub username: Option, + pub password: Option, + #[serde(default = "default_discovery_prefix")] + pub discovery_prefix: String, + #[serde(default = "default_topic_prefix")] + pub topic_prefix: String, + pub client_id: Option, +} + +fn default_false() -> bool { + false +} +fn default_mqtt_host() -> String { + "localhost".to_string() +} +fn default_mqtt_port() -> u16 { + 1883 +} +fn default_discovery_prefix() -> String { + "homeassistant".to_string() +} +fn default_topic_prefix() -> String { + "jellyfin-kiosk".to_string() +} + +impl Default for MqttConfig { + fn default() -> Self { + Self { + enabled: default_false(), + host: default_mqtt_host(), + port: default_mqtt_port(), + username: None, + password: None, + discovery_prefix: default_discovery_prefix(), + topic_prefix: default_topic_prefix(), + client_id: None, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct YouTubeConfig { + #[serde(default = "default_max_height")] + pub max_height: u32, + #[serde(default = "default_true")] + pub fetch_metadata: bool, + #[serde(default)] + pub client_id: String, + #[serde(default)] + pub client_secret: Option, + #[serde(default = "default_youtube_token_path")] + pub token_path: String, +} + +fn default_max_height() -> u32 { + 1080 +} + +fn default_youtube_token_path() -> String { + "youtube_token.json".to_string() +} + +impl Default for YouTubeConfig { + fn default() -> Self { + Self { + max_height: default_max_height(), + fetch_metadata: default_true(), + client_id: String::new(), + client_secret: None, + token_path: default_youtube_token_path(), + } + } +} + +impl AppConfig { + pub fn load() -> Result> { + let candidates = [ + PathBuf::from("config.yaml"), + PathBuf::from("../config.yaml"), + PathBuf::from("/etc/jellyfin-client/config.yaml"), + PathBuf::from("config.example.yaml"), + PathBuf::from("../config.example.yaml"), + ]; + + for path in &candidates { + if path.exists() { + let content = std::fs::read_to_string(path)?; + let cfg: AppConfig = serde_yaml::from_str(&content)?; + tracing::info!("Loaded config from {}", path.display()); + return Ok(cfg); + } + } + + Err("No config.yaml found in search paths".into()) + } +} diff --git a/jellyfin-kiosk-rs/src/eventserver.rs b/jellyfin-kiosk-rs/src/eventserver.rs new file mode 100644 index 0000000..099d19f --- /dev/null +++ b/jellyfin-kiosk-rs/src/eventserver.rs @@ -0,0 +1,658 @@ +use std::sync::Arc; +use tokio::net::UdpSocket; +use tokio::sync::mpsc::UnboundedSender; + +use crate::player::PlayerController; +use crate::yatse::RemoteNavAction; + +// Official Kodi EventServer Packet Types +pub const PT_HELO: u16 = 0x01; +pub const PT_BYE: u16 = 0x02; +pub const PT_BUTTON: u16 = 0x03; +pub const _PT_MOUSE: u16 = 0x04; +pub const PT_PING: u16 = 0x05; +pub const _PT_BROADCAST: u16 = 0x06; +pub const _PT_NOTIFICATION: u16 = 0x07; +pub const _PT_BLOB: u16 = 0x08; +pub const _PT_LOG: u16 = 0x09; +pub const PT_ACTION: u16 = 0x0A; // 10 in decimal! + +// Button packet flags +pub const _PTB_USE_NAME: u16 = 0x01; +pub const PTB_DOWN: u16 = 0x02; +pub const PTB_UP: u16 = 0x04; + +pub const HEADER_SIZE: usize = 32; + +pub struct KodiEventServer { + port: u16, + player: Arc, + nav_tx: UnboundedSender, +} + +impl KodiEventServer { + pub fn new(port: u16, player: Arc, nav_tx: UnboundedSender) -> Self { + Self { port, player, nav_tx } + } + + pub async fn start(&self) -> Result { + let addr = format!("0.0.0.0:{}", self.port); + let socket = match UdpSocket::bind(&addr).await { + Ok(s) => { + let bound_port = s.local_addr()?.port(); + tracing::info!("Kodi EventServer listening on UDP 0.0.0.0:{}", bound_port); + (Arc::new(s), bound_port) + } + Err(e) => { + tracing::warn!("Failed to bind Kodi EventServer on UDP {}: {}", addr, e); + return Err(e); + } + }; + + let (socket, bound_port) = socket; + let player = self.player.clone(); + let nav_tx = self.nav_tx.clone(); + + tokio::spawn(async move { + let mut buf = [0u8; 2048]; + loop { + let (len, peer) = match socket.recv_from(&mut buf).await { + Ok(res) => res, + Err(e) => { + tracing::warn!("EventServer recv error: {}", e); + continue; + } + }; + + if len < HEADER_SIZE { + continue; + } + + if &buf[0..4] != b"XBMC" { + continue; + } + + let majver = buf[4]; + let minver = buf[5]; + let pkttype = u16::from_be_bytes([buf[6], buf[7]]); + let _seq = u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]); + let _maxseq = u32::from_be_bytes([buf[12], buf[13], buf[14], buf[15]]); + let paylen = u16::from_be_bytes([buf[16], buf[17]]) as usize; + let client_token = u32::from_be_bytes([buf[18], buf[19], buf[20], buf[21]]); + + let payload = if len >= HEADER_SIZE + paylen { + &buf[HEADER_SIZE..HEADER_SIZE + paylen] + } else { + &buf[HEADER_SIZE..len] + }; + + tracing::info!( + "EventServer packet from {}: ver={}.{}, type=0x{:02X}, paylen={}, token={}", + peer, majver, minver, pkttype, paylen, client_token + ); + + match pkttype { + PT_HELO | PT_PING => { + let dev_name = payload + .split(|&b| b == 0) + .next() + .map(|s| String::from_utf8_lossy(s).to_string()) + .unwrap_or_default(); + tracing::info!("EventServer HELO/PING from {} (device: '{}')", peer, dev_name); + + // Echo ping ack back to client (Yatse expects this handshake) + let mut ack = [0u8; HEADER_SIZE]; + ack[0..4].copy_from_slice(b"XBMC"); + ack[4] = 2; // majver + ack[5] = 0; // minver + ack[6..8].copy_from_slice(&PT_PING.to_be_bytes()); + ack[8..12].copy_from_slice(&1u32.to_be_bytes()); + ack[12..16].copy_from_slice(&1u32.to_be_bytes()); + ack[16..18].copy_from_slice(&0u16.to_be_bytes()); + ack[18..22].copy_from_slice(&client_token.to_be_bytes()); + + let _ = socket.send_to(&ack, peer).await; + tracing::info!("EventServer sent PING-ACK to {}", peer); + } + PT_BUTTON => { + if payload.len() >= 6 { + // Format: code(2B) + flags(2B) + amount(2B) + map_name(null-term) + button_name(null-term) + let bcode = u16::from_be_bytes([payload[0], payload[1]]); + let flags = u16::from_be_bytes([payload[2], payload[3]]); + let _amount = u16::from_be_bytes([payload[4], payload[5]]); + + let is_down = (flags & PTB_DOWN) != 0 || (flags & PTB_UP) == 0; + if !is_down { + // Ignore key release + continue; + } + + let str_data = &payload[6..]; + let mut parts = str_data.split(|&b| b == 0); + let map_name = parts + .next() + .map(|s| String::from_utf8_lossy(s).to_string()) + .unwrap_or_default(); + let btn_name = parts + .next() + .map(|s| String::from_utf8_lossy(s).to_lowercase()) + .unwrap_or_default(); + + tracing::info!( + "EventServer BUTTON: code=0x{:04X}, flags=0x{:04X}, map='{}', btn='{}'", + bcode, flags, map_name, btn_name + ); + + dispatch_button_or_code(&btn_name, bcode, &nav_tx, &player); + } + } + PT_ACTION => { + if payload.len() >= 2 { + // Format: action_type(1B) + action_string(null-terminated string) + let action_type = payload[0]; + let action_str = String::from_utf8_lossy(&payload[1..]) + .trim_matches('\0') + .trim() + .to_string(); + + tracing::info!("EventServer ACTION: type={}, action='{}'", action_type, action_str); + dispatch_action(&action_str, &nav_tx, &player); + } + } + PT_BYE => { + tracing::info!("EventServer client BYE from {}", peer); + } + _ => { + tracing::debug!("EventServer unhandled packet type: 0x{:02X}", pkttype); + } + } + } + }); + + Ok(bound_port) + } +} + +#[allow(dead_code)] +pub fn build_packet_header(pkttype: u16, paylen: u16, client_token: u32) -> [u8; HEADER_SIZE] { + let mut header = [0u8; HEADER_SIZE]; + header[0..4].copy_from_slice(b"XBMC"); + header[4] = 2; // majver + header[5] = 0; // minver + header[6..8].copy_from_slice(&pkttype.to_be_bytes()); + header[8..12].copy_from_slice(&1u32.to_be_bytes()); + header[12..16].copy_from_slice(&1u32.to_be_bytes()); + header[16..18].copy_from_slice(&paylen.to_be_bytes()); + header[18..22].copy_from_slice(&client_token.to_be_bytes()); + header +} + +#[allow(dead_code)] +pub fn build_helo_packet(device_name: &str, token: u32) -> Vec { + let mut payload = Vec::new(); + payload.extend_from_slice(device_name.as_bytes()); + payload.push(0); + // icon_type (0 = none), port (0), reserved (8) + payload.push(0); + payload.extend_from_slice(&0u16.to_be_bytes()); + payload.extend_from_slice(&[0u8; 8]); + + let header = build_packet_header(PT_HELO, payload.len() as u16, token); + let mut packet = header.to_vec(); + packet.extend_from_slice(&payload); + packet +} + +#[allow(dead_code)] +pub fn build_ping_packet(token: u32) -> Vec { + let header = build_packet_header(PT_PING, 0, token); + header.to_vec() +} + +#[allow(dead_code)] +pub fn build_button_packet( + code: u16, + flags: u16, + map_name: &str, + button_name: &str, + token: u32, +) -> Vec { + let mut payload = Vec::new(); + payload.extend_from_slice(&code.to_be_bytes()); + payload.extend_from_slice(&flags.to_be_bytes()); + payload.extend_from_slice(&0u16.to_be_bytes()); + payload.extend_from_slice(map_name.as_bytes()); + payload.push(0); + payload.extend_from_slice(button_name.as_bytes()); + payload.push(0); + + let header = build_packet_header(PT_BUTTON, payload.len() as u16, token); + let mut packet = header.to_vec(); + packet.extend_from_slice(&payload); + packet +} + +#[allow(dead_code)] +pub fn build_action_packet(action_str: &str, token: u32) -> Vec { + let mut payload = Vec::new(); + payload.push(1); // ACTION_EXECBUILTIN + payload.extend_from_slice(action_str.as_bytes()); + payload.push(0); + + let header = build_packet_header(PT_ACTION, payload.len() as u16, token); + let mut packet = header.to_vec(); + packet.extend_from_slice(&payload); + packet +} + +pub fn dispatch_button_or_code( + btn: &str, + code: u16, + nav_tx: &UnboundedSender, + player: &Arc, +) { + let lower = btn.to_lowercase(); + match lower.as_str() { + "up" | "bigstepforward" | "cursorup" => { + let _ = nav_tx.send(RemoteNavAction::Up); + } + "down" | "bigstepback" | "cursordown" => { + let _ = nav_tx.send(RemoteNavAction::Down); + } + "left" | "stepback" | "cursorleft" => { + let _ = nav_tx.send(RemoteNavAction::Left); + } + "right" | "stepforward" | "cursorright" => { + let _ = nav_tx.send(RemoteNavAction::Right); + } + "select" | "enter" | "ok" => { + let _ = nav_tx.send(RemoteNavAction::Select); + } + "back" | "parentdir" | "previousmenu" | "escape" => { + let _ = nav_tx.send(RemoteNavAction::Back); + } + "title" | "menu" | "topmenu" | "contextmenu" => { + let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView); + } + "togglefullscreen" | "fullscreen" => { + let _ = nav_tx.send(RemoteNavAction::ToggleOSD); + } + "osd" | "showosd" | "subtitles" | "nextsubtitle" => { + let _ = nav_tx.send(RemoteNavAction::CycleSubtitle); + } + "play" | "pause" | "playpause" => { + let p = player.clone(); + tokio::spawn(async move { p.play_pause().await; }); + } + "stop" => { + let _ = nav_tx.send(RemoteNavAction::Stop); + let p = player.clone(); + tokio::spawn(async move { p.stop().await; }); + } + "volumeup" | "vol_up" => { + let _ = nav_tx.send(RemoteNavAction::VolumeUp); + let p = player.clone(); + tokio::spawn(async move { p.volume_up().await; }); + } + "volumedown" | "vol_down" => { + let _ = nav_tx.send(RemoteNavAction::VolumeDown); + let p = player.clone(); + tokio::spawn(async move { p.volume_down().await; }); + } + "mute" | "volumemute" => { + let p = player.clone(); + tokio::spawn(async move { p.toggle_mute().await; }); + } + _ => { + // Fallback to raw keycode if button string was not matched + match code { + 0xF010 | 0xF050 | 273 => { let _ = nav_tx.send(RemoteNavAction::Up); } + 0xF011 | 0xF051 | 274 => { let _ = nav_tx.send(RemoteNavAction::Down); } + 0xF012 | 0xF052 | 276 => { let _ = nav_tx.send(RemoteNavAction::Left); } + 0xF013 | 0xF053 | 275 => { let _ = nav_tx.send(RemoteNavAction::Right); } + 0xF001 | 0xF014 | 13 => { let _ = nav_tx.send(RemoteNavAction::Select); } + 0xF016 | 27 | 8 => { let _ = nav_tx.send(RemoteNavAction::Back); } + _ => {} + } + } + } +} + +pub fn dispatch_action( + action: &str, + nav_tx: &UnboundedSender, + player: &Arc, +) { + let lower = action.to_lowercase(); + if lower == "up" || lower == "action(3)" || lower == "cursorup" { + let _ = nav_tx.send(RemoteNavAction::Up); + } else if lower == "down" || lower == "action(4)" || lower == "cursordown" { + let _ = nav_tx.send(RemoteNavAction::Down); + } else if lower == "left" || lower == "action(1)" || lower == "cursorleft" { + let _ = nav_tx.send(RemoteNavAction::Left); + } else if lower == "right" || lower == "action(2)" || lower == "cursorright" { + let _ = nav_tx.send(RemoteNavAction::Right); + } else if lower == "select" || lower == "enter" || lower == "action(7)" || lower == "selectitem" { + let _ = nav_tx.send(RemoteNavAction::Select); + } else if lower == "back" || lower == "action(9)" || lower == "action(10)" || lower == "previousmenu" || lower == "parentdir" || lower == "close" { + let _ = nav_tx.send(RemoteNavAction::Back); + } else if lower.contains("togglefullscreen") { + let _ = nav_tx.send(RemoteNavAction::ToggleOSD); + } else if lower.contains("action(osd)") || lower == "osd" || lower.contains("nextsubtitle") { + let _ = nav_tx.send(RemoteNavAction::CycleSubtitle); + } else if lower.contains("volumeup") || lower == "action(88)" { + let _ = nav_tx.send(RemoteNavAction::VolumeUp); + let p = player.clone(); + tokio::spawn(async move { p.volume_up().await; }); + } else if lower.contains("volumedown") || lower == "action(89)" { + let _ = nav_tx.send(RemoteNavAction::VolumeDown); + let p = player.clone(); + tokio::spawn(async move { p.volume_down().await; }); + } else if lower.contains("activatewindow(videos,movietitles") || lower.contains("activatewindow(videos)") { + let _ = nav_tx.send(RemoteNavAction::ActivateSection(1)); + } else if lower.contains("activatewindow(videos,tvshowtitles") || lower.contains("activatewindow(tvshows") { + let _ = nav_tx.send(RemoteNavAction::ActivateSection(2)); + } else if lower.contains("activatewindow(music") { + let _ = nav_tx.send(RemoteNavAction::ActivateSection(3)); + } else if lower.contains("activatewindow(pictures") || lower.contains("activatewindow(photos") { + let _ = nav_tx.send(RemoteNavAction::ActivateSection(4)); + } else if lower.contains("activatewindow(home") { + let _ = nav_tx.send(RemoteNavAction::ActivateSection(0)); + } else if lower.contains("playpause") || lower == "pause" || lower == "play" { + let p = player.clone(); + tokio::spawn(async move { p.play_pause().await; }); + } else if lower.contains("stop") { + let _ = nav_tx.send(RemoteNavAction::Stop); + let p = player.clone(); + tokio::spawn(async move { p.stop().await; }); + } else if lower.contains("mute") { + let p = player.clone(); + tokio::spawn(async move { p.toggle_mute().await; }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::PlayerConfig; + use tokio::sync::mpsc; + use tokio::time::{timeout, Duration}; + + #[test] + fn test_build_packet_header() { + let hdr = build_packet_header(PT_PING, 10, 0xAABBCCDD); + assert_eq!(&hdr[0..4], b"XBMC"); + assert_eq!(hdr[4], 2); + assert_eq!(hdr[5], 0); + assert_eq!(u16::from_be_bytes([hdr[6], hdr[7]]), PT_PING); + assert_eq!(u32::from_be_bytes([hdr[8], hdr[9], hdr[10], hdr[11]]), 1); + assert_eq!(u32::from_be_bytes([hdr[12], hdr[13], hdr[14], hdr[15]]), 1); + assert_eq!(u16::from_be_bytes([hdr[16], hdr[17]]), 10); + assert_eq!(u32::from_be_bytes([hdr[18], hdr[19], hdr[20], hdr[21]]), 0xAABBCCDD); + } + + #[tokio::test] + async fn test_dispatch_button_navigation() { + let (nav_tx, mut nav_rx) = mpsc::unbounded_channel(); + let player = PlayerController::new(PlayerConfig::default()); + + let cases = vec![ + ("up", RemoteNavAction::Up), + ("bigstepforward", RemoteNavAction::Up), + ("cursorup", RemoteNavAction::Up), + ("down", RemoteNavAction::Down), + ("bigstepback", RemoteNavAction::Down), + ("cursordown", RemoteNavAction::Down), + ("left", RemoteNavAction::Left), + ("stepback", RemoteNavAction::Left), + ("cursorleft", RemoteNavAction::Left), + ("right", RemoteNavAction::Right), + ("stepforward", RemoteNavAction::Right), + ("cursorright", RemoteNavAction::Right), + ("select", RemoteNavAction::Select), + ("enter", RemoteNavAction::Select), + ("ok", RemoteNavAction::Select), + ("back", RemoteNavAction::Back), + ("parentdir", RemoteNavAction::Back), + ("previousmenu", RemoteNavAction::Back), + ("escape", RemoteNavAction::Back), + ]; + + for (btn, expected) in cases { + dispatch_button_or_code(btn, 0, &nav_tx, &player); + let action = nav_rx.recv().await.expect("Expected nav action"); + assert_eq!(action, expected, "Button '{}' should map to {:?}", btn, expected); + } + } + + #[tokio::test] + async fn test_dispatch_button_raw_keycodes() { + let (nav_tx, mut nav_rx) = mpsc::unbounded_channel(); + let player = PlayerController::new(PlayerConfig::default()); + + let cases = vec![ + (0xF010, RemoteNavAction::Up), + (0xF050, RemoteNavAction::Up), + (273, RemoteNavAction::Up), + (0xF011, RemoteNavAction::Down), + (0xF051, RemoteNavAction::Down), + (274, RemoteNavAction::Down), + (0xF012, RemoteNavAction::Left), + (0xF052, RemoteNavAction::Left), + (276, RemoteNavAction::Left), + (0xF013, RemoteNavAction::Right), + (0xF053, RemoteNavAction::Right), + (275, RemoteNavAction::Right), + (0xF001, RemoteNavAction::Select), + (0xF014, RemoteNavAction::Select), + (13, RemoteNavAction::Select), + (0xF016, RemoteNavAction::Back), + (27, RemoteNavAction::Back), + (8, RemoteNavAction::Back), + ]; + + for (code, expected) in cases { + dispatch_button_or_code("unknown_btn", code, &nav_tx, &player); + let action = nav_rx.recv().await.expect("Expected nav action for keycode"); + assert_eq!(action, expected, "Keycode 0x{:04X} should map to {:?}", code, expected); + } + } + + #[tokio::test] + async fn test_dispatch_action_navigation() { + let (nav_tx, mut nav_rx) = mpsc::unbounded_channel(); + let player = PlayerController::new(PlayerConfig::default()); + + let cases = vec![ + ("up", RemoteNavAction::Up), + ("action(3)", RemoteNavAction::Up), + ("cursorup", RemoteNavAction::Up), + ("down", RemoteNavAction::Down), + ("action(4)", RemoteNavAction::Down), + ("cursordown", RemoteNavAction::Down), + ("left", RemoteNavAction::Left), + ("action(1)", RemoteNavAction::Left), + ("cursorleft", RemoteNavAction::Left), + ("right", RemoteNavAction::Right), + ("action(2)", RemoteNavAction::Right), + ("cursorright", RemoteNavAction::Right), + ("select", RemoteNavAction::Select), + ("enter", RemoteNavAction::Select), + ("action(7)", RemoteNavAction::Select), + ("selectitem", RemoteNavAction::Select), + ("back", RemoteNavAction::Back), + ("action(9)", RemoteNavAction::Back), + ("action(10)", RemoteNavAction::Back), + ("previousmenu", RemoteNavAction::Back), + ("parentdir", RemoteNavAction::Back), + ("close", RemoteNavAction::Back), + ]; + + for (act, expected) in cases { + dispatch_action(act, &nav_tx, &player); + let action = nav_rx.recv().await.expect("Expected nav action for action string"); + assert_eq!(action, expected, "Action '{}' should map to {:?}", act, expected); + } + } + + #[tokio::test] + async fn test_udp_helo_and_ping_ack() { + let (nav_tx, _nav_rx) = mpsc::unbounded_channel(); + let player = PlayerController::new(PlayerConfig::default()); + let server = KodiEventServer::new(0, player, nav_tx); + let port = server.start().await.expect("Failed to start EventServer on port 0"); + + let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP"); + let server_addr = format!("127.0.0.1:{}", port); + + // 1. Send HELO + let token = 0x12345678; + let helo_pkt = build_helo_packet("Test Yatse Device", token); + client.send_to(&helo_pkt, &server_addr).await.expect("Send HELO"); + + // Expect PING ACK reply + let mut buf = [0u8; 128]; + let (len, _) = timeout(Duration::from_secs(2), client.recv_from(&mut buf)) + .await + .expect("Timeout waiting for HELO ack") + .expect("Recv HELO ack"); + + assert!(len >= HEADER_SIZE); + assert_eq!(&buf[0..4], b"XBMC"); + let resp_type = u16::from_be_bytes([buf[6], buf[7]]); + assert_eq!(resp_type, PT_PING); + let resp_token = u32::from_be_bytes([buf[18], buf[19], buf[20], buf[21]]); + assert_eq!(resp_token, token); + + // 2. Send PING + let ping_token = 0x87654321; + let ping_pkt = build_ping_packet(ping_token); + client.send_to(&ping_pkt, &server_addr).await.expect("Send PING"); + + let (len2, _) = timeout(Duration::from_secs(2), client.recv_from(&mut buf)) + .await + .expect("Timeout waiting for PING ack") + .expect("Recv PING ack"); + + assert!(len2 >= HEADER_SIZE); + assert_eq!(&buf[0..4], b"XBMC"); + let resp_type2 = u16::from_be_bytes([buf[6], buf[7]]); + assert_eq!(resp_type2, PT_PING); + let resp_token2 = u32::from_be_bytes([buf[18], buf[19], buf[20], buf[21]]); + assert_eq!(resp_token2, ping_token); + } + + #[tokio::test] + async fn test_udp_button_events_over_socket() { + let (nav_tx, mut nav_rx) = mpsc::unbounded_channel(); + let player = PlayerController::new(PlayerConfig::default()); + let server = KodiEventServer::new(0, player, nav_tx); + let port = server.start().await.expect("Failed to start EventServer on port 0"); + + let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP"); + let server_addr = format!("127.0.0.1:{}", port); + + // Send 'Up' button (DOWN flag) + let btn_pkt = build_button_packet(0, PTB_DOWN, "R1", "up", 1); + client.send_to(&btn_pkt, &server_addr).await.expect("Send button"); + + let action = timeout(Duration::from_secs(2), nav_rx.recv()) + .await + .expect("Timeout waiting for button up") + .expect("Channel closed"); + assert_eq!(action, RemoteNavAction::Up); + + // Send 'Select' button (DOWN flag) + let btn_pkt = build_button_packet(0, PTB_DOWN, "R1", "select", 1); + client.send_to(&btn_pkt, &server_addr).await.expect("Send button"); + + let action = timeout(Duration::from_secs(2), nav_rx.recv()) + .await + .expect("Timeout waiting for button select") + .expect("Channel closed"); + assert_eq!(action, RemoteNavAction::Select); + + // Send key release ONLY (PTB_UP flag, without PTB_DOWN) -> Should be ignored + let release_pkt = build_button_packet(0, PTB_UP, "R1", "up", 1); + client.send_to(&release_pkt, &server_addr).await.expect("Send button release"); + + // Wait short time and ensure no action was dispatched + let res = timeout(Duration::from_millis(100), nav_rx.recv()).await; + assert!(res.is_err(), "Key release should be ignored and not trigger an action"); + } + + #[tokio::test] + async fn test_udp_action_events_over_socket() { + let (nav_tx, mut nav_rx) = mpsc::unbounded_channel(); + let player = PlayerController::new(PlayerConfig::default()); + let server = KodiEventServer::new(0, player, nav_tx); + let port = server.start().await.expect("Failed to start EventServer on port 0"); + + let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP"); + let server_addr = format!("127.0.0.1:{}", port); + + // Send ACTION "Action(1)" which is Left + let action_pkt = build_action_packet("Action(1)", 1); + client.send_to(&action_pkt, &server_addr).await.expect("Send action"); + + let action = timeout(Duration::from_secs(2), nav_rx.recv()) + .await + .expect("Timeout waiting for action Left") + .expect("Channel closed"); + assert_eq!(action, RemoteNavAction::Left); + } + + #[tokio::test] + async fn test_udp_malformed_packets_gracefully_ignored() { + let (nav_tx, mut nav_rx) = mpsc::unbounded_channel(); + let player = PlayerController::new(PlayerConfig::default()); + let server = KodiEventServer::new(0, player, nav_tx); + let port = server.start().await.expect("Failed to start EventServer on port 0"); + + let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP"); + let server_addr = format!("127.0.0.1:{}", port); + + // Packet smaller than header size + client.send_to(b"too small", &server_addr).await.expect("Send small"); + + // Packet with invalid signature + let mut bad_sig = build_button_packet(0, PTB_DOWN, "R1", "up", 1); + bad_sig[0..4].copy_from_slice(b"NOPE"); + client.send_to(&bad_sig, &server_addr).await.expect("Send bad sig"); + + // Immediately following with a valid packet must still succeed + let valid_pkt = build_button_packet(0, PTB_DOWN, "R1", "right", 1); + client.send_to(&valid_pkt, &server_addr).await.expect("Send valid"); + + let action = timeout(Duration::from_secs(2), nav_rx.recv()) + .await + .expect("Timeout waiting for action Right") + .expect("Channel closed"); + assert_eq!(action, RemoteNavAction::Right); + } + + #[tokio::test] + async fn test_udp_action_mute() { + let (nav_tx, _nav_rx) = mpsc::unbounded_channel(); + let player = PlayerController::new(PlayerConfig::default()); + let server = KodiEventServer::new(0, player.clone(), nav_tx); + let port = server.start().await.expect("Failed to start EventServer on port 0"); + + let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP"); + let server_addr = format!("127.0.0.1:{}", port); + + assert!(!player.status.lock().await.is_muted); + + // Yatse sends "Mute()" action over EventServer + let mute_pkt = build_action_packet("Mute()", 1); + client.send_to(&mute_pkt, &server_addr).await.expect("Send Mute()"); + + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(player.status.lock().await.is_muted); + + // Send second "Mute()" to unmute + client.send_to(&mute_pkt, &server_addr).await.expect("Send second Mute()"); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(!player.status.lock().await.is_muted); + } +} diff --git a/jellyfin-kiosk-rs/src/jellyfin.rs b/jellyfin-kiosk-rs/src/jellyfin.rs new file mode 100644 index 0000000..a64abcc --- /dev/null +++ b/jellyfin-kiosk-rs/src/jellyfin.rs @@ -0,0 +1,651 @@ +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::RwLock; + +use crate::config::JellyfinConfig; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CardLayout { + Poster, // 2:3 ratio (Movies, TV Shows) - wat kleinere plaatjes (135 x 200) + Square, // 1:1 ratio (Music Albums, Artists) - vierkant (160 x 160) + LandscapeThumb,// 16:9 ratio (Verder kijken, Music Videos) - liggend (250 x 140) + PhotoLarge, // Foto albums - wat grotere plaatjes (280 x 190) +} + +#[derive(Debug, Clone)] +pub enum PlayableTarget { + Stream { + url: String, + title: String, + media_type: String, + artist: Option, + genre: Option, + }, + Photo { url: String, title: String }, + OpenFolder { folder_id: String, name: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MediaItem { + pub id: String, + pub name: String, + pub sort_name: Option, + pub item_type: String, + pub series_name: Option, + pub artist_name: Option, + pub genre: Option, + pub index_number: Option, + pub year: Option, + pub overview: Option, + pub rating: Option, + pub runtime_ticks: Option, + pub played_percentage: Option, + pub is_folder: bool, + pub has_primary_image: bool, + pub has_backdrop_image: bool, + pub parent_primary_image_item_id: Option, + pub parent_backdrop_item_id: Option, +} + +impl MediaItem { + pub fn primary_image_id(&self) -> &str { + if self.has_primary_image { + &self.id + } else if let Some(ref p_id) = self.parent_primary_image_item_id { + p_id + } else if let Some(ref b_id) = self.parent_backdrop_item_id { + b_id + } else { + &self.id + } + } + + pub fn poster_url(&self, server_url: &str) -> String { + let img = self.primary_image_id(); + if img.starts_with("http://") || img.starts_with("https://") { + return img.to_string(); + } + format!("{}/Items/{}/Images/Primary?fillWidth=320&fillHeight=480&quality=85", server_url.trim_end_matches('/'), img) + } + + pub fn thumb_url(&self, server_url: &str) -> String { + let img = self.primary_image_id(); + if img.starts_with("http://") || img.starts_with("https://") { + return img.to_string(); + } + format!("{}/Items/{}/Images/Primary?fillWidth=480&fillHeight=270&quality=85", server_url.trim_end_matches('/'), img) + } + + pub fn square_url(&self, server_url: &str) -> String { + let img = self.primary_image_id(); + if img.starts_with("http://") || img.starts_with("https://") { + return img.to_string(); + } + format!("{}/Items/{}/Images/Primary?fillWidth=300&fillHeight=300&quality=85", server_url.trim_end_matches('/'), img) + } + + pub fn photo_url(&self, server_url: &str) -> String { + let img = self.primary_image_id(); + if img.starts_with("http://") || img.starts_with("https://") { + return img.to_string(); + } + format!("{}/Items/{}/Images/Primary?fillWidth=1280&quality=90", server_url.trim_end_matches('/'), img) + } + + pub fn backdrop_url(&self, server_url: &str) -> String { + let img = self.primary_image_id(); + if img.starts_with("http://") || img.starts_with("https://") { + return img.to_string(); + } + let base = server_url.trim_end_matches('/'); + if self.has_backdrop_image { + format!("{}/Items/{}/Images/Backdrop/0?maxWidth=1920&quality=85", base, self.id) + } else if let Some(ref p_id) = self.parent_backdrop_item_id { + format!("{}/Items/{}/Images/Backdrop/0?maxWidth=1920&quality=85", base, p_id) + } else { + format!("{}/Items/{}/Images/Primary?maxWidth=1280&quality=85", base, self.id) + } + } + + pub fn stream_url(&self, server_url: &str, token: &str) -> String { + let base = server_url.trim_end_matches('/'); + if self.item_type == "Audio" || self.item_type == "MusicAlbum" { + format!("{}/Audio/{}/stream?static=true&api_key={}", base, self.id, token) + } else { + format!("{}/Videos/{}/stream?static=true&api_key={}", base, self.id, token) + } + } + + pub fn formatted_runtime(&self) -> String { + if let Some(ticks) = self.runtime_ticks { + let total_mins = ticks / (10_000_000 * 60); + let hours = total_mins / 60; + let mins = total_mins % 60; + if hours > 0 { + format!("{}u {}m", hours, mins) + } else { + format!("{}m", mins) + } + } else { + String::new() + } + } + + pub fn sort_letter(&self) -> char { + let text = self.sort_name.as_deref().unwrap_or(&self.name); + let trimmed = text.trim().trim_start_matches(|c: char| !c.is_alphanumeric()); + for c in trimmed.chars() { + if c.is_ascii_digit() { + return '#'; + } + if c.is_ascii_alphabetic() { + return c.to_ascii_uppercase(); + } + } + '#' + } +} + +pub fn sort_media_items_alphabetically(items: &mut [MediaItem]) { + items.sort_by(|a, b| { + let la = a.sort_letter(); + let lb = b.sort_letter(); + let order_a = if la == '#' { 0 } else { (la as u32) + 1 }; + let order_b = if lb == '#' { 0 } else { (lb as u32) + 1 }; + order_a.cmp(&order_b).then_with(|| { + let na = a.sort_name.as_deref().unwrap_or(&a.name).trim_start_matches(|c: char| !c.is_alphanumeric()); + let nb = b.sort_name.as_deref().unwrap_or(&b.name).trim_start_matches(|c: char| !c.is_alphanumeric()); + na.to_lowercase().cmp(&nb.to_lowercase()) + }) + }); +} + + +#[derive(Debug, Clone, Default)] +pub struct MediaLibrary { + // Dashboard (Home) + pub resume_items: Vec, + pub latest_movies: Vec, + pub latest_shows: Vec, + pub latest_music: Vec, + + // Libraries + pub all_movies: Vec, + pub all_shows: Vec, + pub music_artists: Vec, + pub music_albums: Vec, + pub music_videos: Vec, + pub photos: Vec, + pub live_channels: Vec, + pub favorites: Vec, +} + +#[derive(Clone)] +pub struct JellyfinClient { + pub config: JellyfinConfig, + client: reqwest::Client, + pub token: Arc>>, + pub user_id: Arc>>, +} + +impl JellyfinClient { + pub fn new(config: JellyfinConfig) -> Self { + Self { + config, + client: reqwest::Client::new(), + token: Arc::new(RwLock::new(None)), + user_id: Arc::new(RwLock::new(None)), + } + } + + fn auth_header_value(&self, token: Option<&str>) -> String { + let base = format!( + "MediaBrowser Client=\"{}\", Device=\"{}\", DeviceId=\"{}\", Version=\"1.0.0\"", + self.config.client_name, self.config.device_name, self.config.device_id + ); + if let Some(t) = token { + format!("{}, Token=\"{}\"", base, t) + } else { + base + } + } + + pub async fn authenticate(&self) -> Result<(), Box> { + let url = format!("{}/Users/AuthenticateByName", self.config.server_url.trim_end_matches('/')); + let auth_val = self.auth_header_value(None); + + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_val)?); + + let payload = serde_json::json!({ + "Username": self.config.username, + "Pw": self.config.password.clone().unwrap_or_default(), + }); + + let resp = self.client.post(&url) + .headers(headers) + .json(&payload) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + return Err(format!("Jellyfin authentication failed: {}", err).into()); + } + + let data: serde_json::Value = resp.json().await?; + let token = data["AccessToken"].as_str().ok_or("No AccessToken in response")?.to_string(); + let user_id = data["User"]["Id"].as_str().ok_or("No User.Id in response")?.to_string(); + + *self.token.write().await = Some(token); + *self.user_id.write().await = Some(user_id); + + tracing::info!("Authenticated successfully with Jellyfin as {}", self.config.username); + Ok(()) + } + + async fn get_authed_headers(&self) -> Result> { + let token_guard = self.token.read().await; + let token = token_guard.as_deref().ok_or("Not authenticated")?; + let auth_val = self.auth_header_value(Some(token)); + + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_val)?); + headers.insert("X-Emby-Token", HeaderValue::from_str(token)?); + Ok(headers) + } + + pub async fn fetch_folder_items(&self, folder_id: &str) -> Vec { + let base_url = self.config.server_url.trim_end_matches('/'); + if let Ok(headers) = self.get_authed_headers().await { + let user_id = self.user_id.read().await.clone().unwrap_or_default(); + // 1. Try standard folder items with parentId + let url = format!( + "{}/Users/{}/Items?parentId={}&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres", + base_url, user_id, folder_id + ); + let mut items = fetch_endpoint(&self.client, &url, &headers).await; + + // 2. If empty, maybe this is an artist: try fetching albums via albumArtistIds + if items.is_empty() { + let url_albums = format!( + "{}/Users/{}/Items?albumArtistIds={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres", + base_url, user_id, folder_id + ); + items = fetch_endpoint(&self.client, &url_albums, &headers).await; + } + + // 3. If still empty, try artistIds for albums + if items.is_empty() { + let url_albums2 = format!( + "{}/Users/{}/Items?artistIds={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres", + base_url, user_id, folder_id + ); + items = fetch_endpoint(&self.client, &url_albums2, &headers).await; + } + + // 4. If still empty (e.g. ABBA, *NSYNC who have no albums, only compilation tracks), fetch individual audio songs directly + if items.is_empty() { + let url_songs = format!( + "{}/Users/{}/Items?artistIds={}&includeItemTypes=Audio&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres", + base_url, user_id, folder_id + ); + items = fetch_endpoint(&self.client, &url_songs, &headers).await; + } + + // If the folder items are Audio tracks, sort them by index_number + if items.iter().any(|i| i.item_type == "Audio") { + items.sort_by_key(|i| i.index_number.unwrap_or(u32::MAX)); + } else { + sort_media_items_alphabetically(&mut items); + } + return items; + } + Vec::new() + } + + pub async fn resolve_playable_item(&self, item: &MediaItem) -> Result> { + let base_url = self.config.server_url.trim_end_matches('/'); + let token = { + let guard = self.token.read().await; + guard.clone().unwrap_or_default() + }; + let user_id = { + let guard = self.user_id.read().await; + guard.clone().unwrap_or_default() + }; + + // 0. YouTube Video or direct stream URL + if item.item_type == "YouTubeVideo" + || item.id.starts_with("https://www.youtube.com") + || item.id.starts_with("https://youtu.be") + { + return Ok(PlayableTarget::Stream { + url: item.id.clone(), + title: item.name.clone(), + media_type: "stream".to_string(), + artist: item.series_name.clone(), + genre: item.genre.clone(), + }); + } + + // 1. Photo item -> view photo + if item.item_type == "Photo" { + return Ok(PlayableTarget::Photo { + url: item.photo_url(base_url), + title: item.name.clone(), + }); + } + + // 2. Series -> resolve next unplayed episode + if item.item_type == "Series" { + if let Ok(headers) = self.get_authed_headers().await { + let next_url = format!("{}/Shows/NextUp?seriesId={}&userId={}&fields=Genres", base_url, item.id, user_id); + let episodes = fetch_endpoint(&self.client, &next_url, &headers).await; + if let Some(ep) = episodes.first() { + return Ok(PlayableTarget::Stream { + url: ep.stream_url(base_url, &token), + title: format!("{}: {}", item.name, ep.name), + media_type: "video".to_string(), + artist: None, + genre: ep.genre.clone().or_else(|| item.genre.clone()), + }); + } + // Fallback: episode 1 + let ep1_url = format!( + "{}/Users/{}/Items?parentId={}&includeItemTypes=Episode&recursive=true&sortBy=SortName&limit=1&fields=Genres", + base_url, user_id, item.id + ); + let ep1 = fetch_endpoint(&self.client, &ep1_url, &headers).await; + if let Some(ep) = ep1.first() { + return Ok(PlayableTarget::Stream { + url: ep.stream_url(base_url, &token), + title: format!("{}: {}", item.name, ep.name), + media_type: "video".to_string(), + artist: None, + genre: ep.genre.clone().or_else(|| item.genre.clone()), + }); + } + } + } + + // 3. PhotoAlbum, Folder, MusicArtist, or MusicAlbum -> Open folder in UI + if item.item_type == "PhotoAlbum" + || item.item_type == "MusicArtist" + || item.item_type == "MusicAlbum" + || (item.is_folder && item.item_type != "Series") + { + return Ok(PlayableTarget::OpenFolder { + folder_id: item.id.clone(), + name: item.name.clone(), + }); + } + + // 5. Standard playable stream (Movie, Episode, Video, MusicVideo, Audio, TvChannel) + let (media_type, artist) = match item.item_type.as_str() { + "Audio" => ("music".to_string(), item.artist_name.clone()), + "TvChannel" => ("stream".to_string(), None), + "MusicVideo" => ("video".to_string(), item.artist_name.clone()), + _ => ("video".to_string(), None), + }; + + Ok(PlayableTarget::Stream { + url: item.stream_url(base_url, &token), + title: item.name.clone(), + media_type, + artist, + genre: item.genre.clone(), + }) + } + + pub async fn fetch_all_media(&self) -> Result> { + if self.token.read().await.is_none() { + self.authenticate().await?; + } + + let user_id = { + let guard = self.user_id.read().await; + guard.clone().ok_or("Missing user_id")? + }; + + let headers = self.get_authed_headers().await?; + let base_url = self.config.server_url.trim_end_matches('/'); + + // 1. Fetch Views to discover all library categories + let views_url = format!("{}/Users/{}/Views", base_url, user_id); + let views_resp: serde_json::Value = self.client.get(&views_url) + .headers(headers.clone()) + .send() + .await? + .json() + .await?; + + let mut movies_id: Option = None; + let mut shows_id: Option = None; + let mut music_id: Option = None; + let mut photo_id: Option = None; + let mut music_video_id: Option = None; + + if let Some(items) = views_resp["Items"].as_array() { + for v in items { + let col_type = v["CollectionType"].as_str().unwrap_or(""); + let name = v["Name"].as_str().unwrap_or("").to_lowercase(); + let id = v["Id"].as_str().unwrap_or("").to_string(); + + if col_type == "movies" || name.contains("movie") || name.contains("film") { + if movies_id.is_none() { movies_id = Some(id); } + } else if col_type == "tvshows" || name.contains("show") || name.contains("serie") { + if shows_id.is_none() { shows_id = Some(id); } + } else if col_type == "music" && !name.contains("video") { + if music_id.is_none() { music_id = Some(id); } + } else if col_type == "musicvideos" || name.contains("music video") { + if music_video_id.is_none() { music_video_id = Some(id); } + } else if col_type == "homevideos" || col_type == "photos" || name.contains("foto") || name.contains("photo") { + if photo_id.is_none() { photo_id = Some(id); } + } + } + } + + // 2. Fetch Resume / Next Up + let resume_url = format!( + "{}/UserItems/Resume?userId={}&limit=16&fields=Overview,PrimaryImageAspectRatio,SeriesName,ParentBackdropItemId,UserData", + base_url, user_id + ); + let resume_items = fetch_endpoint(&self.client, &resume_url, &headers).await; + + // 3. Fetch Latest & All Movies + let mut latest_movies = Vec::new(); + let mut all_movies = Vec::new(); + if let Some(ref m_id) = movies_id { + let url_latest = format!( + "{}/Users/{}/Items?parentId={}&includeItemTypes=Movie&sortBy=DateCreated&sortOrder=Descending&limit=25&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName", + base_url, user_id, m_id + ); + latest_movies = fetch_endpoint(&self.client, &url_latest, &headers).await; + + let url_all = format!( + "{}/Users/{}/Items?parentId={}&includeItemTypes=Movie&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName", + base_url, user_id, m_id + ); + all_movies = fetch_endpoint(&self.client, &url_all, &headers).await; + sort_media_items_alphabetically(&mut all_movies); + } + + // 4. Fetch Latest & All Shows + let mut latest_shows = Vec::new(); + let mut all_shows = Vec::new(); + if let Some(ref s_id) = shows_id { + let url_latest = format!( + "{}/Users/{}/Items?parentId={}&includeItemTypes=Series&sortBy=DateCreated&sortOrder=Descending&limit=25&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName", + base_url, user_id, s_id + ); + latest_shows = fetch_endpoint(&self.client, &url_latest, &headers).await; + + let url_all = format!( + "{}/Users/{}/Items?parentId={}&includeItemTypes=Series&sortBy=SortName&sortOrder=Ascending&limit=1000&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName", + base_url, user_id, s_id + ); + all_shows = fetch_endpoint(&self.client, &url_all, &headers).await; + sort_media_items_alphabetically(&mut all_shows); + } + + // 5. Fetch Music (Latest Albums, Artists, and All Albums) + let mut latest_music = Vec::new(); + let mut music_artists = Vec::new(); + let mut music_albums = Vec::new(); + if let Some(ref mus_id) = music_id { + let url_latest_mus = format!( + "{}/Users/{}/Items?parentId={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=DateCreated&sortOrder=Descending&limit=25&fields=Overview,ProductionYear,AlbumArtist,SortName", + base_url, user_id, mus_id + ); + latest_music = fetch_endpoint(&self.client, &url_latest_mus, &headers).await; + + // Fetch artists using dedicated /Artists endpoint first + let url_artists = format!( + "{}/Artists?userId={}&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,SortName", + base_url, user_id + ); + music_artists = fetch_endpoint(&self.client, &url_artists, &headers).await; + if music_artists.is_empty() { + let url_artists_fb = format!( + "{}/Users/{}/Items?parentId={}&includeItemTypes=MusicArtist&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,SortName", + base_url, user_id, mus_id + ); + music_artists = fetch_endpoint(&self.client, &url_artists_fb, &headers).await; + } + sort_media_items_alphabetically(&mut music_artists); + + let url_albums = format!( + "{}/Users/{}/Items?parentId={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,ProductionYear,AlbumArtist,SortName", + base_url, user_id, mus_id + ); + music_albums = fetch_endpoint(&self.client, &url_albums, &headers).await; + sort_media_items_alphabetically(&mut music_albums); + } + + // 6. Fetch Music Videos + let mut music_videos = Vec::new(); + if let Some(ref mv_id) = music_video_id { + let url_mv = format!( + "{}/Users/{}/Items?parentId={}&includeItemTypes=MusicVideo&sortBy=DateCreated&sortOrder=Descending&limit=200&fields=Overview,Artist,SortName", + base_url, user_id, mv_id + ); + music_videos = fetch_endpoint(&self.client, &url_mv, &headers).await; + sort_media_items_alphabetically(&mut music_videos); + } + + // 7. Fetch Photos / Home Videos + let mut photos = Vec::new(); + if let Some(ref p_id) = photo_id { + let url_photo = format!( + "{}/Users/{}/Items?parentId={}&sortBy=DateCreated&sortOrder=Descending&limit=500&fields=Overview,SortName", + base_url, user_id, p_id + ); + photos = fetch_endpoint(&self.client, &url_photo, &headers).await; + sort_media_items_alphabetically(&mut photos); + } + + // 8. Fetch Live TV Channels + let url_live = format!("{}/LiveTv/Channels?userId={}&limit=100", base_url, user_id); + let live_channels = fetch_endpoint(&self.client, &url_live, &headers).await; + + // 9. Fetch Favorites + let url_fav = format!( + "{}/Users/{}/Items?userId={}&isFavorite=true&recursive=true&limit=200&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,SortName", + base_url, user_id, user_id + ); + let mut favorites = fetch_endpoint(&self.client, &url_fav, &headers).await; + sort_media_items_alphabetically(&mut favorites); + + Ok(MediaLibrary { + resume_items, + latest_movies, + latest_shows, + latest_music, + all_movies, + all_shows, + music_artists, + music_albums, + music_videos, + photos, + live_channels, + favorites, + }) + } +} + +async fn fetch_endpoint(client: &reqwest::Client, url: &str, headers: &HeaderMap) -> Vec { + if let Ok(resp) = client.get(url).headers(headers.clone()).send().await { + if let Ok(json) = resp.json::().await { + return parse_items(&json); + } + } + Vec::new() +} + +fn parse_items(json: &serde_json::Value) -> Vec { + let mut out = Vec::new(); + if let Some(items) = json["Items"].as_array() { + for it in items { + let id = it["Id"].as_str().unwrap_or("").to_string(); + let name = it["Name"].as_str().unwrap_or("Onbekend").to_string(); + let item_type = it["Type"].as_str().unwrap_or("Video").to_string(); + let series_name = it["SeriesName"].as_str().map(|s| s.to_string()); + let artist_name = it["AlbumArtist"].as_str().or_else(|| it["Artist"].as_str()).map(|s| s.to_string()); + let genre = it["Genres"].as_array().and_then(|arr| { + let list: Vec<&str> = arr.iter().filter_map(|g| g.as_str()).collect(); + if list.is_empty() { + None + } else { + Some(list.join(", ")) + } + }).or_else(|| it["Genre"].as_str().map(|s| s.to_string())); + let year = it["ProductionYear"].as_u64().map(|y| y as u32); + let overview = it["Overview"].as_str().map(|s| s.to_string()); + let rating = it["CommunityRating"].as_f64().map(|r| r as f32); + let runtime_ticks = it["RunTimeTicks"].as_u64(); + let played_percentage = it["UserData"]["PlayedPercentage"].as_f64().map(|p| p as f32); + let is_folder = it["IsFolder"].as_bool().unwrap_or(false); + let has_primary_image = it["ImageTags"]["Primary"].is_string(); + let has_backdrop_image = it["BackdropImageTags"].as_array().map_or(false, |b| !b.is_empty()); + let parent_primary_image_item_id = it["ParentPrimaryImageItemId"] + .as_str() + .or_else(|| it["AlbumId"].as_str()) + .or_else(|| it["SeriesId"].as_str()) + .map(|s| s.to_string()); + let parent_backdrop_item_id = it["ParentBackdropItemId"] + .as_str() + .or_else(|| it["SeriesId"].as_str()) + .map(|s| s.to_string()); + + let sort_name = it["SortName"].as_str().map(|s| s.to_string()); + let index_number = it["IndexNumber"].as_u64().map(|n| n as u32); + + if !id.is_empty() { + out.push(MediaItem { + id, + name, + sort_name, + item_type, + series_name, + artist_name, + genre, + index_number, + year, + overview, + rating, + runtime_ticks, + played_percentage, + is_folder, + has_primary_image, + has_backdrop_image, + parent_primary_image_item_id, + parent_backdrop_item_id, + }); + } + } + } + out +} diff --git a/jellyfin-kiosk-rs/src/main.rs b/jellyfin-kiosk-rs/src/main.rs new file mode 100644 index 0000000..9b5a59b --- /dev/null +++ b/jellyfin-kiosk-rs/src/main.rs @@ -0,0 +1,147 @@ +use eframe::egui::Vec2; +use tokio::sync::mpsc; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +mod app; +mod config; +mod eventserver; +mod jellyfin; +mod mqtt; +mod player; +mod yatse; +mod youtube; + +use app::KioskApp; +use config::AppConfig; +use eventserver::KodiEventServer; +use jellyfin::JellyfinClient; +use player::PlayerController; +use yatse::{create_yatse_router, YatseServerState}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) + .with(tracing_subscriber::fmt::layer()) + .init(); + + tracing::info!("Starting Jellyfin HTPC Kiosk (Rust)..."); + + // 1. Load Configuration + let config = AppConfig::load()?; + + // 2. Initialize Media Player (MPV Controller) + let player = PlayerController::new(config.player.clone()); + + // 3. Navigation channel (Yatse Remote -> UI) + let (nav_tx, nav_rx) = mpsc::unbounded_channel(); + + // 4. Start Yatse Kodi JSON-RPC Server & EventServer + if config.kodi_rpc.enabled { + let yatse_state = YatseServerState { + player: player.clone(), + nav_tx: nav_tx.clone(), + }; + let app_router = create_yatse_router(yatse_state); + let port = config.kodi_rpc.http_port; + + tokio::spawn(async move { + let addr = format!("0.0.0.0:{}", port); + tracing::info!("Yatse Kodi JSON-RPC server listening on http://{}", addr); + if let Ok(listener) = tokio::net::TcpListener::bind(&addr).await { + let _ = axum::serve(listener, app_router).await; + } + }); + + // Start Kodi EventServer (UDP 9777) for Yatse + let event_server = KodiEventServer::new(9777, player.clone(), nav_tx); + let _ = event_server.start().await; + + // Zeroconf / mDNS Advertisement for Yatse automatic discovery + tokio::spawn(async move { + let hostname = std::fs::read_to_string("/etc/hostname") + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| std::env::var("HOSTNAME").unwrap_or_else(|_| "kiosk".into())); + let service_name = format!("Kodi ({})", hostname); + + // Announce JSON-RPC HTTP on port 8080 + let _http_child = std::process::Command::new("avahi-publish-service") + .args(["-s", &service_name, "_xbmc-jsonrpc-h._tcp", &port.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + + // Announce EventServer UDP on port 9777 + let _event_child = std::process::Command::new("avahi-publish-service") + .args(["-s", &service_name, "_xbmc-events._udp", "9777"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + + tracing::info!("Published Zeroconf services for Yatse discovery: '{}'", service_name); + }); + } + + // 5. Start MQTT Service & Home Assistant Discovery if enabled + if config.mqtt.enabled { + mqtt::MqttService::start(config.clone(), player.clone()); + } + + // 6. Initialize Jellyfin Client + let client = JellyfinClient::new(config.jellyfin.clone()); + + // 7. Launch Native GUI via eframe (runs on main thread) + let native_options = eframe::NativeOptions { + viewport: eframe::egui::ViewportBuilder::default() + .with_title("Jellyfin HTPC Kiosk") + .with_inner_size(Vec2::new(1280.0, 720.0)) + .with_fullscreen(config.player.fullscreen), + ..Default::default() + }; + + let app_config = config.clone(); + let app_client = client.clone(); + let app_player = player.clone(); + + // Run eframe app (blocks until window is closed) + tokio::task::block_in_place(|| { + eframe::run_native( + "Jellyfin HTPC Kiosk", + native_options, + Box::new(move |cc| { + // Install egui image loaders (http, png, jpg) + egui_extras::install_image_loaders(&cc.egui_ctx); + + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + if let Ok(handle) = cc.window_handle() { + match handle.as_raw() { + RawWindowHandle::Xlib(h) => { + let p = app_player.clone(); + let wid = h.window as u64; + tokio::spawn(async move { + p.set_window_id(Some(wid)).await; + }); + } + RawWindowHandle::Xcb(h) => { + let p = app_player.clone(); + let wid = h.window.get() as u64; + tokio::spawn(async move { + p.set_window_id(Some(wid)).await; + }); + } + _ => {} + } + } + Ok(Box::new(KioskApp::new( + app_config, + app_client, + app_player, + nav_rx, + ))) + }), + ) + }) + .map_err(|e| format!("GUI runtime error: {}", e))?; + + Ok(()) +} diff --git a/jellyfin-kiosk-rs/src/mqtt.rs b/jellyfin-kiosk-rs/src/mqtt.rs new file mode 100644 index 0000000..cde99c4 --- /dev/null +++ b/jellyfin-kiosk-rs/src/mqtt.rs @@ -0,0 +1,449 @@ +use std::sync::Arc; +use std::time::Duration; +use rumqttc::{AsyncClient, LastWill, MqttOptions, Packet, QoS}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::config::{AppConfig, MqttConfig}; +use crate::player::{PlaybackState, PlayerController, PlayerStatus}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MqttPlaybackState { + pub state: String, + pub media_type: String, + pub title: String, + pub artist: String, + pub genre: String, + pub stream_title: String, +} + +impl MqttPlaybackState { + pub fn from_status(st: &PlayerStatus) -> Self { + if st.state == PlaybackState::Stopped { + Self { + state: "stopped".to_string(), + media_type: String::new(), + title: String::new(), + artist: String::new(), + genre: String::new(), + stream_title: String::new(), + } + } else { + let state_str = match st.state { + PlaybackState::Playing => "playing", + PlaybackState::Paused => "paused", + PlaybackState::Stopped => "stopped", + }; + Self { + state: state_str.to_string(), + media_type: st.media_type.clone(), + title: st.title.clone(), + artist: st.artist.clone(), + genre: st.genre.clone(), + stream_title: st.stream_title.clone(), + } + } + } +} + +pub struct MqttService; + +impl MqttService { + pub fn start(config: AppConfig, player: Arc) { + tokio::spawn(async move { + run_mqtt_loop(config, player).await; + }); + } +} + +async fn run_mqtt_loop(config: AppConfig, player: Arc) { + let mqtt_cfg = &config.mqtt; + let device_id = &config.jellyfin.device_id; + let device_name = &config.jellyfin.device_name; + + let client_id = mqtt_cfg.client_id.clone().unwrap_or_else(|| { + format!("{}-{}", mqtt_cfg.topic_prefix, device_id) + }); + + let avail_topic = format!("{}/{}/availability", mqtt_cfg.topic_prefix, device_id); + let command_topic = format!("{}/{}/command", mqtt_cfg.topic_prefix, device_id); + + let mut mqtt_opts = MqttOptions::new(&client_id, &mqtt_cfg.host, mqtt_cfg.port); + mqtt_opts.set_keep_alive(Duration::from_secs(15)); + if let (Some(u), Some(p)) = (&mqtt_cfg.username, &mqtt_cfg.password) { + if !u.is_empty() { + mqtt_opts.set_credentials(u, p); + } + } + mqtt_opts.set_last_will(LastWill::new( + &avail_topic, + "offline", + QoS::AtLeastOnce, + true, + )); + + tracing::info!( + "Starting MQTT Service: broker={}:{}, client_id='{}', device='{}'", + mqtt_cfg.host, + mqtt_cfg.port, + client_id, + device_name + ); + + let mut status_rx = player.status_watch_rx(); + let mut last_published: Option = None; + + loop { + let (client, mut eventloop) = AsyncClient::new(mqtt_opts.clone(), 30); + let mut is_connected = false; + + loop { + tokio::select! { + event_res = eventloop.poll() => { + match event_res { + Ok(rumqttc::Event::Incoming(Packet::ConnAck(_))) => { + is_connected = true; + tracing::info!("MQTT broker connected ({}:{})", mqtt_cfg.host, mqtt_cfg.port); + + // 1. Announce online availability + let _ = client.publish(&avail_topic, QoS::AtLeastOnce, true, "online").await; + + // 2. Subscribe to control commands + let _ = client.subscribe(&command_topic, QoS::AtLeastOnce).await; + + // 3. Publish Home Assistant Discovery configs + publish_homeassistant_discovery(&client, mqtt_cfg, device_id, device_name).await; + + // 4. Publish current playback state + let current_st = { + let guard = player.status.lock().await; + MqttPlaybackState::from_status(&guard) + }; + publish_playback_state(&client, mqtt_cfg, device_id, ¤t_st).await; + last_published = Some(current_st); + } + Ok(rumqttc::Event::Incoming(Packet::Publish(p))) => { + if p.topic == command_topic { + if let Ok(cmd_str) = std::str::from_utf8(&p.payload) { + handle_remote_command(&player, cmd_str.trim()).await; + } + } + } + Ok(_) => {} + Err(e) => { + tracing::warn!("MQTT connection lost or error: {}. Reconnecting...", e); + tokio::time::sleep(Duration::from_secs(3)).await; + break; // break inner loop to re-create client/eventloop + } + } + } + changed_res = status_rx.changed() => { + if changed_res.is_ok() && is_connected { + let st = status_rx.borrow().clone(); + let new_state = MqttPlaybackState::from_status(&st); + if last_published.as_ref() != Some(&new_state) { + publish_playback_state(&client, mqtt_cfg, device_id, &new_state).await; + last_published = Some(new_state); + } + } + } + } + } + } +} + +pub async fn publish_homeassistant_discovery( + client: &AsyncClient, + mqtt_cfg: &MqttConfig, + device_id: &str, + device_name: &str, +) { + let state_topic = format!("{}/{}/state", mqtt_cfg.topic_prefix, device_id); + let avail_topic = format!("{}/{}/availability", mqtt_cfg.topic_prefix, device_id); + + let device_obj = json!({ + "identifiers": [device_id], + "name": device_name, + "model": "Jellyfin HTPC Kiosk", + "manufacturer": "Jellyfin", + "sw_version": env!("CARGO_PKG_VERSION") + }); + + let availability_obj = json!([{ + "topic": avail_topic, + "payload_available": "online", + "payload_not_available": "offline" + }]); + + struct SensorConfig<'a> { + object_id: &'a str, + name: &'a str, + value_template: &'a str, + icon: &'a str, + } + + let sensors = [ + SensorConfig { + object_id: "media_type", + name: "Media Type", + value_template: "{{ value_json.media_type }}", + icon: "mdi:play-box", + }, + SensorConfig { + object_id: "title", + name: "Title", + value_template: "{{ value_json.title }}", + icon: "mdi:movie-open", + }, + SensorConfig { + object_id: "artist", + name: "Artist", + value_template: "{{ value_json.artist }}", + icon: "mdi:account-music", + }, + SensorConfig { + object_id: "genre", + name: "Genre", + value_template: "{{ value_json.genre }}", + icon: "mdi:tag-text-outline", + }, + SensorConfig { + object_id: "stream_title", + name: "Stream Title", + value_template: "{{ value_json.stream_title }}", + icon: "mdi:radio", + }, + SensorConfig { + object_id: "playback_state", + name: "Playback State", + value_template: "{{ value_json.state }}", + icon: "mdi:play-pause", + }, + ]; + + for s in &sensors { + let disc_topic = format!( + "{}/sensor/{}/{}/config", + mqtt_cfg.discovery_prefix, device_id, s.object_id + ); + let payload = json!({ + "name": format!("{} {}", device_name, s.name), + "unique_id": format!("{}_{}", device_id, s.object_id), + "state_topic": state_topic, + "value_template": s.value_template, + "json_attributes_topic": state_topic, + "icon": s.icon, + "device": device_obj, + "availability": availability_obj + }); + + if let Err(e) = client.publish(&disc_topic, QoS::AtLeastOnce, true, payload.to_string()).await { + tracing::warn!("Failed to publish Home Assistant discovery for {}: {}", s.object_id, e); + } else { + tracing::debug!("Published HA discovery: {}", disc_topic); + } + } + + tracing::info!("Published Home Assistant MQTT discovery for device '{}'", device_id); +} + +pub async fn publish_playback_state( + client: &AsyncClient, + mqtt_cfg: &MqttConfig, + device_id: &str, + state: &MqttPlaybackState, +) { + let payload = json!({ + "state": state.state, + "media_type": state.media_type, + "title": state.title, + "artist": state.artist, + "genre": state.genre, + "stream_title": state.stream_title, + }); + + let state_topic = format!("{}/{}/state", mqtt_cfg.topic_prefix, device_id); + let _ = client.publish(&state_topic, QoS::AtLeastOnce, true, payload.to_string()).await; + + // Subtopics for direct subscriptions + let _ = client.publish(format!("{}/{}/media_type", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.media_type.as_str()).await; + let _ = client.publish(format!("{}/{}/title", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.title.as_str()).await; + let _ = client.publish(format!("{}/{}/artist", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.artist.as_str()).await; + let _ = client.publish(format!("{}/{}/genre", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.genre.as_str()).await; + let _ = client.publish(format!("{}/{}/stream_title", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.stream_title.as_str()).await; + let _ = client.publish(format!("{}/{}/playback_state", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.state.as_str()).await; + + tracing::info!( + "MQTT state published: media_type='{}', title='{}', artist='{}', genre='{}', stream_title='{}', state='{}'", + state.media_type, + state.title, + state.artist, + state.genre, + state.stream_title, + state.state + ); +} + +async fn handle_remote_command(player: &Arc, cmd: &str) { + tracing::info!("Received MQTT remote command: '{}'", cmd); + match cmd { + "play_pause" | "toggle" => player.play_pause().await, + "pause" => { + let st = player.status.lock().await; + if st.state == PlaybackState::Playing { + drop(st); + player.play_pause().await; + } + } + "play" => { + let st = player.status.lock().await; + if st.state == PlaybackState::Paused { + drop(st); + player.play_pause().await; + } + } + "stop" => player.stop().await, + "toggle_mute" | "mute" => player.toggle_mute().await, + s if s.starts_with("volume:") => { + if let Ok(vol) = s["volume:".len()..].trim().parse::() { + player.set_volume(vol).await; + } + } + _ => { + tracing::warn!("Unrecognized MQTT command: '{}'", cmd); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::player::PlaybackState; + + #[test] + fn test_stopped_state_has_empty_media_type_and_metadata() { + let st = PlayerStatus { + state: PlaybackState::Stopped, + media_type: "music".to_string(), + title: "Previous Track".to_string(), + artist: "Previous Artist".to_string(), + genre: "Rock".to_string(), + stream_title: "".to_string(), + time_pos: 0.0, + duration: 0.0, + volume: 100.0, + is_muted: false, + error_message: None, + }; + + let mqtt_state = MqttPlaybackState::from_status(&st); + assert_eq!(mqtt_state.state, "stopped"); + assert_eq!(mqtt_state.media_type, ""); + assert_eq!(mqtt_state.title, ""); + assert_eq!(mqtt_state.artist, ""); + assert_eq!(mqtt_state.genre, ""); + assert_eq!(mqtt_state.stream_title, ""); + } + + #[test] + fn test_playing_music_state() { + let st = PlayerStatus { + state: PlaybackState::Playing, + media_type: "music".to_string(), + title: "Bohemian Rhapsody".to_string(), + artist: "Queen".to_string(), + genre: "Rock".to_string(), + stream_title: "".to_string(), + time_pos: 42.0, + duration: 354.0, + volume: 85.0, + is_muted: false, + error_message: None, + }; + + let mqtt_state = MqttPlaybackState::from_status(&st); + assert_eq!(mqtt_state.state, "playing"); + assert_eq!(mqtt_state.media_type, "music"); + assert_eq!(mqtt_state.title, "Bohemian Rhapsody"); + assert_eq!(mqtt_state.artist, "Queen"); + assert_eq!(mqtt_state.genre, "Rock"); + assert_eq!(mqtt_state.stream_title, ""); + } + + #[test] + fn test_playing_stream_with_icy_metadata() { + let st = PlayerStatus { + state: PlaybackState::Playing, + media_type: "stream".to_string(), + title: "Radio Veronica".to_string(), + artist: "Pink Floyd".to_string(), + genre: "Classic Rock".to_string(), + stream_title: "Pink Floyd - Comfortably Numb".to_string(), + time_pos: 120.0, + duration: 0.0, + volume: 90.0, + is_muted: false, + error_message: None, + }; + + let mqtt_state = MqttPlaybackState::from_status(&st); + assert_eq!(mqtt_state.state, "playing"); + assert_eq!(mqtt_state.media_type, "stream"); + assert_eq!(mqtt_state.title, "Radio Veronica"); + assert_eq!(mqtt_state.artist, "Pink Floyd"); + assert_eq!(mqtt_state.genre, "Classic Rock"); + assert_eq!(mqtt_state.stream_title, "Pink Floyd - Comfortably Numb"); + } + + #[test] + fn test_playing_video_state() { + let st = PlayerStatus { + state: PlaybackState::Playing, + media_type: "video".to_string(), + title: "Interstellar".to_string(), + artist: "".to_string(), + genre: "Sci-Fi".to_string(), + stream_title: "".to_string(), + time_pos: 1000.0, + duration: 10140.0, + volume: 100.0, + is_muted: false, + error_message: None, + }; + + let mqtt_state = MqttPlaybackState::from_status(&st); + assert_eq!(mqtt_state.state, "playing"); + assert_eq!(mqtt_state.media_type, "video"); + assert_eq!(mqtt_state.title, "Interstellar"); + assert_eq!(mqtt_state.artist, ""); + assert_eq!(mqtt_state.genre, "Sci-Fi"); + assert_eq!(mqtt_state.stream_title, ""); + } + + #[test] + fn test_yaml_config_deserialization() { + let yaml_str = r#" +jellyfin: + server_url: "https://jellyfin.example.com" + username: "testuser" + device_id: "living-room-01" + device_name: "Living Room Kiosk" +mqtt: + enabled: true + host: "192.168.1.50" + port: 1883 + username: "mqtt_user" + password: "mqtt_password" + discovery_prefix: "homeassistant" + topic_prefix: "kiosk" +"#; + let cfg: AppConfig = serde_yaml::from_str(yaml_str).expect("Failed to deserialize AppConfig"); + assert!(cfg.mqtt.enabled); + assert_eq!(cfg.mqtt.host, "192.168.1.50"); + assert_eq!(cfg.mqtt.port, 1883); + assert_eq!(cfg.mqtt.username.as_deref(), Some("mqtt_user")); + assert_eq!(cfg.mqtt.password.as_deref(), Some("mqtt_password")); + assert_eq!(cfg.mqtt.discovery_prefix, "homeassistant"); + assert_eq!(cfg.mqtt.topic_prefix, "kiosk"); + } +} diff --git a/jellyfin-kiosk-rs/src/player.rs b/jellyfin-kiosk-rs/src/player.rs new file mode 100644 index 0000000..31ec732 --- /dev/null +++ b/jellyfin-kiosk-rs/src/player.rs @@ -0,0 +1,494 @@ +use serde::{Deserialize, Serialize}; +use std::process::{Child, Command}; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tokio::sync::Mutex; + +use crate::config::PlayerConfig; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PlaybackState { + Stopped, + Playing, + Paused, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlayerStatus { + pub state: PlaybackState, + pub media_type: String, + pub title: String, + pub artist: String, + pub genre: String, + pub stream_title: String, + pub time_pos: f64, + pub duration: f64, + pub volume: f64, + pub is_muted: bool, + pub error_message: Option, +} + +impl Default for PlayerStatus { + fn default() -> Self { + Self { + state: PlaybackState::Stopped, + media_type: String::new(), + title: String::new(), + artist: String::new(), + genre: String::new(), + stream_title: String::new(), + time_pos: 0.0, + duration: 0.0, + volume: 100.0, + is_muted: false, + error_message: None, + } + } +} + +pub struct PlayerController { + config: PlayerConfig, + child_process: Mutex>, + pub status: Arc>, + status_tx: tokio::sync::watch::Sender, + status_rx: tokio::sync::watch::Receiver, + pub window_id: Mutex>, +} + +impl PlayerController { + pub fn new(config: PlayerConfig) -> Arc { + let initial_status = PlayerStatus::default(); + let (status_tx, status_rx) = tokio::sync::watch::channel(initial_status.clone()); + Arc::new(Self { + config, + child_process: Mutex::new(None), + status: Arc::new(Mutex::new(initial_status)), + status_tx, + status_rx, + window_id: Mutex::new(None), + }) + } + + pub fn status_watch_rx(&self) -> tokio::sync::watch::Receiver { + self.status_rx.clone() + } + + pub async fn notify_status(&self) { + let st = self.status.lock().await.clone(); + let _ = self.status_tx.send(st); + } + + pub async fn set_window_id(&self, wid: Option) { + let mut guard = self.window_id.lock().await; + *guard = wid; + if let Some(w) = wid { + tracing::info!("Registered window handle for MPV embedding: {}", w); + } + } + + pub async fn play(&self, url: &str, title: &str) -> Result<(), Box> { + let media_type = if url.contains("youtube.com") || url.contains("youtu.be") || (url.starts_with("http") && !url.contains("/Videos/") && !url.contains("/Audio/")) { + "stream" + } else { + "video" + }; + self.play_media(url, title, media_type, None, None).await + } + + pub async fn play_media( + &self, + url: &str, + title: &str, + media_type: &str, + artist: Option<&str>, + genre: Option<&str>, + ) -> Result<(), Box> { + tracing::info!("Playing media [type={}]: '{}' -> {}", media_type, title, url); + + // Update status immediately + { + let mut st = self.status.lock().await; + st.state = PlaybackState::Playing; + st.media_type = media_type.to_string(); + st.title = title.to_string(); + st.artist = artist.unwrap_or_default().to_string(); + st.genre = genre.unwrap_or_default().to_string(); + st.stream_title = if media_type == "stream" { title.to_string() } else { String::new() }; + st.time_pos = 0.0; + st.error_message = None; + } + self.notify_status().await; + + // Clean up socket file if exists + let sock_path = std::path::Path::new(&self.config.ipc_socket); + if sock_path.exists() { + let _ = std::fs::remove_file(sock_path); + } + + // Kill any existing instance + { + let mut proc_guard = self.child_process.lock().await; + if let Some(mut child) = proc_guard.take() { + let _ = child.kill(); + } + } + + // Build MPV command + let mut cmd = Command::new(&self.config.mpv_path); + cmd.arg(format!("--input-ipc-server={}", self.config.ipc_socket)); + cmd.arg(format!("--hwdec={}", self.config.hwdec)); + cmd.arg(format!("--vo={}", self.config.vo)); + + // Configure yt-dlp for YouTube streams + let ytdlp_path = if std::path::Path::new("/home/bart/.local/bin/yt-dlp").exists() { + "/home/bart/.local/bin/yt-dlp" + } else { + "yt-dlp" + }; + cmd.arg(format!("--script-opts=ytdl_hook-ytdl_path={}", ytdlp_path)); + cmd.arg("--ytdl-format=bestvideo[height<=?1080]+bestaudio/best"); + cmd.arg("--ytdl-raw-options=no-playlist="); + + let wid = *self.window_id.lock().await; + if let Some(w) = wid { + tracing::info!("Embedding MPV directly into window ID: {}", w); + cmd.arg(format!("--wid={}", w)); + } else if self.config.fullscreen { + cmd.arg("--fs"); + } + cmd.arg(format!("--title={}", title)); + + // Strip playlist/index params from single YouTube video playback + let play_url = if url.contains("youtube.com") || url.contains("youtu.be") { + let mut u = url.to_string(); + if let Some(pos) = u.find("&list=") { + u.truncate(pos); + } + if let Some(pos) = u.find("&index=") { + u.truncate(pos); + } + u + } else { + url.to_string() + }; + cmd.arg(play_url); + + match cmd.spawn() { + Ok(child) => { + let mut proc_guard = self.child_process.lock().await; + *proc_guard = Some(child); + } + Err(e) => { + let msg = format!("MPV niet gevonden ('{}')! Installeer MPV via: sudo apt install mpv", self.config.mpv_path); + tracing::warn!("{}: {}", msg, e); + let mut st = self.status.lock().await; + st.state = PlaybackState::Stopped; + st.error_message = Some(msg); + return Err(format!("Could not launch MPV: {}", e).into()); + } + } + + // Start background IPC monitoring task + let sock_file = self.config.ipc_socket.clone(); + let status_arc = self.status.clone(); + let status_tx = self.status_tx.clone(); + tokio::spawn(async move { + let _ = monitor_mpv_ipc(sock_file, status_arc, status_tx).await; + }); + + Ok(()) + } + + pub async fn play_pause(&self) { + let current_state = { + let st = self.status.lock().await; + st.state + }; + + let new_state = match current_state { + PlaybackState::Playing => PlaybackState::Paused, + PlaybackState::Paused => PlaybackState::Playing, + PlaybackState::Stopped => PlaybackState::Stopped, + }; + + let _ = self.send_ipc_cmd(&["cycle", "pause"]).await; + { + let mut st = self.status.lock().await; + st.state = new_state; + } + self.notify_status().await; + } + + pub async fn stop(&self) { + let _ = self.send_ipc_cmd(&["stop"]).await; + let mut proc_guard = self.child_process.lock().await; + if let Some(mut child) = proc_guard.take() { + let _ = child.kill(); + } + { + let mut st = self.status.lock().await; + st.state = PlaybackState::Stopped; + st.media_type.clear(); + st.title.clear(); + st.artist.clear(); + st.genre.clear(); + st.stream_title.clear(); + st.time_pos = 0.0; + st.duration = 0.0; + } + self.notify_status().await; + } + + pub async fn seek(&self, seconds: f64) { + let _ = self.send_ipc_cmd(&["seek", &seconds.to_string(), "relative"]).await; + } + + pub async fn toggle_mute(&self) { + let new_muted = { + let mut st = self.status.lock().await; + st.is_muted = !st.is_muted; + st.is_muted + }; + let _ = self.send_ipc_cmd(&["cycle", "mute"]).await; + self.notify_status().await; + tracing::info!("Player mute toggled to {}", new_muted); + } + + pub async fn set_mute(&self, muted: bool) { + { + let mut st = self.status.lock().await; + st.is_muted = muted; + } + let arg = if muted { "yes" } else { "no" }; + let _ = self.send_ipc_cmd(&["set", "mute", arg]).await; + self.notify_status().await; + tracing::info!("Player mute set to {}", muted); + } + + pub async fn set_volume(&self, volume: f64) { + let vol = volume.clamp(0.0, 100.0); + { + let mut st = self.status.lock().await; + st.volume = vol; + } + let _ = self.send_ipc_cmd(&["set", "volume", &vol.to_string()]).await; + self.notify_status().await; + tracing::info!("Player volume set to {}", vol); + } + + pub async fn volume_up(&self) { + let new_vol = { + let mut st = self.status.lock().await; + st.volume = (st.volume + 5.0).min(100.0); + st.volume + }; + let _ = self.send_ipc_cmd(&["add", "volume", "5"]).await; + self.notify_status().await; + tracing::info!("Player volume increased to {}", new_vol); + } + + pub async fn volume_down(&self) { + let new_vol = { + let mut st = self.status.lock().await; + st.volume = (st.volume - 5.0).max(0.0); + st.volume + }; + let _ = self.send_ipc_cmd(&["add", "volume", "-5"]).await; + self.notify_status().await; + tracing::info!("Player volume decreased to {}", new_vol); + } + + pub async fn cycle_subtitle(&self) { + let _ = self.send_ipc_cmd(&["cycle", "sub"]).await; + tracing::info!("Cycled subtitle track"); + } + + pub async fn cycle_audio(&self) { + let _ = self.send_ipc_cmd(&["cycle", "audio"]).await; + tracing::info!("Cycled audio track"); + } + + async fn send_ipc_cmd(&self, args: &[&str]) -> Result<(), Box> { + let mut stream = UnixStream::connect(&self.config.ipc_socket).await?; + let cmd_json = serde_json::json!({ + "command": args + }); + stream.write_all(format!("{}\n", cmd_json).as_bytes()).await?; + Ok(()) + } +} + +async fn monitor_mpv_ipc( + sock_path: String, + status: Arc>, + status_tx: tokio::sync::watch::Sender, +) -> Result<(), Box> { + // Wait for socket to become available + for _ in 0..30 { + if std::path::Path::new(&sock_path).exists() { + break; + } + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + let stream = match UnixStream::connect(&sock_path).await { + Ok(s) => s, + Err(_) => return Ok(()), + }; + + let (reader, mut writer) = stream.into_split(); + let mut lines = BufReader::new(reader).lines(); + + // Observe properties + let observe_cmds = [ + r#"{"command":["observe_property",1,"time-pos"]}"#, + r#"{"command":["observe_property",2,"duration"]}"#, + r#"{"command":["observe_property",3,"pause"]}"#, + r#"{"command":["observe_property",4,"volume"]}"#, + r#"{"command":["observe_property",5,"media-title"]}"#, + r#"{"command":["observe_property",6,"icy-title"]}"#, + r#"{"command":["observe_property",7,"metadata"]}"#, + r#"{"command":["observe_property",8,"mute"]}"#, + ]; + + for c in &observe_cmds { + let _ = writer.write_all(format!("{}\n", c).as_bytes()).await; + } + + while let Ok(Some(line)) = lines.next_line().await { + if let Ok(v) = serde_json::from_str::(&line) { + if let Some(event) = v.get("event").and_then(|e| e.as_str()) { + if event == "property-change" { + let name = v.get("name").and_then(|n| n.as_str()).unwrap_or(""); + let data = v.get("data"); + + let mut st = status.lock().await; + let mut notify = false; + match name { + "mute" => { + if let Some(m) = data.and_then(|d| d.as_bool()) { + if st.is_muted != m { + st.is_muted = m; + notify = true; + } + } + } + "time-pos" => { + if let Some(pos) = data.and_then(|d| d.as_f64()) { + st.time_pos = pos; + } + } + "duration" => { + if let Some(dur) = data.and_then(|d| d.as_f64()) { + st.duration = dur; + } + } + "pause" => { + if let Some(paused) = data.and_then(|d| d.as_bool()) { + let new_state = if paused { PlaybackState::Paused } else { PlaybackState::Playing }; + if st.state != new_state { + st.state = new_state; + notify = true; + } + } + } + "volume" => { + if let Some(vol) = data.and_then(|d| d.as_f64()) { + if (st.volume - vol).abs() > 0.01 { + st.volume = vol; + notify = true; + } + } + } + "icy-title" => { + if let Some(icy) = data.and_then(|d| d.as_str()) { + let trimmed = icy.trim(); + if !trimmed.is_empty() && st.stream_title != trimmed { + tracing::info!("MPV ICY Stream title detected: {}", trimmed); + st.stream_title = trimmed.to_string(); + if st.media_type.is_empty() || st.media_type != "stream" { + st.media_type = "stream".to_string(); + } + notify = true; + } + } + } + "media-title" => { + if let Some(mt) = data.and_then(|d| d.as_str()) { + let trimmed = mt.trim(); + if !trimmed.is_empty() { + if st.media_type == "stream" { + if st.stream_title.is_empty() || st.stream_title != trimmed { + tracing::info!("MPV Stream media-title: {}", trimmed); + st.stream_title = trimmed.to_string(); + notify = true; + } + } else if st.title.is_empty() { + st.title = trimmed.to_string(); + notify = true; + } + } + } + } + "metadata" => { + if let Some(meta_obj) = data.and_then(|d| d.as_object()) { + let stream_t = meta_obj.get("icy-title") + .or_else(|| meta_obj.get("icy_title")) + .or_else(|| meta_obj.get("StreamTitle")) + .or_else(|| meta_obj.get("title")) + .or_else(|| meta_obj.get("Title")) + .and_then(|val| val.as_str()); + + if let Some(s) = stream_t { + let trimmed = s.trim(); + if !trimmed.is_empty() && (st.media_type == "stream" || meta_obj.contains_key("icy-title")) { + if st.stream_title != trimmed { + st.stream_title = trimmed.to_string(); + st.media_type = "stream".to_string(); + notify = true; + } + } + } + + if let Some(a) = meta_obj.get("artist").or_else(|| meta_obj.get("Artist")).and_then(|val| val.as_str()) { + let trimmed = a.trim(); + if !trimmed.is_empty() && st.artist != trimmed { + st.artist = trimmed.to_string(); + notify = true; + } + } + + if let Some(g) = meta_obj.get("genre").or_else(|| meta_obj.get("Genre")).or_else(|| meta_obj.get("icy-genre")).and_then(|val| val.as_str()) { + let trimmed = g.trim(); + if !trimmed.is_empty() && st.genre != trimmed { + st.genre = trimmed.to_string(); + notify = true; + } + } + } + } + _ => {} + } + if notify { + let _ = status_tx.send(st.clone()); + } + } else if event == "end-file" { + let mut st = status.lock().await; + st.state = PlaybackState::Stopped; + st.media_type.clear(); + st.title.clear(); + st.artist.clear(); + st.genre.clear(); + st.stream_title.clear(); + st.time_pos = 0.0; + st.duration = 0.0; + let _ = status_tx.send(st.clone()); + break; + } + } + } + } + + Ok(()) +} diff --git a/jellyfin-kiosk-rs/src/yatse.rs b/jellyfin-kiosk-rs/src/yatse.rs new file mode 100644 index 0000000..7ecdb7a --- /dev/null +++ b/jellyfin-kiosk-rs/src/yatse.rs @@ -0,0 +1,505 @@ +use axum::{ + extract::State, + response::{IntoResponse, Json}, + routing::{get, post}, + Router, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::sync::Arc; +use tokio::sync::mpsc::UnboundedSender; + +use crate::player::{PlaybackState, PlayerController}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteNavAction { + Up, + Down, + Left, + Right, + Select, + Back, + ToggleOSD, + CycleSubtitle, + TogglePlaybackView, + ActivateSection(usize), + VolumeUp, + VolumeDown, + Stop, +} + +#[derive(Clone)] +pub struct YatseServerState { + pub player: Arc, + pub nav_tx: UnboundedSender, +} + +#[derive(Debug, Deserialize)] +struct JsonRpcRequest { + #[allow(dead_code)] + jsonrpc: Option, + method: String, + params: Option, + id: Option, +} + +pub fn create_yatse_router(state: YatseServerState) -> Router { + Router::new() + .route("/", get(handle_root)) + .route("/jsonrpc", post(handle_jsonrpc).get(handle_jsonrpc_get)) + .with_state(state) +} + +async fn handle_root() -> impl IntoResponse { + "Jellyfin HTPC Kiosk (Rust) - Kodi JSON-RPC Server for Yatse" +} + +async fn handle_jsonrpc_get() -> impl IntoResponse { + Json(json!({ + "jsonrpc": "2.0", + "result": { + "version": {"major": 19, "minor": 0, "patch": 0} + }, + "id": 1 + })) +} + +async fn handle_jsonrpc( + State(state): State, + Json(req): Json, +) -> impl IntoResponse { + if let Some(arr) = req.as_array() { + let mut results = Vec::new(); + for item in arr { + if let Ok(parsed) = serde_json::from_value::(item.clone()) { + results.push(dispatch_rpc(&state, parsed).await); + } + } + Json(Value::Array(results)) + } else if let Ok(parsed) = serde_json::from_value::(req) { + let res = dispatch_rpc(&state, parsed).await; + Json(res) + } else { + Json(json!({ + "jsonrpc": "2.0", + "error": {"code": -32700, "message": "Parse error"}, + "id": Value::Null + })) + } +} + +async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { + let id = req.id.unwrap_or(Value::Null); + let method = req.method.as_str(); + tracing::info!("Yatse JSON-RPC: method={}, params={:?}", method, req.params); + + match method { + "JSONRPC.Ping" => json!({ + "jsonrpc": "2.0", + "result": "pong", + "id": id + }), + "JSONRPC.Version" => json!({ + "jsonrpc": "2.0", + "result": {"version": {"major": 19, "minor": 0, "patch": 0}}, + "id": id + }), + "Application.GetProperties" => { + let st = state.player.status.lock().await; + json!({ + "jsonrpc": "2.0", + "result": { + "volume": st.volume as i64, + "muted": st.is_muted, + "name": "Kodi", + "version": {"major": 19, "minor": 0, "revision": "20260401", "tag": "stable"}, + "language": "en_GB", + "sorttokens": ["The ", "The.", "The_"] + }, + "id": id + }) + } + "Application.SetMute" => { + let mute_param = req.params.as_ref() + .and_then(|p| p.get("mute").or_else(|| p.as_array().and_then(|a| a.first()))) + .and_then(|m| m.as_bool()); + + let is_muted = match mute_param { + Some(m) => { + state.player.set_mute(m).await; + m + } + None => { + state.player.toggle_mute().await; + state.player.status.lock().await.is_muted + } + }; + json!({ + "jsonrpc": "2.0", + "result": is_muted, + "id": id + }) + } + "Application.SetVolume" => { + let vol_param = req.params.as_ref() + .and_then(|p| p.get("volume").or_else(|| p.as_array().and_then(|a| a.first()))) + .and_then(|v| v.as_f64()); + + let new_vol = match vol_param { + Some(v) => { + state.player.set_volume(v).await; + v.clamp(0.0, 100.0) as i64 + } + None => { + state.player.status.lock().await.volume as i64 + } + }; + json!({ + "jsonrpc": "2.0", + "result": new_vol, + "id": id + }) + } + "Player.GetActivePlayers" => { + let st = state.player.status.lock().await; + if st.state != PlaybackState::Stopped { + json!({ + "jsonrpc": "2.0", + "result": [{"playerid": 1, "playertype": "internal", "type": "video"}], + "id": id + }) + } else { + json!({ + "jsonrpc": "2.0", + "result": [], + "id": id + }) + } + } + "Player.GetProperties" => { + let st = state.player.status.lock().await; + let time = seconds_to_kodi_time(st.time_pos); + let totaltime = seconds_to_kodi_time(st.duration); + let speed = if st.state == PlaybackState::Playing { 1 } else { 0 }; + + json!({ + "jsonrpc": "2.0", + "result": { + "canseek": true, + "speed": speed, + "time": time, + "totaltime": totaltime, + "percentage": if st.duration > 0.0 { (st.time_pos / st.duration) * 100.0 } else { 0.0 } + }, + "id": id + }) + } + "Player.GetItem" => { + let st = state.player.status.lock().await; + json!({ + "jsonrpc": "2.0", + "result": { + "item": { + "label": st.title, + "title": st.title, + "type": "movie" + } + }, + "id": id + }) + } + "Player.PlayPause" => { + state.player.play_pause().await; + let st = state.player.status.lock().await; + let speed = if st.state == PlaybackState::Playing { 1 } else { 0 }; + json!({ + "jsonrpc": "2.0", + "result": {"speed": speed}, + "id": id + }) + } + "Player.Stop" => { + state.player.stop().await; + json!({ + "jsonrpc": "2.0", + "result": "OK", + "id": id + }) + } + "Player.Seek" => { + if let Some(params) = req.params { + if let Some(sec) = params.get("value").and_then(|v| v.as_f64()) { + state.player.seek(sec).await; + } + } + json!({ + "jsonrpc": "2.0", + "result": {"percentage": 50.0}, + "id": id + }) + } + "Player.Open" => { + if let Some(params) = req.params { + if let Some(item) = params.get("item") { + if let Some(file) = item.get("file").and_then(|f| f.as_str()) { + let file_str = file.to_string(); + let p = state.player.clone(); + tokio::spawn(async move { + let _ = p.play(&file_str, "Media from Yatse").await; + }); + } + } + } + json!({ + "jsonrpc": "2.0", + "result": "OK", + "id": id + }) + } + // Remote D-Pad inputs forwarded directly to the on-screen UI + "Input.Up" => { + let _ = state.nav_tx.send(RemoteNavAction::Up); + json!({"jsonrpc": "2.0", "result": "OK", "id": id}) + } + "Input.Down" => { + let _ = state.nav_tx.send(RemoteNavAction::Down); + json!({"jsonrpc": "2.0", "result": "OK", "id": id}) + } + "Input.Left" => { + let _ = state.nav_tx.send(RemoteNavAction::Left); + json!({"jsonrpc": "2.0", "result": "OK", "id": id}) + } + "Input.Right" => { + let _ = state.nav_tx.send(RemoteNavAction::Right); + json!({"jsonrpc": "2.0", "result": "OK", "id": id}) + } + "Input.Select" => { + let _ = state.nav_tx.send(RemoteNavAction::Select); + json!({"jsonrpc": "2.0", "result": "OK", "id": id}) + } + "Input.Back" => { + let _ = state.nav_tx.send(RemoteNavAction::Back); + json!({"jsonrpc": "2.0", "result": "OK", "id": id}) + } + "Input.ContextMenu" | "Input.Info" => { + json!({"jsonrpc": "2.0", "result": "OK", "id": id}) + } + "Input.ExecuteAction" => { + let action = req.params.as_ref() + .and_then(|p| p.get("action").or_else(|| p.as_array().and_then(|a| a.first()))) + .and_then(|a| a.as_str()) + .unwrap_or("") + .to_lowercase(); + match action.as_str() { + "up" | "cursorup" => { let _ = state.nav_tx.send(RemoteNavAction::Up); } + "down" | "cursordown" => { let _ = state.nav_tx.send(RemoteNavAction::Down); } + "left" | "cursorleft" => { let _ = state.nav_tx.send(RemoteNavAction::Left); } + "right" | "cursorright" => { let _ = state.nav_tx.send(RemoteNavAction::Right); } + "select" | "enter" => { let _ = state.nav_tx.send(RemoteNavAction::Select); } + "back" | "previousmenu" | "parentdir" | "close" => { let _ = state.nav_tx.send(RemoteNavAction::Back); } + "togglefullscreen" => { let _ = state.nav_tx.send(RemoteNavAction::ToggleOSD); } + "osd" | "nextsubtitle" => { let _ = state.nav_tx.send(RemoteNavAction::CycleSubtitle); } + "title" | "menu" => { let _ = state.nav_tx.send(RemoteNavAction::TogglePlaybackView); } + "volumeup" => { + let _ = state.nav_tx.send(RemoteNavAction::VolumeUp); + let p = state.player.clone(); + tokio::spawn(async move { p.volume_up().await; }); + } + "volumedown" => { + let _ = state.nav_tx.send(RemoteNavAction::VolumeDown); + let p = state.player.clone(); + tokio::spawn(async move { p.volume_down().await; }); + } + "playpause" | "pause" | "play" => { state.player.play_pause().await; } + "stop" => { + let _ = state.nav_tx.send(RemoteNavAction::Stop); + state.player.stop().await; + } + _ => {} + } + json!({"jsonrpc": "2.0", "result": "OK", "id": id}) + } + "GUI.ActivateWindow" => { + if let Some(params) = req.params { + let window = params.get("window").and_then(|w| w.as_str()).unwrap_or("").to_lowercase(); + match window.as_str() { + "videos" | "video" | "movies" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(1)); } + "tvshows" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(2)); } + "music" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(3)); } + "pictures" | "photos" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(4)); } + "home" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(0)); } + _ => {} + } + } + json!({"jsonrpc": "2.0", "result": "OK", "id": id}) + } + "XBMC.GetInfoBooleans" => { + let mut res = serde_json::Map::new(); + let mut requested = Vec::new(); + + if let Some(ref params) = req.params { + if let Some(arr) = params.get("booleans").and_then(|b| b.as_array()) { + for b in arr { + if let Some(s) = b.as_str() { + requested.push(s.to_string()); + } + } + } else if let Some(arr) = params.as_array().and_then(|a| a.first()).and_then(|b| b.as_array()) { + for b in arr { + if let Some(s) = b.as_str() { + requested.push(s.to_string()); + } + } + } + } + + for s in &requested { + let val = match s.as_str() { + "Services.EventServer" | "Services.Webserver" | "Services.UPnP" => true, + "System.Platform.Linux" => true, + s if s.starts_with("System.Platform.") => false, + _ => true, + }; + res.insert(s.clone(), json!(val)); + } + + if res.is_empty() { + res.insert("Services.EventServer".to_string(), json!(true)); + res.insert("Services.Webserver".to_string(), json!(true)); + res.insert("System.Platform.Linux".to_string(), json!(true)); + } + json!({"jsonrpc": "2.0", "result": res, "id": id}) + } + "XBMC.GetInfoLabels" => { + let mut res = serde_json::Map::new(); + let mut requested = Vec::new(); + + if let Some(ref params) = req.params { + if let Some(arr) = params.get("labels").and_then(|b| b.as_array()) { + for b in arr { + if let Some(s) = b.as_str() { + requested.push(s.to_string()); + } + } + } else if let Some(arr) = params.as_array().and_then(|a| a.first()).and_then(|b| b.as_array()) { + for b in arr { + if let Some(s) = b.as_str() { + requested.push(s.to_string()); + } + } + } + } + + for s in &requested { + let val = match s.as_str() { + "System.BuildVersion" => "19.4 (19.4.0) Kodi", + "System.FriendlyName" => "HTPC", + "System.KernelVersion" => "Linux 6.8.0", + _ => "", + }; + res.insert(s.clone(), json!(val)); + } + json!({"jsonrpc": "2.0", "result": res, "id": id}) + } + "Settings.GetSettingValue" => { + let setting = req.params.as_ref() + .and_then(|p| p.get("setting").or_else(|| p.as_array().and_then(|a| a.first()))) + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_lowercase(); + let val = if setting.contains("port") { + if setting.contains("es") { json!(9777) } else { json!(8080) } + } else if setting.contains("passthrough") { + // Audio passthrough is FALSE, so standard volume controls work! + json!(false) + } else { + json!(true) + }; + json!({"jsonrpc": "2.0", "result": {"value": val}, "id": id}) + } + "Settings.GetSettings" => { + json!({ + "jsonrpc": "2.0", + "result": { + "settings": [ + {"id": "services.eventserver", "value": true}, + {"id": "services.esenabled", "value": true}, + {"id": "services.esport", "value": 9777}, + {"id": "services.esallinterfaces", "value": true}, + {"id": "services.webserver", "value": true}, + {"id": "services.webserverport", "value": 8080}, + {"id": "audiooutput.passthrough", "value": false} + ] + }, + "id": id + }) + } + "Settings.SetSettingValue" => { + json!({"jsonrpc": "2.0", "result": true, "id": id}) + } + "JSONRPC.Permission" => { + json!({ + "jsonrpc": "2.0", + "result": { + "ControlGUI": true, + "ControlNotify": true, + "ControlPlayback": true, + "ControlPower": true, + "ControlSystem": true, + "ExecuteAddon": true, + "ManageFiles": true, + "Navigate": true, + "ReadData": true, + "WriteData": true + }, + "id": id + }) + } + "GUI.GetProperties" => { + json!({ + "jsonrpc": "2.0", + "result": { + "currentwindow": {"id": 10000, "label": "Home"}, + "fullscreen": true, + "skin": {"id": "skin.estuary", "name": "Estuary"} + }, + "id": id + }) + } + "System.GetProperties" => { + json!({ + "jsonrpc": "2.0", + "result": { + "canreboot": true, + "canshutdown": true, + "cansuspend": false + }, + "id": id + }) + } + "GUI.ShowNotification" => { + json!({"jsonrpc": "2.0", "result": "OK", "id": id}) + } + _ => { + json!({ + "jsonrpc": "2.0", + "result": "OK", + "id": id + }) + } + } +} + +fn seconds_to_kodi_time(seconds: f64) -> Value { + let s = seconds.max(0.0) as u64; + let hours = s / 3600; + let minutes = (s % 3600) / 60; + let secs = s % 60; + let millis = ((seconds - s as f64) * 1000.0) as u64; + json!({ + "hours": hours, + "minutes": minutes, + "seconds": secs, + "milliseconds": millis + }) +} diff --git a/jellyfin-kiosk-rs/src/youtube.rs b/jellyfin-kiosk-rs/src/youtube.rs new file mode 100644 index 0000000..1dd27d2 --- /dev/null +++ b/jellyfin-kiosk-rs/src/youtube.rs @@ -0,0 +1,650 @@ +use reqwest::header::AUTHORIZATION; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; + +use crate::config::YouTubeConfig; +use crate::jellyfin::MediaItem; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceCodeResponse { + pub device_code: String, + pub user_code: String, + pub verification_url: String, + pub expires_in: u64, + pub interval: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredToken { + pub access_token: String, + pub refresh_token: Option, + pub expires_at: u64, + pub token_type: String, +} + +#[derive(Debug, Clone)] +pub enum DevicePollResult { + Pending, + SlowDown, + Success, + Error(String), +} + +#[derive(Debug, Clone)] +pub struct YouTubeClient { + config: YouTubeConfig, + client: reqwest::Client, + token: Arc>>, +} + +#[derive(Debug, Deserialize)] +struct SubscriptionsResponse { + items: Option>, +} + +#[derive(Debug, Deserialize)] +struct SubscriptionItem { + snippet: Option, +} + +#[derive(Debug, Deserialize)] +struct SubscriptionSnippet { + title: String, + description: Option, + #[serde(rename = "resourceId")] + resource_id: Option, + thumbnails: Option, +} + +#[derive(Debug, Deserialize)] +struct ResourceId { + #[serde(rename = "channelId")] + channel_id: Option, + #[serde(rename = "videoId")] + video_id: Option, +} + +#[derive(Debug, Deserialize)] +struct Thumbnails { + default: Option, + medium: Option, + high: Option, +} + +#[derive(Debug, Deserialize)] +struct ThumbnailItem { + url: String, +} + +#[derive(Debug, Deserialize)] +struct PlaylistItemsResponse { + items: Option>, +} + +#[derive(Debug, Deserialize)] +struct PlaylistItem { + snippet: Option, +} + +#[derive(Debug, Deserialize)] +struct PlaylistItemSnippet { + title: String, + description: Option, + #[serde(rename = "publishedAt")] + published_at: Option, + #[serde(rename = "channelTitle")] + channel_title: Option, + #[serde(rename = "resourceId")] + resource_id: Option, + thumbnails: Option, +} + +#[derive(Debug, Deserialize)] +struct SearchResponse { + items: Option>, +} + +#[derive(Debug, Deserialize)] +struct SearchItem { + id: Option, + snippet: Option, +} + +fn best_thumbnail(t: &Option) -> Option { + if let Some(t) = t { + if let Some(h) = &t.high { + return Some(h.url.clone()); + } + if let Some(m) = &t.medium { + return Some(m.url.clone()); + } + if let Some(d) = &t.default { + return Some(d.url.clone()); + } + } + None +} + +fn resolve_token_file(path_str: &str) -> PathBuf { + if path_str.starts_with("~/") { + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(&path_str[2..]); + } + } + PathBuf::from(path_str) +} + +fn current_timestamp() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +impl YouTubeClient { + pub fn new(config: YouTubeConfig) -> Self { + let mut initial_token = None; + let token_path = resolve_token_file(&config.token_path); + if token_path.exists() { + if let Ok(data) = std::fs::read_to_string(&token_path) { + if let Ok(t) = serde_json::from_str::(&data) { + tracing::info!("Loaded cached YouTube token from {}", token_path.display()); + initial_token = Some(t); + } + } + } + + Self { + config, + client: reqwest::Client::builder().build().unwrap_or_default(), + token: Arc::new(RwLock::new(initial_token)), + } + } + + pub fn is_configured(&self) -> bool { + !self.config.client_id.trim().is_empty() + } + + pub async fn is_logged_in(&self) -> bool { + let guard = self.token.read().await; + if let Some(t) = guard.as_ref() { + t.expires_at > current_timestamp() || t.refresh_token.is_some() + } else { + false + } + } + + pub async fn logout(&self) { + { + let mut guard = self.token.write().await; + *guard = None; + } + let token_path = resolve_token_file(&self.config.token_path); + if token_path.exists() { + let _ = std::fs::remove_file(token_path); + } + } + + fn save_token(&self, token: &StoredToken) { + let token_path = resolve_token_file(&self.config.token_path); + if let Some(parent) = token_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(data) = serde_json::to_string_pretty(token) { + let _ = std::fs::write(&token_path, data); + } + } + + pub async fn start_device_auth(&self) -> Result { + if !self.is_configured() { + return Err("YouTube Client ID is niet ingesteld in config.yaml".to_string()); + } + + let params = [ + ("client_id", self.config.client_id.as_str()), + ("scope", "https://www.googleapis.com/auth/youtube.readonly"), + ]; + + let res = self + .client + .post("https://oauth2.googleapis.com/device/code") + .form(¶ms) + .send() + .await + .map_err(|e| format!("Netwerkfout bij starten authenticatie: {}", e))?; + + if !res.status().is_success() { + let status = res.status(); + let text = res.text().await.unwrap_or_default(); + return Err(format!("Google API fout ({}): {}", status, text)); + } + + let resp: DeviceCodeResponse = res + .json() + .await + .map_err(|e| format!("Ongeldig antwoord van Google: {}", e))?; + + Ok(resp) + } + + pub async fn poll_device_token(&self, device_code: &str) -> DevicePollResult { + let mut params = vec![ + ("client_id", self.config.client_id.clone()), + ("device_code", device_code.to_string()), + ( + "grant_type", + "urn:ietf:params:oauth:grant-type:device_code".to_string(), + ), + ]; + + if let Some(secret) = &self.config.client_secret { + if !secret.trim().is_empty() { + params.push(("client_secret", secret.clone())); + } + } + + let res = match self + .client + .post("https://oauth2.googleapis.com/token") + .form(¶ms) + .send() + .await + { + Ok(r) => r, + Err(e) => return DevicePollResult::Error(format!("Netwerkfout: {}", e)), + }; + + let status = res.status(); + let body: serde_json::Value = match res.json().await { + Ok(v) => v, + Err(e) => return DevicePollResult::Error(format!("JSON fout: {}", e)), + }; + + if status.is_success() { + let access_token = body + .get("access_token") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let refresh_token = body + .get("refresh_token") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let expires_in = body + .get("expires_in") + .and_then(|v| v.as_u64()) + .unwrap_or(3600); + let token_type = body + .get("token_type") + .and_then(|v| v.as_str()) + .unwrap_or("Bearer") + .to_string(); + + let stored = StoredToken { + access_token, + refresh_token, + expires_at: current_timestamp() + expires_in, + token_type, + }; + + self.save_token(&stored); + { + let mut guard = self.token.write().await; + *guard = Some(stored); + } + + return DevicePollResult::Success; + } + + if let Some(err) = body.get("error").and_then(|e| e.as_str()) { + match err { + "authorization_pending" => DevicePollResult::Pending, + "slow_down" => DevicePollResult::SlowDown, + "access_denied" => { + DevicePollResult::Error("Toestemming geweigerd door gebruiker.".to_string()) + } + "expired_token" => { + DevicePollResult::Error("De koppelcode is verlopen. Probeer opnieuw.".to_string()) + } + other => DevicePollResult::Error(format!("Google fout: {}", other)), + } + } else { + DevicePollResult::Error(format!("Onbekende fout van Google (status {})", status)) + } + } + + pub async fn get_valid_token(&self) -> Result { + let (current_access, refresh_opt, is_expired) = { + let guard = self.token.read().await; + if let Some(t) = guard.as_ref() { + let now = current_timestamp(); + (t.access_token.clone(), t.refresh_token.clone(), t.expires_at <= now + 60) + } else { + return Err("Niet ingelogd op YouTube".to_string()); + } + }; + + if !is_expired { + return Ok(current_access); + } + + // Need to refresh + let refresh_token = match refresh_opt { + Some(rt) => rt, + None => return Err("Geen refresh token aanwezig. Log opnieuw in.".to_string()), + }; + + let mut params = vec![ + ("client_id", self.config.client_id.clone()), + ("refresh_token", refresh_token.clone()), + ("grant_type", "refresh_token".to_string()), + ]; + if let Some(secret) = &self.config.client_secret { + if !secret.trim().is_empty() { + params.push(("client_secret", secret.clone())); + } + } + + let res = self + .client + .post("https://oauth2.googleapis.com/token") + .form(¶ms) + .send() + .await + .map_err(|e| format!("Netwerkfout bij vernieuwen YouTube token: {}", e))?; + + if !res.status().is_success() { + return Err("Kon YouTube sessie niet vernieuwen. Log opnieuw in.".to_string()); + } + + let body: serde_json::Value = res + .json() + .await + .map_err(|e| format!("JSON fout bij token vernieuwen: {}", e))?; + + let new_access = body + .get("access_token") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Geen access_token in antwoord".to_string())? + .to_string(); + + let expires_in = body + .get("expires_in") + .and_then(|v| v.as_u64()) + .unwrap_or(3600); + + let updated = StoredToken { + access_token: new_access.clone(), + refresh_token: Some(refresh_token), + expires_at: current_timestamp() + expires_in, + token_type: "Bearer".to_string(), + }; + + self.save_token(&updated); + { + let mut guard = self.token.write().await; + *guard = Some(updated); + } + + Ok(new_access) + } + + pub async fn fetch_subscriptions(&self) -> Result, String> { + let token = self.get_valid_token().await?; + + let url = "https://www.googleapis.com/youtube/v3/subscriptions?part=snippet&mine=true&maxResults=50&order=alphabetical"; + let res = self + .client + .get(url) + .header(AUTHORIZATION, format!("Bearer {}", token)) + .send() + .await + .map_err(|e| format!("Fout bij ophalen abonnementen: {}", e))?; + + if !res.status().is_success() { + let s = res.status(); + let t = res.text().await.unwrap_or_default(); + return Err(format!("YouTube API fout ({}): {}", s, t)); + } + + let data: SubscriptionsResponse = res + .json() + .await + .map_err(|e| format!("JSON parse fout: {}", e))?; + + let mut items = Vec::new(); + if let Some(list) = data.items { + for item in list { + if let Some(snip) = item.snippet { + let channel_id = snip + .resource_id + .and_then(|r| r.channel_id) + .unwrap_or_default(); + if channel_id.is_empty() { + continue; + } + let thumb = best_thumbnail(&snip.thumbnails); + let title = snip.title; + items.push(MediaItem { + id: channel_id, + name: title.clone(), + sort_name: Some(title), + item_type: "YouTubeChannel".to_string(), + series_name: None, + artist_name: None, + genre: None, + index_number: None, + year: None, + overview: snip.description, + rating: None, + runtime_ticks: None, + played_percentage: None, + is_folder: true, + has_primary_image: thumb.is_some(), + has_backdrop_image: false, + parent_primary_image_item_id: thumb, + parent_backdrop_item_id: None, + }); + } + } + } + + Ok(items) + } + + pub async fn fetch_channel_videos(&self, channel_id: &str) -> Result, String> { + let token = self.get_valid_token().await?; + + // Channel upload playlist ID is channel ID with "UU" instead of "UC" + let playlist_id = if channel_id.starts_with("UC") { + format!("UU{}", &channel_id[2..]) + } else { + channel_id.to_string() + }; + + let url = format!( + "https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&playlistId={}&maxResults=30", + playlist_id + ); + + let res = self + .client + .get(&url) + .header(AUTHORIZATION, format!("Bearer {}", token)) + .send() + .await + .map_err(|e| format!("Fout bij ophalen uploads van kanaal: {}", e))?; + + if !res.status().is_success() { + let s = res.status(); + let t = res.text().await.unwrap_or_default(); + return Err(format!("YouTube API fout ({}): {}", s, t)); + } + + let data: PlaylistItemsResponse = res + .json() + .await + .map_err(|e| format!("JSON parse fout: {}", e))?; + + let mut items = Vec::new(); + if let Some(list) = data.items { + for item in list { + if let Some(snip) = item.snippet { + let video_id = snip + .resource_id + .and_then(|r| r.video_id) + .unwrap_or_default(); + if video_id.is_empty() { + continue; + } + let thumb = best_thumbnail(&snip.thumbnails); + let title = snip.title; + let video_url = format!("https://www.youtube.com/watch?v={}", video_id); + let date_str = snip.published_at.as_ref().map(|d| d.chars().take(10).collect()); + + items.push(MediaItem { + id: video_url, + name: title.clone(), + sort_name: snip.published_at, + item_type: "YouTubeVideo".to_string(), + series_name: snip.channel_title, + artist_name: None, + genre: date_str, + index_number: None, + year: None, + overview: snip.description, + rating: None, + runtime_ticks: None, + played_percentage: None, + is_folder: false, + has_primary_image: thumb.is_some(), + has_backdrop_image: false, + parent_primary_image_item_id: thumb, + parent_backdrop_item_id: None, + }); + } + } + } + + Ok(items) + } + + pub async fn fetch_feed(&self) -> Result, String> { + let subs = self.fetch_subscriptions().await?; + if subs.is_empty() { + return Ok(Vec::new()); + } + + // Concurrently fetch the latest 3 videos from up to top 15 subscriptions + let top_channels: Vec = subs.into_iter().take(15).map(|c| c.id).collect(); + let mut handles = Vec::new(); + + for channel_id in top_channels { + let client = self.clone(); + handles.push(tokio::spawn(async move { + client.fetch_channel_videos(&channel_id).await.unwrap_or_default() + })); + } + + let mut all_videos = Vec::new(); + for h in handles { + if let Ok(videos) = h.await { + // Take top 3 of each channel + all_videos.extend(videos.into_iter().take(3)); + } + } + + // Sort descending by published_at (stored in sort_name) + all_videos.sort_by(|a, b| b.sort_name.cmp(&a.sort_name)); + + Ok(all_videos) + } + + pub async fn search(&self, query: &str) -> Result, String> { + let token = self.get_valid_token().await?; + let encoded_q = urlencoding_simple(query); + let url = format!( + "https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&q={}&maxResults=25", + encoded_q + ); + + let res = self + .client + .get(&url) + .header(AUTHORIZATION, format!("Bearer {}", token)) + .send() + .await + .map_err(|e| format!("Fout bij zoeken: {}", e))?; + + if !res.status().is_success() { + let s = res.status(); + let t = res.text().await.unwrap_or_default(); + return Err(format!("YouTube API fout ({}): {}", s, t)); + } + + let data: SearchResponse = res + .json() + .await + .map_err(|e| format!("JSON parse fout: {}", e))?; + + let mut items = Vec::new(); + if let Some(list) = data.items { + for item in list { + let video_id = item + .id + .and_then(|r| r.video_id) + .unwrap_or_default(); + if video_id.is_empty() { + continue; + } + if let Some(snip) = item.snippet { + let thumb = best_thumbnail(&snip.thumbnails); + let title = snip.title; + let video_url = format!("https://www.youtube.com/watch?v={}", video_id); + let date_str = snip.published_at.as_ref().map(|d| d.chars().take(10).collect()); + + items.push(MediaItem { + id: video_url, + name: title.clone(), + sort_name: snip.published_at, + item_type: "YouTubeVideo".to_string(), + series_name: snip.channel_title, + artist_name: None, + genre: date_str, + index_number: None, + year: None, + overview: snip.description, + rating: None, + runtime_ticks: None, + played_percentage: None, + is_folder: false, + has_primary_image: thumb.is_some(), + has_backdrop_image: false, + parent_primary_image_item_id: thumb, + parent_backdrop_item_id: None, + }); + } + } + } + + Ok(items) + } +} + +fn urlencoding_simple(s: &str) -> String { + let mut out = String::new(); + for c in s.chars() { + if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~' { + out.push(c); + } else if c == ' ' { + out.push('+'); + } else { + for b in c.to_string().as_bytes() { + out.push_str(&format!("%{:02X}", b)); + } + } + } + out +} diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/config.py b/src/config.py deleted file mode 100644 index 5cfa8c2..0000000 --- a/src/config.py +++ /dev/null @@ -1,159 +0,0 @@ -from __future__ import annotations - -import os -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Dict, List, Optional -import yaml - - -@dataclass -class JellyfinConfig: - server_url: str = "http://127.0.0.1:8096" - username: str = "" - password: str = "" - token: Optional[str] = None - client_name: str = "Jellyfin HTPC Client" - device_name: str = "HTPC" - device_id: str = "htpc-default-01" - - -@dataclass -class PlayerConfig: - mpv_path: str = "mpv" - ipc_socket: str = "/tmp/mpv-jellyfin.sock" - hwdec: str = "auto" - vo: str = "gpu" - fullscreen: bool = True - audio_device: str = "auto" - extra_args: List[str] = field(default_factory=lambda: [ - "--keep-open=yes", - "--force-window=immediate", - "--osd-font=sans-serif", - "--osd-font-size=28", - ]) - - -@dataclass -class KodiRpcConfig: - enabled: bool = True - http_port: int = 8080 - ws_port: int = 9090 - event_port: int = 9777 - - -@dataclass -class WebApiConfig: - enabled: bool = True - host: str = "0.0.0.0" - port: int = 8097 - - -@dataclass -class YoutubeConfig: - max_height: int = 1080 - fetch_metadata: bool = True - - -@dataclass -class AppConfig: - jellyfin: JellyfinConfig = field(default_factory=JellyfinConfig) - player: PlayerConfig = field(default_factory=PlayerConfig) - kodi_rpc: KodiRpcConfig = field(default_factory=KodiRpcConfig) - web_api: WebApiConfig = field(default_factory=WebApiConfig) - youtube: YoutubeConfig = field(default_factory=YoutubeConfig) - - @classmethod - def load(cls, config_path: Optional[str | Path] = None) -> AppConfig: - paths_to_check = [] - if config_path: - paths_to_check.append(Path(config_path)) - if env_path := os.environ.get("JELLYFIN_CLIENT_CONFIG"): - paths_to_check.append(Path(env_path)) - - paths_to_check.extend([ - Path.cwd() / "config.yaml", - Path.home() / ".config" / "jellyfin-client" / "config.yaml", - Path("/etc/jellyfin-client/config.yaml"), - ]) - - data: Dict[str, Any] = {} - found_path = None - for p in paths_to_check: - if p.is_file(): - found_path = p - with open(p, "r", encoding="utf-8") as f: - data = yaml.safe_load(f) or {} - break - - cfg = cls() - if not data: - return cfg - - if "jellyfin" in data: - jf = data["jellyfin"] - cfg.jellyfin = JellyfinConfig( - server_url=jf.get("server_url", cfg.jellyfin.server_url).rstrip("/"), - username=jf.get("username", cfg.jellyfin.username), - password=jf.get("password", cfg.jellyfin.password), - token=jf.get("token", cfg.jellyfin.token), - client_name=jf.get("client_name", cfg.jellyfin.client_name), - device_name=jf.get("device_name", cfg.jellyfin.device_name), - device_id=jf.get("device_id", cfg.jellyfin.device_id), - ) - - if "player" in data: - pl = data["player"] - cfg.player = PlayerConfig( - mpv_path=pl.get("mpv_path", cfg.player.mpv_path), - ipc_socket=pl.get("ipc_socket", cfg.player.ipc_socket), - hwdec=pl.get("hwdec", cfg.player.hwdec), - vo=pl.get("vo", cfg.player.vo), - fullscreen=pl.get("fullscreen", cfg.player.fullscreen), - audio_device=pl.get("audio_device", cfg.player.audio_device), - extra_args=pl.get("extra_args", cfg.player.extra_args), - ) - - if "kodi_rpc" in data: - kr = data["kodi_rpc"] - cfg.kodi_rpc = KodiRpcConfig( - enabled=kr.get("enabled", cfg.kodi_rpc.enabled), - http_port=kr.get("http_port", cfg.kodi_rpc.http_port), - ws_port=kr.get("ws_port", cfg.kodi_rpc.ws_port), - ) - - if "web_api" in data: - wa = data["web_api"] - cfg.web_api = WebApiConfig( - enabled=wa.get("enabled", cfg.web_api.enabled), - host=wa.get("host", cfg.web_api.host), - port=wa.get("port", cfg.web_api.port), - ) - - if "youtube" in data: - yt = data["youtube"] - cfg.youtube = YoutubeConfig( - max_height=yt.get("max_height", cfg.youtube.max_height), - fetch_metadata=yt.get("fetch_metadata", cfg.youtube.fetch_metadata), - ) - - # Environment variable overrides (useful for Docker & CI) - if jf_url := os.environ.get("JELLYFIN_SERVER_URL"): - cfg.jellyfin.server_url = jf_url.rstrip("/") - if jf_user := os.environ.get("JELLYFIN_USERNAME"): - cfg.jellyfin.username = jf_user - if jf_pass := os.environ.get("JELLYFIN_PASSWORD"): - cfg.jellyfin.password = jf_pass - if jf_dev := os.environ.get("JELLYFIN_DEVICE_NAME"): - cfg.jellyfin.device_name = jf_dev - - if mpv_vo := os.environ.get("MPV_VO"): - cfg.player.vo = mpv_vo - if mpv_hwdec := os.environ.get("MPV_HWDEC"): - cfg.player.hwdec = mpv_hwdec - if mpv_fs := os.environ.get("MPV_FULLSCREEN"): - cfg.player.fullscreen = mpv_fs.lower() in ("1", "true", "yes") - if mpv_ao := os.environ.get("MPV_AO"): - cfg.player.audio_device = mpv_ao - - return cfg diff --git a/src/jellyfin/__init__.py b/src/jellyfin/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/jellyfin/client.py b/src/jellyfin/client.py deleted file mode 100644 index 478e6d2..0000000 --- a/src/jellyfin/client.py +++ /dev/null @@ -1,349 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -from typing import Any, Dict, Optional -import aiohttp - -from src.config import JellyfinConfig -from src.player.models import MediaMetadata, PlaybackState, PlayerStatus -from src.player.mpv_controller import MpvController - -logger = logging.getLogger(__name__) - -TICKS_PER_SECOND = 10_000_000 - - -class JellyfinClient: - """Handles communication with the Jellyfin server, session registration, and remote control commands.""" - - def __init__(self, config: JellyfinConfig, player: MpvController): - self.config = config - self.player = player - self.session: Optional[aiohttp.ClientSession] = None - self.ws: Optional[aiohttp.ClientWebSocketResponse] = None - self.auth_token: Optional[str] = config.token - self.user_id: Optional[str] = None - self._running = False - self._ws_task: Optional[asyncio.Task] = None - self._report_task: Optional[asyncio.Task] = None - - @property - def auth_header(self) -> str: - return ( - f'MediaBrowser Client="{self.config.client_name}", ' - f'Device="{self.config.device_name}", ' - f'DeviceId="{self.config.device_id}", ' - f'Version="1.0.0"' - ) - - def _get_headers(self) -> Dict[str, str]: - headers = { - "Authorization": self.auth_header, - "X-Emby-Authorization": self.auth_header, - "Content-Type": "application/json", - "Accept": "application/json", - } - if self.auth_token: - headers["X-Emby-Token"] = self.auth_token - headers["Authorization"] = f'{self.auth_header}, Token="{self.auth_token}"' - return headers - - async def start(self) -> None: - """Start the Jellyfin client connection loop.""" - if not self.config.server_url or not self.config.username: - logger.info("Jellyfin server URL or username not configured; skipping Jellyfin server connection") - return - - if not self.config.password and not self.config.token: - logger.info( - "Jellyfin password is empty in config.yaml. Kodi RPC (Yatse) and YouTube sharing are fully active. Set your password in config.yaml to also enable Jellyfin casting." - ) - return - - self._running = True - self.session = aiohttp.ClientSession(headers=self._get_headers()) - self._ws_task = asyncio.create_task(self._connection_manager()) - self._report_task = asyncio.create_task(self._progress_reporter()) - - async def _connection_manager(self) -> None: - """Keeps the connection and WebSocket session alive with auto-reconnect.""" - while self._running: - try: - if not self.auth_token: - await self._authenticate() - - if self.auth_token: - await self._report_capabilities() - await self._listen_websocket() - except asyncio.CancelledError: - break - except Exception as e: - logger.error("Jellyfin connection error: %s. Reconnecting in 10s...", e) - - await asyncio.sleep(10) - - async def _authenticate(self) -> None: - """Authenticate user and obtain access token.""" - url = f"{self.config.server_url}/Users/AuthenticateByName" - payload = { - "Username": self.config.username, - "Pw": self.config.password or "", - } - logger.info("Authenticating to Jellyfin server: %s as %s", self.config.server_url, self.config.username) - async with self.session.post(url, json=payload, headers=self._get_headers()) as resp: - if resp.status != 200: - err_text = await resp.text() - raise RuntimeError(f"Authentication failed ({resp.status}): {err_text}") - data = await resp.json() - self.auth_token = data.get("AccessToken") - self.user_id = data.get("User", {}).get("Id") - self.session.headers["X-Emby-Token"] = self.auth_token - self.session.headers["Authorization"] = f'{self.auth_header}, Token="{self.auth_token}"' - logger.info("Authenticated successfully with Jellyfin (UserId: %s)", self.user_id) - - async def _report_capabilities(self) -> None: - """Notify Jellyfin of client capabilities for remote control.""" - url = f"{self.config.server_url}/Sessions/Capabilities/Full" - capabilities = { - "PlayableMediaTypes": ["Video", "Audio"], - "SupportedCommands": [ - "VolumeUp", - "VolumeDown", - "Mute", - "Unmute", - "ToggleMute", - "SetVolume", - "SetAudioStreamIndex", - "SetSubtitleStreamIndex", - "DisplayMessage", - ], - "SupportsMediaControl": True, - "SupportsPersistentIdentifier": True, - "DeviceProfile": { - "Name": "HTPC MPV Player", - "DirectPlayProfiles": [ - {"Type": "Video"}, - {"Type": "Audio"}, - ], - }, - } - async with self.session.post(url, json=capabilities) as resp: - if resp.status in (200, 204): - logger.info("Registered Jellyfin remote control capabilities successfully") - else: - logger.warning("Failed to register capabilities: HTTP %d", resp.status) - - async def _listen_websocket(self) -> None: - """Maintain WebSocket connection for remote commands from Yatse / Jellyfin Web.""" - ws_url = ( - f"{self.config.server_url.replace('http://', 'ws://').replace('https://', 'wss://')}" - f"/socket?api_key={self.auth_token}&deviceId={self.config.device_id}" - ) - ws_headers = { - "Authorization": f'MediaBrowser Token="{self.auth_token}"', - "X-Emby-Token": self.auth_token, - } - logger.info("Connecting to Jellyfin WebSocket: %s", ws_url) - async with self.session.ws_connect(ws_url, headers=ws_headers) as ws: - - self.ws = ws - logger.info("Jellyfin WebSocket connected; waiting for remote control commands") - async for msg in ws: - if msg.type == aiohttp.WSMsgType.TEXT: - try: - payload = json.loads(msg.data) - await self._handle_remote_message(payload) - except Exception as e: - logger.error("Error processing Jellyfin WebSocket message: %s", e) - elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): - break - - async def _handle_remote_message(self, msg: Dict[str, Any]) -> None: - msg_type = msg.get("MessageType") - data = msg.get("Data", {}) - - logger.debug("Received Jellyfin message: %s", msg_type) - - if msg_type == "Play": - await self._handle_play_command(data) - elif msg_type == "Playstate": - await self._handle_playstate_command(data) - elif msg_type == "GeneralCommand": - await self._handle_general_command(data) - elif msg_type == "KeepAlive": - pass - - async def _handle_play_command(self, data: Dict[str, Any]) -> None: - item_ids = data.get("ItemIds", []) - if not item_ids: - return - - item_id = item_ids[0] - start_ticks = data.get("StartPositionTicks") or 0 - start_seconds = start_ticks / TICKS_PER_SECOND - - logger.info("Remote play command for Jellyfin item: %s", item_id) - - # Retrieve item metadata from Jellyfin - item_url = f"{self.config.server_url}/Users/{self.user_id}/Items/{item_id}" - async with self.session.get(item_url) as resp: - if resp.status != 200: - logger.error("Failed to fetch item info for %s: %s", item_id, resp.status) - return - item_data = await resp.json() - - title = item_data.get("Name", "Unknown") - series_name = item_data.get("SeriesName") - run_time_ticks = item_data.get("RunTimeTicks") or 0 - duration = run_time_ticks / TICKS_PER_SECOND - - stream_url = ( - f"{self.config.server_url}/Videos/{item_id}/stream" - f"?static=true&api_key={self.auth_token}" - ) - - metadata = MediaMetadata( - url=stream_url, - title=title, - artist=series_name, - album=item_data.get("SeasonName"), - duration=duration, - thumbnail=f"{self.config.server_url}/Items/{item_id}/Images/Primary", - source_type="jellyfin", - jellyfin_item_id=item_id, - jellyfin_media_source_id=data.get("MediaSourceId"), - ) - - await self.player.play_url(stream_url, metadata=metadata, start_seconds=start_seconds) - await self._report_playing(start_ticks) - - async def _handle_playstate_command(self, data: Dict[str, Any]) -> None: - cmd = data.get("Command") - logger.info("Jellyfin Playstate command: %s", cmd) - if cmd == "PlayPause": - await self.player.play_pause() - elif cmd == "Pause": - await self.player.pause() - elif cmd == "Unpause": - await self.player.resume() - elif cmd == "Stop": - await self.player.stop() - elif cmd == "Seek": - seek_ticks = data.get("SeekPositionTicks") or 0 - await self.player.seek(seek_ticks / TICKS_PER_SECOND, absolute=True) - - async def _handle_general_command(self, data: Dict[str, Any]) -> None: - name = data.get("Name") - args = data.get("Arguments", {}) - logger.info("Jellyfin GeneralCommand: %s (%s)", name, args) - - if name == "SetVolume": - vol = int(args.get("Volume", 100)) - await self.player.set_volume(vol) - elif name == "Mute": - await self.player.set_mute(True) - elif name == "Unmute": - await self.player.set_mute(False) - elif name == "ToggleMute": - await self.player.toggle_mute() - elif name == "SetAudioStreamIndex": - idx = int(args.get("Index", 1)) - await self.player.set_audio_track(idx) - elif name == "SetSubtitleStreamIndex": - idx = int(args.get("Index", 0)) - await self.player.set_subtitle_track(idx) - elif name == "DisplayMessage": - text = args.get("Text", "") - await self.player.show_osd_message(text) - - async def _report_playing(self, position_ticks: int = 0) -> None: - if not self.auth_token or not self.session: - return - meta = self.player.status.metadata - if not meta or not meta.jellyfin_item_id: - return - - payload = { - "ItemId": meta.jellyfin_item_id, - "MediaSourceId": meta.jellyfin_media_source_id or meta.jellyfin_item_id, - "PositionTicks": position_ticks, - "IsPaused": self.player.status.state == PlaybackState.PAUSED, - "CanSeek": True, - } - url = f"{self.config.server_url}/Sessions/Playing" - try: - await self.session.post(url, json=payload) - except Exception as e: - logger.debug("Error reporting playing to Jellyfin: %s", e) - - async def _report_progress(self) -> None: - if not self.auth_token or not self.session: - return - meta = self.player.status.metadata - if not meta or not meta.jellyfin_item_id: - return - - position_ticks = int(self.player.status.current_time * TICKS_PER_SECOND) - payload = { - "ItemId": meta.jellyfin_item_id, - "MediaSourceId": meta.jellyfin_media_source_id or meta.jellyfin_item_id, - "PositionTicks": position_ticks, - "IsPaused": self.player.status.state == PlaybackState.PAUSED, - "CanSeek": True, - } - url = f"{self.config.server_url}/Sessions/Playing/Progress" - try: - await self.session.post(url, json=payload) - except Exception as e: - logger.debug("Error reporting progress to Jellyfin: %s", e) - - async def _report_stopped(self) -> None: - if not self.auth_token or not self.session: - return - meta = self.player.status.metadata - if not meta or not meta.jellyfin_item_id: - return - - position_ticks = int(self.player.status.current_time * TICKS_PER_SECOND) - payload = { - "ItemId": meta.jellyfin_item_id, - "MediaSourceId": meta.jellyfin_media_source_id or meta.jellyfin_item_id, - "PositionTicks": position_ticks, - } - url = f"{self.config.server_url}/Sessions/Playing/Stopped" - try: - await self.session.post(url, json=payload) - except Exception as e: - logger.debug("Error reporting stopped to Jellyfin: %s", e) - - async def _progress_reporter(self) -> None: - """Periodically update Jellyfin server with current playback status.""" - last_reported_state = self.player.status.state - while self._running: - try: - curr_state = self.player.status.state - if curr_state in (PlaybackState.PLAYING, PlaybackState.PAUSED): - await self._report_progress() - elif last_reported_state in (PlaybackState.PLAYING, PlaybackState.PAUSED) and curr_state == PlaybackState.STOPPED: - await self._report_stopped() - - last_reported_state = curr_state - except asyncio.CancelledError: - break - except Exception as e: - logger.debug("Reporter error: %s", e) - - await asyncio.sleep(4) - - async def shutdown(self) -> None: - self._running = False - if self._ws_task: - self._ws_task.cancel() - if self._report_task: - self._report_task.cancel() - if self.ws: - await self.ws.close() - if self.session: - await self.session.close() diff --git a/src/main.py b/src/main.py deleted file mode 100644 index 3f8d4eb..0000000 --- a/src/main.py +++ /dev/null @@ -1,89 +0,0 @@ -from __future__ import annotations - -import argparse -import asyncio -import logging -import signal -import sys -from typing import List - -from src.config import AppConfig -from src.jellyfin.client import JellyfinClient -from src.player.mpv_controller import MpvController -from src.remotes.kodi_eventserver import KodiEventServer -from src.remotes.kodi_rpc import KodiRpcServer -from src.remotes.rest_api import RestApiServer - -logger = logging.getLogger("jellyfin_client") - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Jellyfin HTPC Client & YouTube Player") - parser.add_argument("-c", "--config", help="Path to config.yaml file") - parser.add_argument("-d", "--debug", action="store_true", help="Enable debug logging") - return parser.parse_args() - - -async def run_app(config: AppConfig) -> None: - player = MpvController(config.player) - jellyfin = JellyfinClient(config.jellyfin, player) - kodi_rpc = KodiRpcServer(config.kodi_rpc, player) - kodi_event = KodiEventServer(config.kodi_rpc.event_port, player) - rest_api = RestApiServer(config.web_api, player) - - # Start services - await player.start() - await jellyfin.start() - await kodi_rpc.start() - await kodi_event.start() - await rest_api.start() - - logger.info("Jellyfin HTPC Client is fully initialized and ready.") - - # Setup signal handling for graceful shutdown - loop = asyncio.get_running_loop() - stop_event = asyncio.Event() - - def _sig_handler() -> None: - logger.info("Shutdown signal received") - stop_event.set() - - for sig in (signal.SIGINT, signal.SIGTERM): - try: - loop.add_signal_handler(sig, _sig_handler) - except NotImplementedError: - pass # Windows or non-main thread - - await stop_event.wait() - - logger.info("Shutting down services...") - await rest_api.shutdown() - await kodi_event.shutdown() - await kodi_rpc.shutdown() - await jellyfin.shutdown() - await player.shutdown() - - logger.info("Graceful shutdown complete.") - - -def main() -> None: - args = parse_args() - log_level = logging.DEBUG if args.debug else logging.INFO - logging.basicConfig( - level=log_level, - format="%(asctime)s [%(levelname)s] (%(name)s) %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - - config = AppConfig.load(args.config) - try: - asyncio.run(run_app(config)) - except KeyboardInterrupt: - pass - except Exception as e: - logger.critical("Fatal error: %s", e, exc_info=True) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/src/player/__init__.py b/src/player/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/player/models.py b/src/player/models.py deleted file mode 100644 index 20f6beb..0000000 --- a/src/player/models.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from enum import Enum -from typing import Any, Dict, List, Optional - - -class PlaybackState(str, Enum): - IDLE = "idle" - PLAYING = "playing" - PAUSED = "paused" - STOPPED = "stopped" - - -@dataclass -class TrackInfo: - id: int - type: str # "video", "audio", "sub" - title: Optional[str] = None - lang: Optional[str] = None - codec: Optional[str] = None - selected: bool = False - - -@dataclass -class MediaMetadata: - url: str - title: str = "Unknown" - artist: Optional[str] = None # Channel name for YouTube, artist for music, series name for episodes - album: Optional[str] = None # Playlist for YouTube, album for music, season for episodes - duration: float = 0.0 # Seconds - thumbnail: Optional[str] = None - source_type: str = "generic" # "jellyfin", "youtube", "url" - jellyfin_item_id: Optional[str] = None - jellyfin_media_source_id: Optional[str] = None - jellyfin_play_session_id: Optional[str] = None - extra: Dict[str, Any] = field(default_factory=dict) - - -@dataclass -class PlayerStatus: - state: PlaybackState = PlaybackState.IDLE - current_time: float = 0.0 - duration: float = 0.0 - volume: int = 100 - muted: bool = False - metadata: Optional[MediaMetadata] = None - tracks: List[TrackInfo] = field(default_factory=list) - speed: float = 1.0 - - @property - def percentage(self) -> float: - if self.duration > 0: - return min(100.0, max(0.0, (self.current_time / self.duration) * 100.0)) - return 0.0 - - @property - def is_active(self) -> bool: - return self.state in (PlaybackState.PLAYING, PlaybackState.PAUSED) diff --git a/src/player/mpv_controller.py b/src/player/mpv_controller.py deleted file mode 100644 index 282d25e..0000000 --- a/src/player/mpv_controller.py +++ /dev/null @@ -1,335 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -import os -from pathlib import Path -from typing import Any, Callable, Dict, List, Optional - -from src.config import PlayerConfig -from src.player.models import MediaMetadata, PlaybackState, PlayerStatus, TrackInfo - -logger = logging.getLogger(__name__) - - -class MpvController: - """Manages MPV media player process and IPC communication via Unix domain socket.""" - - def __init__(self, config: PlayerConfig): - self.config = config - self.status = PlayerStatus() - self.process: Optional[asyncio.subprocess.Process] = None - self._reader: Optional[asyncio.StreamReader] = None - self._writer: Optional[asyncio.StreamWriter] = None - self._request_id = 0 - self._pending_requests: Dict[int, asyncio.Future[Any]] = {} - self._listeners: List[Callable[[PlayerStatus], None]] = [] - self._stopping = False - self._read_task: Optional[asyncio.Task] = None - - def add_listener(self, callback: Callable[[PlayerStatus], None]) -> None: - self._listeners.append(callback) - - def _notify(self) -> None: - for cb in self._listeners: - try: - cb(self.status) - except Exception as e: - logger.error("Error in status listener callback: %s", e) - - async def start(self) -> None: - """Launch mpv in idle mode and establish IPC connection, with fallback to headless mode.""" - success = await self._spawn_mpv(vo=self.config.vo, hwdec=self.config.hwdec) - if not success and self.config.vo != "null": - logger.warning( - "MPV failed to start with vo=%s (possibly no active X11/Wayland display). Falling back to headless mode (--vo=null --ao=null)...", - self.config.vo, - ) - success = await self._spawn_mpv(vo="null", hwdec="no", extra_fallback_args=["--ao=null"]) - - if not success: - logger.error("Unable to initialize MPV in either display or headless mode.") - - async def _spawn_mpv( - self, - vo: str, - hwdec: str, - extra_fallback_args: Optional[List[str]] = None, - ) -> bool: - sock_path = Path(self.config.ipc_socket) - if sock_path.exists(): - try: - sock_path.unlink() - except OSError: - pass - - cmd = [ - self.config.mpv_path, - "--idle=yes", - f"--input-ipc-server={self.config.ipc_socket}", - f"--hwdec={hwdec}", - f"--vo={vo}", - "--fs" if (self.config.fullscreen and vo != "null") else "--fs=no", - ] - - if self.config.audio_device and self.config.audio_device != "auto" and not extra_fallback_args: - cmd.append(f"--audio-device={self.config.audio_device}") - - cmd.extend(self.config.extra_args) - if extra_fallback_args: - cmd.extend(extra_fallback_args) - - logger.info("Starting MPV: %s", " ".join(cmd)) - try: - self.process = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL, - ) - except Exception as e: - logger.error("Failed to spawn mpv process: %s", e) - return False - - # Wait for socket to become available - for _ in range(30): - if sock_path.exists(): - break - # If process already exited, abort early - if self.process.returncode is not None: - return False - await asyncio.sleep(0.1) - - if not sock_path.exists() or (self.process and self.process.returncode is not None): - if self.process and self.process.returncode is not None: - logger.warning("MPV process exited prematurely with code %s", self.process.returncode) - return False - - try: - self._reader, self._writer = await asyncio.open_unix_connection(str(sock_path)) - self._read_task = asyncio.create_task(self._socket_reader_loop()) - await self._setup_observers() - logger.info("Successfully connected to MPV IPC socket") - return True - except Exception as e: - logger.error("Failed to connect to MPV IPC socket: %s", e) - return False - - async def _setup_observers(self) -> None: - """Observe relevant MPV properties.""" - properties = [ - "time-pos", - "duration", - "pause", - "volume", - "mute", - "speed", - "track-list", - "idle-active", - ] - for prop in properties: - await self.command("observe_property", 0, prop) - - async def _socket_reader_loop(self) -> None: - """Read and dispatch messages from MPV IPC.""" - while not self._stopping and self._reader: - try: - line = await self._reader.readline() - if not line: - break - data = json.loads(line.decode("utf-8").strip()) - self._handle_ipc_message(data) - except asyncio.CancelledError: - break - except Exception as e: - logger.error("Error reading from MPV socket: %s", e) - break - - def _handle_ipc_message(self, msg: Dict[str, Any]) -> None: - req_id = msg.get("request_id") - if req_id is not None and req_id in self._pending_requests: - fut = self._pending_requests.pop(req_id) - if not fut.done(): - if msg.get("error") == "success": - fut.set_result(msg.get("data")) - else: - fut.set_exception(RuntimeError(f"MPV error: {msg.get('error')}")) - return - - event = msg.get("event") - if not event: - return - - if event == "property-change": - name = msg.get("name") - value = msg.get("data") - self._on_property_change(name, value) - elif event == "end-file": - reason = msg.get("reason") - logger.info("MPV file playback ended: reason=%s", reason) - self.status.state = PlaybackState.STOPPED - self._notify() - elif event == "playback-restart": - self.status.state = PlaybackState.PLAYING - self._notify() - - def _on_property_change(self, name: str, value: Any) -> None: - if name == "time-pos" and value is not None: - self.status.current_time = float(value) - elif name == "duration" and value is not None: - self.status.duration = float(value) - elif name == "pause": - if value is True: - self.status.state = PlaybackState.PAUSED - elif value is False: - if self.status.state != PlaybackState.IDLE: - self.status.state = PlaybackState.PLAYING - elif name == "volume" and value is not None: - self.status.volume = int(round(value)) - elif name == "mute": - self.status.muted = bool(value) - elif name == "speed" and value is not None: - self.status.speed = float(value) - elif name == "idle-active" and value is True: - self.status.state = PlaybackState.IDLE - self.status.current_time = 0.0 - self.status.duration = 0.0 - elif name == "track-list" and isinstance(value, list): - tracks = [] - for t in value: - tracks.append( - TrackInfo( - id=t.get("id", 0), - type=t.get("type", "unknown"), - title=t.get("title"), - lang=t.get("lang"), - codec=t.get("codec"), - selected=t.get("selected", False), - ) - ) - self.status.tracks = tracks - - self._notify() - - async def command(self, *args: Any) -> Any: - """Send a command to MPV and wait for response.""" - if not self._writer or (self.process and self.process.returncode is not None): - logger.debug("Cannot send command, MPV is disconnected: %s", args) - return None - - self._request_id += 1 - req_id = self._request_id - fut = asyncio.get_event_loop().create_future() - self._pending_requests[req_id] = fut - - msg = {"command": list(args), "request_id": req_id} - payload = (json.dumps(msg) + "\n").encode("utf-8") - try: - self._writer.write(payload) - await self._writer.drain() - return await asyncio.wait_for(fut, timeout=5.0) - except asyncio.TimeoutError: - self._pending_requests.pop(req_id, None) - logger.warning("MPV command timed out: %s", args) - return None - except Exception as e: - self._pending_requests.pop(req_id, None) - logger.warning("MPV command error (%s): %s", args, e) - return None - - - async def play_url( - self, - url: str, - metadata: Optional[MediaMetadata] = None, - start_seconds: float = 0.0, - ) -> None: - """Play a media URL (Jellyfin direct stream, YouTube, or direct file).""" - logger.info("Loading media into MPV: %s (start=%s)", url, start_seconds) - self.status.metadata = metadata or MediaMetadata(url=url) - self.status.state = PlaybackState.PLAYING - self.status.current_time = start_seconds - - options = [] - if start_seconds > 0: - options.append(f"start={int(start_seconds)}") - - if options: - await self.command("loadfile", url, "replace", ",".join(options)) - else: - await self.command("loadfile", url, "replace") - - # Set title in MPV OSD if known - if self.status.metadata and self.status.metadata.title: - title = self.status.metadata.title - if self.status.metadata.artist: - title = f"{self.status.metadata.artist} - {title}" - await self.command("set_property", "force-media-title", title) - - self._notify() - - async def pause(self) -> None: - await self.command("set_property", "pause", True) - self.status.state = PlaybackState.PAUSED - self._notify() - - async def resume(self) -> None: - await self.command("set_property", "pause", False) - self.status.state = PlaybackState.PLAYING - self._notify() - - async def play_pause(self) -> None: - if self.status.state == PlaybackState.PAUSED: - await self.resume() - elif self.status.state == PlaybackState.PLAYING: - await self.pause() - - async def stop(self) -> None: - await self.command("stop") - self.status.state = PlaybackState.STOPPED - self._notify() - - async def seek(self, seconds: float, absolute: bool = False) -> None: - seek_type = "absolute" if absolute else "relative" - await self.command("seek", seconds, seek_type) - - async def set_volume(self, volume: int) -> None: - clamped = max(0, min(100, volume)) - await self.command("set_property", "volume", clamped) - self.status.volume = clamped - self._notify() - - async def set_mute(self, muted: bool) -> None: - await self.command("set_property", "mute", muted) - self.status.muted = muted - self._notify() - - async def toggle_mute(self) -> None: - await self.set_mute(not self.status.muted) - - async def set_audio_track(self, track_id: int) -> None: - await self.command("set_property", "aid", track_id) - - async def set_subtitle_track(self, track_id: int) -> None: - await self.command("set_property", "sid", track_id) - - async def show_osd_message(self, message: str, duration_ms: int = 3000) -> None: - await self.command("show-text", message, duration_ms) - - async def shutdown(self) -> None: - self._stopping = True - if self._read_task: - self._read_task.cancel() - if self._writer: - try: - self._writer.close() - await self._writer.wait_closed() - except Exception: - pass - if self.process: - try: - self.process.terminate() - await asyncio.wait_for(self.process.wait(), timeout=2.0) - except Exception: - if self.process: - self.process.kill() diff --git a/src/remotes/__init__.py b/src/remotes/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/remotes/kodi_eventserver.py b/src/remotes/kodi_eventserver.py deleted file mode 100644 index bc86481..0000000 --- a/src/remotes/kodi_eventserver.py +++ /dev/null @@ -1,150 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import struct -from typing import Optional - -from src.player.mpv_controller import MpvController - -logger = logging.getLogger(__name__) - -# Kodi EventServer Packet Types -PT_HELO = 0x01 -PT_BYE = 0x02 -PT_BUTTON = 0x03 -PT_MOUSE = 0x04 -PT_PING = 0x05 -PT_NOTIFICATION = 0x06 -PT_BLOB = 0x07 -PT_LOG = 0x08 -PT_ACTION = 0x09 - -HEADER_FORMAT = "!4sBBHIIHI10s" -HEADER_SIZE = struct.calcsize(HEADER_FORMAT) # 32 bytes - - -class KodiEventServerProtocol(asyncio.DatagramProtocol): - """UDP protocol handler for Kodi EventServer packets from Yatse.""" - - def __init__(self, player: MpvController): - self.player = player - self.transport: Optional[asyncio.DatagramTransport] = None - - def connection_made(self, transport: asyncio.DatagramTransport) -> None: - self.transport = transport - logger.info("Kodi EventServer UDP protocol initialized") - - def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: - if len(data) < HEADER_SIZE: - return - - try: - sig, majver, minver, pkttype, seq, maxseq, paylen, client_token, _ = struct.unpack( - HEADER_FORMAT, data[:HEADER_SIZE] - ) - if sig != b"XBMC": - return - - payload = data[HEADER_SIZE : HEADER_SIZE + paylen] - self._handle_packet(pkttype, payload, addr, client_token) - except Exception as e: - logger.debug("Error parsing EventServer packet: %s", e) - - def _handle_packet(self, pkttype: int, payload: bytes, addr: tuple[str, int], client_token: int) -> None: - if pkttype == PT_HELO: - # Client registration - devname = payload.split(b"\x00")[0].decode("utf-8", errors="ignore") - logger.info("Yatse EventServer connected from %s (Device: %s)", addr, devname) - # Echo ping ack back to client - if self.transport: - ack = struct.pack(HEADER_FORMAT, b"XBMC", 2, 0, PT_PING, 1, 1, 0, client_token, b"\x00" * 10) - self.transport.sendto(ack, addr) - elif pkttype == PT_PING: - logger.debug("Yatse EventServer PING from %s", addr) - if self.transport: - ack = struct.pack(HEADER_FORMAT, b"XBMC", 2, 0, PT_PING, 1, 1, 0, client_token, b"\x00" * 10) - self.transport.sendto(ack, addr) - - elif pkttype == PT_BUTTON: - self._handle_button(payload) - elif pkttype == PT_ACTION: - self._handle_action(payload) - elif pkttype == PT_BYE: - logger.info("Yatse EventServer disconnected from %s", addr) - - def _handle_button(self, payload: bytes) -> None: - # Format: code(2B) + map_name(null-terminated) + button_name(null-terminated) + flags(2B) + amount(2B) - if len(payload) < 6: - return - try: - parts = payload[2:].split(b"\x00") - if len(parts) >= 2: - map_name = parts[0].decode("utf-8", errors="ignore") - button_name = parts[1].decode("utf-8", errors="ignore").lower() - - # Check flags: 0x01 is KEY_DOWN - tail = b"\x00".join(parts[2:]) - if len(tail) >= 2: - flags = struct.unpack("!H", tail[:2])[0] - if not (flags & 0x01): # Ignore key up events - return - - logger.info("EventServer button pressed: map=%s, btn=%s", map_name, button_name) - self._dispatch_button(button_name) - except Exception as e: - logger.debug("Error handling EventServer button: %s", e) - - def _handle_action(self, payload: bytes) -> None: - # Format: action_type(1B) + action_name(null-terminated string) - if len(payload) < 2: - return - action = payload[1:].split(b"\x00")[0].decode("utf-8", errors="ignore").lower() - logger.info("EventServer action received: %s", action) - if "playpause" in action or "play" in action: - asyncio.create_task(self.player.play_pause()) - elif "stop" in action: - asyncio.create_task(self.player.stop()) - - def _dispatch_button(self, btn: str) -> None: - if btn in ("left", "stepback"): - asyncio.create_task(self.player.seek(-10)) - elif btn in ("right", "stepforward"): - asyncio.create_task(self.player.seek(10)) - elif btn in ("up", "bigstepforward"): - asyncio.create_task(self.player.seek(60)) - elif btn in ("down", "bigstepback"): - asyncio.create_task(self.player.seek(-60)) - elif btn in ("select", "enter", "play", "pause", "playpause"): - asyncio.create_task(self.player.play_pause()) - elif btn in ("back", "stop"): - asyncio.create_task(self.player.stop()) - elif btn in ("volumeup", "volup"): - asyncio.create_task(self.player.set_volume(self.player.status.volume + 5)) - elif btn in ("volumedown", "voldown"): - asyncio.create_task(self.player.set_volume(self.player.status.volume - 5)) - - -class KodiEventServer: - """Manages the UDP socket for Kodi EventServer.""" - - def __init__(self, port: int, player: MpvController): - self.port = port - self.player = player - self.transport: Optional[asyncio.DatagramTransport] = None - - async def start(self) -> None: - loop = asyncio.get_running_loop() - try: - transport, _ = await loop.create_datagram_endpoint( - lambda: KodiEventServerProtocol(self.player), - local_addr=("0.0.0.0", self.port), - ) - self.transport = transport - logger.info("Kodi EventServer UDP listener started on 0.0.0.0:%d (Yatse compatible)", self.port) - except Exception as e: - logger.error("Failed to start Kodi EventServer on UDP port %d: %s", self.port, e) - - async def shutdown(self) -> None: - if self.transport: - self.transport.close() diff --git a/src/remotes/kodi_rpc.py b/src/remotes/kodi_rpc.py deleted file mode 100644 index b81ddfd..0000000 --- a/src/remotes/kodi_rpc.py +++ /dev/null @@ -1,552 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -from typing import Any, Dict, List, Optional, Set -from aiohttp import web - -from src.config import KodiRpcConfig -from src.player.models import MediaMetadata, PlaybackState, PlayerStatus -from src.player.mpv_controller import MpvController -from src.utils.youtube import fetch_youtube_metadata, is_youtube_url - -logger = logging.getLogger(__name__) - - -def _seconds_to_kodi_time(seconds: float) -> Dict[str, int]: - total_sec = max(0, int(seconds)) - hrs = total_sec // 3600 - mins = (total_sec % 3600) // 60 - secs = total_sec % 60 - millis = int((seconds - total_sec) * 1000) - return {"hours": hrs, "minutes": mins, "seconds": secs, "milliseconds": millis} - - -class KodiRpcServer: - """Emulates Kodi JSON-RPC server (HTTP, WebSocket, and raw TCP socket) for Yatse.""" - - def __init__(self, config: KodiRpcConfig, player: MpvController): - self.config = config - self.player = player - self.app = web.Application() - self._runner: Optional[web.AppRunner] = None - self._site: Optional[web.TCPSite] = None - self._ws_clients: Set[web.WebSocketResponse] = set() - self._tcp_clients: Set[asyncio.StreamWriter] = set() - self._tcp_server: Optional[asyncio.Server] = None - - self.app.router.add_post("/jsonrpc", self._handle_post_jsonrpc) - self.app.router.add_get("/jsonrpc", self._handle_get_jsonrpc) - self.app.router.add_get("/", self._handle_root) - - self.player.add_listener(self._on_player_status_changed) - - def _on_player_status_changed(self, status: PlayerStatus) -> None: - asyncio.create_task(self._broadcast_player_event(status)) - - async def _broadcast_player_event(self, status: PlayerStatus) -> None: - event_name = "Player.OnPlay" if status.state == PlaybackState.PLAYING else ( - "Player.OnPause" if status.state == PlaybackState.PAUSED else "Player.OnStop" - ) - notification = { - "jsonrpc": "2.0", - "method": event_name, - "params": { - "data": { - "player": {"playerid": 1, "speed": 1 if status.state == PlaybackState.PLAYING else 0}, - "item": {"type": "movie" if status.is_active else "unknown"}, - }, - "sender": "xbmc", - }, - } - - # Broadcast to WebSockets - dead_ws = set() - for ws in self._ws_clients: - try: - await ws.send_json(notification) - except Exception: - dead_ws.add(ws) - self._ws_clients -= dead_ws - - # Broadcast to raw TCP clients (Yatse socket) - payload = (json.dumps(notification) + "\n").encode("utf-8") - dead_tcp = set() - for writer in self._tcp_clients: - try: - writer.write(payload) - await writer.drain() - except Exception: - dead_tcp.add(writer) - self._tcp_clients -= dead_tcp - - async def _handle_root(self, request: web.Request) -> web.Response: - return web.Response(text="Kodi JSON-RPC emulation server for Yatse") - - async def _handle_post_jsonrpc(self, request: web.Request) -> web.Response: - try: - body = await request.json() - except Exception: - return web.json_response( - {"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": None} - ) - - if isinstance(body, list): - results = [await self._dispatch(item) for item in body] - return web.json_response(results) - else: - result = await self._dispatch(body) - return web.json_response(result) - - async def _handle_get_jsonrpc(self, request: web.Request) -> web.StreamResponse: - # Check if client is requesting a WebSocket upgrade - if request.headers.get("Upgrade", "").lower() == "websocket": - return await self._handle_websocket_jsonrpc(request) - - # Standard HTTP GET with ?request= (used by Yatse probe) - req_param = request.query.get("request") - if req_param: - try: - body = json.loads(req_param) - if isinstance(body, list): - res = [await self._dispatch(item) for item in body] - return web.json_response(res) - res = await self._dispatch(body) - return web.json_response(res) - except Exception as e: - logger.error("Failed to parse GET /jsonrpc request query param: %s", e) - return web.json_response( - {"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": None} - ) - - return web.json_response({"jsonrpc": "2.0", "result": "Kodi JSON-RPC Active", "id": None}) - - async def _handle_websocket_jsonrpc(self, request: web.Request) -> web.WebSocketResponse: - ws = web.WebSocketResponse() - await ws.prepare(request) - self._ws_clients.add(ws) - logger.info("Yatse WebSocket connected via JSON-RPC") - - try: - async for msg in ws: - if msg.type == web.WSMsgType.TEXT: - try: - data = json.loads(msg.data) - resp = await self._dispatch(data) - await ws.send_json(resp) - except Exception as e: - logger.error("Error in Kodi WebSocket JSON-RPC: %s", e) - elif msg.type in (web.WSMsgType.CLOSED, web.WSMsgType.ERROR): - break - finally: - self._ws_clients.discard(ws) - logger.info("Yatse WebSocket disconnected") - - return ws - - async def _handle_tcp_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: - self._tcp_clients.add(writer) - addr = writer.get_extra_info("peername") - logger.info("Yatse TCP connection established from %s", addr) - - buffer = "" - decoder = json.JSONDecoder() - try: - while not reader.at_eof(): - data = await reader.read(4096) - if not data: - break - buffer += data.decode("utf-8", errors="ignore") - while buffer: - buffer = buffer.lstrip() - if not buffer: - break - try: - obj, idx = decoder.raw_decode(buffer) - buffer = buffer[idx:] - res = await self._dispatch(obj) - out = (json.dumps(res) + "\n").encode("utf-8") - writer.write(out) - await writer.drain() - except json.JSONDecodeError: - break - except asyncio.CancelledError: - pass - except Exception as e: - logger.debug("Error in TCP client connection %s: %s", addr, e) - finally: - self._tcp_clients.discard(writer) - try: - writer.close() - await writer.wait_closed() - except Exception: - pass - logger.info("Yatse TCP connection closed from %s", addr) - - async def _dispatch(self, req: Dict[str, Any]) -> Dict[str, Any]: - req_id = req.get("id") - method = req.get("method", "") - params = req.get("params", {}) - - logger.info("Kodi JSON-RPC method called: %s (params=%s, id=%s)", method, params, req_id) - - try: - handler = self._methods.get(method) - if handler: - result = await handler(self, params) - return {"jsonrpc": "2.0", "result": result, "id": req_id} - else: - logger.info("Handled Kodi JSON-RPC method with generic OK: %s", method) - return {"jsonrpc": "2.0", "result": "OK", "id": req_id} - except Exception as e: - logger.error("Error dispatching method %s: %s", method, e) - return { - "jsonrpc": "2.0", - "error": {"code": -32603, "message": f"Internal error: {str(e)}"}, - "id": req_id, - } - - # --- JSON-RPC Method Handlers --- - - async def _rpc_ping(self, params: Any) -> str: - return "pong" - - async def _rpc_version(self, params: Any) -> Dict[str, Any]: - return {"version": {"major": 19, "minor": 0, "patch": 0, "tag": "release"}} - - async def _rpc_introspect(self, params: Any) -> Dict[str, Any]: - return {"methods": {m: {} for m in self._methods.keys()}} - - async def _rpc_jsonrpc_permission(self, params: Any) -> Dict[str, bool]: - return { - "ControlGUI": True, - "ControlNotify": True, - "ControlPlayback": True, - "ControlPower": True, - "ControlSystem": True, - "ExecuteAddon": True, - "ManageFiles": True, - "Navigate": True, - "ReadData": True, - "WriteData": True, - } - - async def _rpc_app_get_properties(self, params: Dict[str, Any]) -> Dict[str, Any]: - props = params.get("properties", []) - result = {} - for prop in props: - if prop == "volume": - result["volume"] = self.player.status.volume - elif prop == "muted": - result["muted"] = self.player.status.muted - elif prop == "name": - result["name"] = "Kodi" - elif prop == "version": - result["version"] = {"major": 19, "minor": 0, "revision": "0", "tag": "release"} - return result - - async def _rpc_gui_get_properties(self, params: Dict[str, Any]) -> Dict[str, Any]: - return { - "currentwindow": {"id": 10000, "label": "Home"}, - "currentcontrol": {"label": ""}, - "skin": {"id": "skin.estuary", "name": "Estuary"}, - "fullscreen": True, - } - - async def _rpc_system_get_properties(self, params: Dict[str, Any]) -> Dict[str, Any]: - return { - "canhibernate": False, - "canreboot": True, - "canshutdown": True, - "cansuspend": False, - } - - async def _rpc_xbmc_get_info_booleans(self, params: Dict[str, Any]) -> Dict[str, bool]: - booleans = params.get("booleans", []) - result = {} - for b in booleans: - if b in ("System.Platform.Linux", "Services.EventServer", "Services.Webserver"): - result[b] = True - elif b.startswith("System.Platform."): - result[b] = False - elif b == "System.HasModalDialog": - result[b] = False - else: - result[b] = True - return result - - async def _rpc_xbmc_get_info_labels(self, params: Dict[str, Any]) -> Dict[str, str]: - labels = params.get("labels", []) - result = {} - for l in labels: - if l == "System.BuildVersion": - result[l] = "19.4 (19.4.0) Git:20220302-e26f2334f3 Media Center Kodi" - elif l == "System.KernelVersion": - result[l] = "Linux 6.8.0" - elif l == "System.FriendlyName": - result[l] = "HTPC" - else: - result[l] = "" - return result - - async def _rpc_app_set_volume(self, params: Dict[str, Any]) -> int: - vol = params.get("volume", 100) - await self.player.set_volume(int(vol)) - return self.player.status.volume - - async def _rpc_app_set_mute(self, params: Dict[str, Any]) -> bool: - mute = params.get("mute") - if mute == "toggle": - await self.player.toggle_mute() - else: - await self.player.set_mute(bool(mute)) - return self.player.status.muted - - async def _rpc_player_get_active_players(self, params: Any) -> List[Dict[str, Any]]: - if self.player.status.is_active: - return [{"playerid": 1, "playertype": "internal", "type": "video"}] - return [] - - async def _rpc_player_get_properties(self, params: Dict[str, Any]) -> Dict[str, Any]: - props = params.get("properties", []) - result = {} - status = self.player.status - - for prop in props: - if prop == "percentage": - result["percentage"] = status.percentage - elif prop == "time": - result["time"] = _seconds_to_kodi_time(status.current_time) - elif prop == "totaltime": - result["totaltime"] = _seconds_to_kodi_time(status.duration) - elif prop == "speed": - result["speed"] = 1 if status.state == PlaybackState.PLAYING else 0 - elif prop == "partymode": - result["partymode"] = False - elif prop == "playlistid": - result["playlistid"] = 0 - elif prop == "position": - result["position"] = 0 - elif prop == "repeat": - result["repeat"] = "off" - elif prop == "shuffled": - result["shuffled"] = False - elif prop == "canseek": - result["canseek"] = True - elif prop == "live": - result["live"] = False - return result - - async def _rpc_player_get_item(self, params: Dict[str, Any]) -> Dict[str, Any]: - meta = self.player.status.metadata - item: Dict[str, Any] = { - "label": meta.title if meta else "Nothing playing", - "type": "movie", - } - if meta: - item["title"] = meta.title - item["artist"] = [meta.artist] if meta.artist else [] - item["album"] = meta.album or "" - item["duration"] = int(meta.duration) - item["file"] = meta.url - if meta.thumbnail: - item["thumbnail"] = meta.thumbnail - item["art"] = {"thumb": meta.thumbnail, "poster": meta.thumbnail} - - return {"item": item} - - async def _rpc_player_play_pause(self, params: Dict[str, Any]) -> Dict[str, Any]: - play_arg = params.get("play") - if play_arg is True or play_arg == "true": - await self.player.resume() - elif play_arg is False or play_arg == "false": - await self.player.pause() - else: - await self.player.play_pause() - - speed = 1 if self.player.status.state == PlaybackState.PLAYING else 0 - return {"speed": speed} - - async def _rpc_player_stop(self, params: Any) -> str: - await self.player.stop() - return "OK" - - async def _rpc_player_seek(self, params: Dict[str, Any]) -> Dict[str, Any]: - value = params.get("value") - status = self.player.status - - if isinstance(value, (int, float)): - target = (float(value) / 100.0) * status.duration - await self.player.seek(target, absolute=True) - elif isinstance(value, dict): - hrs = value.get("hours", 0) - mins = value.get("minutes", 0) - secs = value.get("seconds", 0) - target = hrs * 3600 + mins * 60 + secs - await self.player.seek(target, absolute=True) - elif value == "smallforward": - await self.player.seek(10, absolute=False) - elif value == "smallbackward": - await self.player.seek(-10, absolute=False) - elif value == "bigforward": - await self.player.seek(60, absolute=False) - elif value == "bigbackward": - await self.player.seek(-60, absolute=False) - - return { - "percentage": self.player.status.percentage, - "time": _seconds_to_kodi_time(self.player.status.current_time), - "totaltime": _seconds_to_kodi_time(self.player.status.duration), - } - - async def _rpc_player_open(self, params: Dict[str, Any]) -> str: - item = params.get("item", {}) - file_url = item.get("file") or item.get("path") - if not file_url: - raise ValueError("No file or path provided in Player.Open") - - logger.info("Yatse Player.Open requested URL: %s", file_url) - - metadata: Optional[MediaMetadata] = None - if is_youtube_url(file_url): - logger.info("Detected YouTube media via Yatse share: %s", file_url) - metadata = await fetch_youtube_metadata(file_url) - - await self.player.play_url(file_url, metadata=metadata) - return "OK" - - async def _rpc_playlist_add(self, params: Dict[str, Any]) -> str: - item = params.get("item", {}) - file_url = item.get("file") - if file_url: - await self._rpc_player_open({"item": {"file": file_url}}) - return "OK" - - async def _rpc_playlist_get_items(self, params: Any) -> Dict[str, Any]: - meta = self.player.status.metadata - items = [] - if meta: - items.append({"title": meta.title, "file": meta.url, "duration": int(meta.duration)}) - return {"items": items, "limits": {"start": 0, "total": len(items), "end": len(items)}} - - async def _rpc_gui_show_notification(self, params: Dict[str, Any]) -> str: - title = params.get("title", "") - msg = params.get("message", "") - text = f"{title}: {msg}" if title else msg - await self.player.show_osd_message(text) - return "OK" - - async def _rpc_input_ok(self, params: Any) -> str: - return "OK" - - async def _rpc_input_back(self, params: Any) -> str: - await self.player.stop() - return "OK" - - async def _rpc_settings_get_setting_value(self, params: Dict[str, Any]) -> Dict[str, Any]: - setting = params.get("setting", "").lower() - if "port" in setting: - val = 9777 if "es" in setting else 8080 - elif any(k in setting for k in ("enabled", "eventserver", "webserver", "interface", "upnp", "zeroconf")): - val = True - else: - val = True - return {"value": val} - - async def _rpc_settings_set_setting_value(self, params: Dict[str, Any]) -> bool: - return True - - async def _rpc_settings_get_settings(self, params: Dict[str, Any]) -> Dict[str, Any]: - return { - "settings": [ - {"id": "services.eventserver", "value": True}, - {"id": "services.esenabled", "value": True}, - {"id": "services.esport", "value": 9777}, - {"id": "services.esallinterfaces", "value": True}, - {"id": "services.webserver", "value": True}, - {"id": "services.webserverport", "value": 8080}, - ] - } - - async def _rpc_playlist_get_playlists(self, params: Any) -> List[Dict[str, Any]]: - return [{"playlistid": 0, "type": "audio"}, {"playlistid": 1, "type": "video"}] - - async def _rpc_files_get_sources(self, params: Any) -> Dict[str, Any]: - return {"sources": [], "limits": {"start": 0, "total": 0, "end": 0}} - - _methods = { - "JSONRPC.Ping": _rpc_ping, - "JSONRPC.Version": _rpc_version, - "JSONRPC.Introspect": _rpc_introspect, - "JSONRPC.Permission": _rpc_jsonrpc_permission, - "Application.GetProperties": _rpc_app_get_properties, - "Application.SetVolume": _rpc_app_set_volume, - "Application.SetMute": _rpc_app_set_mute, - "GUI.GetProperties": _rpc_gui_get_properties, - "System.GetProperties": _rpc_system_get_properties, - "Settings.GetSettingValue": _rpc_settings_get_setting_value, - "Settings.SetSettingValue": _rpc_settings_set_setting_value, - "Settings.GetSettings": _rpc_settings_get_settings, - "XBMC.GetInfoBooleans": _rpc_xbmc_get_info_booleans, - "XBMC.GetInfoLabels": _rpc_xbmc_get_info_labels, - "Player.GetActivePlayers": _rpc_player_get_active_players, - "Player.GetProperties": _rpc_player_get_properties, - "Player.GetItem": _rpc_player_get_item, - "Player.PlayPause": _rpc_player_play_pause, - "Player.Stop": _rpc_player_stop, - "Player.Seek": _rpc_player_seek, - "Player.Open": _rpc_player_open, - "Playlist.Add": _rpc_playlist_add, - "Playlist.GetItems": _rpc_playlist_get_items, - "Playlist.GetPlaylists": _rpc_playlist_get_playlists, - "Files.GetSources": _rpc_files_get_sources, - "GUI.ShowNotification": _rpc_gui_show_notification, - "Input.Back": _rpc_input_back, - "Input.Home": _rpc_input_ok, - "Input.Up": _rpc_input_ok, - "Input.Down": _rpc_input_ok, - "Input.Left": _rpc_input_ok, - "Input.Right": _rpc_input_ok, - "Input.Select": _rpc_input_ok, - "Input.ContextMenu": _rpc_input_ok, - "Input.Info": _rpc_input_ok, - "Input.ShowOSD": _rpc_input_ok, - } - - - async def start(self) -> None: - if not self.config.enabled: - logger.info("Kodi RPC emulation server is disabled in config") - return - - # Start HTTP & WebSocket server on http_port (8080) - self._runner = web.AppRunner(self.app) - await self._runner.setup() - self._site = web.TCPSite(self._runner, "0.0.0.0", self.config.http_port) - await self._site.start() - logger.info( - "Kodi JSON-RPC HTTP server listening on http://0.0.0.0:%d (Yatse compatible)", - self.config.http_port, - ) - - # Start raw TCP socket server on ws_port (9090) - await self._start_tcp_server() - - async def _start_tcp_server(self) -> None: - try: - self._tcp_server = await asyncio.start_server( - self._handle_tcp_client, - "0.0.0.0", - self.config.ws_port, - ) - logger.info("Kodi TCP JSON-RPC socket server listening on port %d", self.config.ws_port) - except Exception as e: - logger.error("Failed to start TCP JSON-RPC server on port %d: %s", self.config.ws_port, e) - - async def shutdown(self) -> None: - if self._tcp_server: - self._tcp_server.close() - await self._tcp_server.wait_closed() - if self._runner: - await self._runner.cleanup() diff --git a/src/remotes/rest_api.py b/src/remotes/rest_api.py deleted file mode 100644 index 0008acb..0000000 --- a/src/remotes/rest_api.py +++ /dev/null @@ -1,343 +0,0 @@ -from __future__ import annotations - -import logging -from typing import Any, Dict, Optional -from aiohttp import web - -from src.config import WebApiConfig -from src.player.models import MediaMetadata, PlaybackState -from src.player.mpv_controller import MpvController -from src.utils.youtube import fetch_youtube_metadata, is_youtube_url - -logger = logging.getLogger(__name__) - -HTML_TEMPLATE = """ - - - - - HTPC Media Remote - - - -
-

HTPC Media Player

- -
-
- - - -
-
- -
-
Nothing Playing
-
-
- -
-
-
-
- 00:00 - 00:00 -
- -
- - - - -
- -
- - -
-
-
- - - - -""" - - -class RestApiServer: - """Provides HTTP REST API endpoints and web interface for playback control and URL sharing.""" - - def __init__(self, config: WebApiConfig, player: MpvController): - self.config = config - self.player = player - self.app = web.Application() - self._runner: Optional[web.AppRunner] = None - self._site: Optional[web.TCPSite] = None - - self.app.router.add_get("/", self._handle_index) - self.app.router.add_get("/api/status", self._handle_status) - self.app.router.add_post("/api/play", self._handle_play) - self.app.router.add_post("/api/playpause", self._handle_playpause) - self.app.router.add_post("/api/pause", self._handle_pause) - self.app.router.add_post("/api/resume", self._handle_resume) - self.app.router.add_post("/api/stop", self._handle_stop) - self.app.router.add_post("/api/seek", self._handle_seek) - self.app.router.add_post("/api/volume", self._handle_volume) - - async def _handle_index(self, request: web.Request) -> web.Response: - return web.Response(text=HTML_TEMPLATE, content_type="text/html") - - async def _handle_status(self, request: web.Request) -> web.Response: - st = self.player.status - meta = st.metadata - data = { - "state": st.state.value, - "current_time": st.current_time, - "duration": st.duration, - "percentage": st.percentage, - "volume": st.volume, - "muted": st.muted, - "title": meta.title if meta else None, - "artist": meta.artist if meta else None, - "album": meta.album if meta else None, - "thumbnail": meta.thumbnail if meta else None, - "source_type": meta.source_type if meta else None, - } - return web.json_response(data) - - async def _handle_play(self, request: web.Request) -> web.Response: - try: - body = await request.json() - except Exception: - return web.json_response({"error": "Invalid JSON"}, status=400) - - url = body.get("url") - if not url: - return web.json_response({"error": "Missing 'url' parameter"}, status=400) - - metadata: Optional[MediaMetadata] = None - if is_youtube_url(url): - logger.info("REST API received YouTube URL: %s", url) - metadata = await fetch_youtube_metadata(url) - else: - logger.info("REST API received media URL: %s", url) - metadata = MediaMetadata(url=url, title=body.get("title") or url, source_type="url") - - start_time = float(body.get("start", 0.0)) - await self.player.play_url(url, metadata=metadata, start_seconds=start_time) - return web.json_response({"status": "playing", "title": metadata.title if metadata else url}) - - async def _handle_playpause(self, request: web.Request) -> web.Response: - await self.player.play_pause() - return web.json_response({"status": self.player.status.state.value}) - - async def _handle_pause(self, request: web.Request) -> web.Response: - await self.player.pause() - return web.json_response({"status": "paused"}) - - async def _handle_resume(self, request: web.Request) -> web.Response: - await self.player.resume() - return web.json_response({"status": "playing"}) - - async def _handle_stop(self, request: web.Request) -> web.Response: - await self.player.stop() - return web.json_response({"status": "stopped"}) - - async def _handle_seek(self, request: web.Request) -> web.Response: - body = await request.json() - seconds = float(body.get("seconds", 0.0)) - absolute = bool(body.get("absolute", False)) - await self.player.seek(seconds, absolute=absolute) - return web.json_response({"current_time": self.player.status.current_time}) - - async def _handle_volume(self, request: web.Request) -> web.Response: - body = await request.json() - vol = int(body.get("volume", 100)) - await self.player.set_volume(vol) - return web.json_response({"volume": self.player.status.volume}) - - async def start(self) -> None: - if not self.config.enabled: - logger.info("REST API server is disabled in config") - return - - self._runner = web.AppRunner(self.app) - await self._runner.setup() - self._site = web.TCPSite(self._runner, self.config.host, self.config.port) - await self._site.start() - logger.info("REST API & Web Remote listening on http://%s:%d", self.config.host, self.config.port) - - async def shutdown(self) -> None: - if self._runner: - await self._runner.cleanup() diff --git a/src/utils/__init__.py b/src/utils/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/utils/youtube.py b/src/utils/youtube.py deleted file mode 100644 index 53ef8ed..0000000 --- a/src/utils/youtube.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -import re -from typing import Optional, Tuple -from urllib.parse import parse_qs, unquote, urlparse - -from src.player.models import MediaMetadata - -logger = logging.getLogger(__name__) - -YOUTUBE_REGEX = re.compile( - r"(?:https?://)?(?:www\.|m\.)?(?:youtube\.com/(?:watch\?.*v=|embed/|v/|shorts/)|youtu\.be/)([\w-]{11})", - re.IGNORECASE, -) - -KODI_PLUGIN_YOUTUBE_REGEX = re.compile( - r"plugin://plugin\.video\.youtube/(?:play/)?\?.*video_id=([\w-]{11})", - re.IGNORECASE, -) - - -def extract_youtube_video_id(url_or_text: str) -> Optional[str]: - """Extract an 11-character YouTube video ID from various URL formats or text.""" - if not url_or_text: - return None - - url_clean = url_or_text.strip() - - # Check for Kodi YouTube plugin URL: plugin://plugin.video.youtube/play/?video_id=... - match = KODI_PLUGIN_YOUTUBE_REGEX.search(url_clean) - if match: - return match.group(1) - - # Check for standard YouTube URL - match = YOUTUBE_REGEX.search(url_clean) - if match: - return match.group(1) - - # Check query string params (e.g., if inside a redirect or wrapped URL) - try: - parsed = urlparse(url_clean) - qs = parse_qs(parsed.query) - if "v" in qs and qs["v"]: - vid = qs["v"][0] - if len(vid) == 11: - return vid - if "video_id" in qs and qs["video_id"]: - vid = qs["video_id"][0] - if len(vid) == 11: - return vid - except Exception: - pass - - return None - - -def is_youtube_url(url_or_text: str) -> bool: - return extract_youtube_video_id(url_or_text) is not None - - -def get_canonical_youtube_url(video_id: str) -> str: - return f"https://www.youtube.com/watch?v={video_id}" - - -async def fetch_youtube_metadata(video_url_or_id: str) -> Optional[MediaMetadata]: - """Fetch video title, channel, duration, and thumbnail using yt-dlp.""" - video_id = extract_youtube_video_id(video_url_or_id) - canonical_url = get_canonical_youtube_url(video_id) if video_id else video_url_or_id - - try: - proc = await asyncio.create_subprocess_exec( - "yt-dlp", - "--dump-json", - "--no-playlist", - "--skip-download", - canonical_url, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=8.0) - - if proc.returncode != 0: - logger.warning("yt-dlp returned code %d: %s", proc.returncode, stderr.decode().strip()) - return None - - info = json.loads(stdout.decode("utf-8")) - title = info.get("title", "YouTube Video") - uploader = info.get("uploader") or info.get("channel") or "YouTube" - duration = float(info.get("duration") or 0.0) - thumbnail = info.get("thumbnail") - - return MediaMetadata( - url=canonical_url, - title=title, - artist=uploader, - album="YouTube", - duration=duration, - thumbnail=thumbnail, - source_type="youtube", - extra={"youtube_id": video_id, "view_count": info.get("view_count")}, - ) - except asyncio.TimeoutError: - logger.warning("Timeout fetching metadata with yt-dlp for %s", canonical_url) - except Exception as e: - logger.warning("Error fetching metadata via yt-dlp: %s", e) - - # Fallback if yt-dlp is unavailable or times out - return MediaMetadata( - url=canonical_url, - title=f"YouTube Video ({video_id})" if video_id else "YouTube Video", - artist="YouTube", - source_type="youtube", - extra={"youtube_id": video_id}, - ) diff --git a/tests/test_eventserver_client.py b/tests/test_eventserver_client.py new file mode 100644 index 0000000..b1e556c --- /dev/null +++ b/tests/test_eventserver_client.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Local Kodi EventServer (Yatse UDP 9777) test utility and CLI. + +This script allows you to test the Kodi EventServer locally by sending +valid Kodi EventServer UDP packets (HELO, PING, BUTTON, ACTION) and +verifying handshake responses. + +Usage examples: + # 1. Test HELO and PING handshake + python3 tests/test_eventserver_client.py ping + + # 2. Send navigation buttons + python3 tests/test_eventserver_client.py up + python3 tests/test_eventserver_client.py down + python3 tests/test_eventserver_client.py left + python3 tests/test_eventserver_client.py right + python3 tests/test_eventserver_client.py select + python3 tests/test_eventserver_client.py back + + # 3. Interactive keyboard control mode: + python3 tests/test_eventserver_client.py interactive + + # 4. Run automated test suite against running server: + python3 tests/test_eventserver_client.py test +""" + +from __future__ import annotations + +import argparse +import random +import socket +import struct +import sys +import time + +# Kodi EventServer Constants +HEADER_FORMAT = "!4sBBHIIHI10s" +HEADER_SIZE = struct.calcsize(HEADER_FORMAT) # 32 bytes + +PT_HELO = 0x01 +PT_BYE = 0x02 +PT_BUTTON = 0x03 +PT_PING = 0x05 +PT_ACTION = 0x0A + +PTB_DOWN = 0x02 +PTB_UP = 0x04 + + +class EventServerClient: + """UDP Client simulating Yatse / Kodi remote.""" + + def __init__(self, host: str = "127.0.0.1", port: int = 9777, timeout: float = 2.0): + self.host = host + self.port = port + self.timeout = timeout + self.token = random.randint(100000, 999999) + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock.settimeout(timeout) + + def close(self) -> None: + self.sock.close() + + def _build_header(self, pkttype: int, paylen: int) -> bytes: + return struct.pack( + HEADER_FORMAT, + b"XBMC", # Signature + 2, # Major version + 0, # Minor version + pkttype, # Packet type + 1, # Seq + 1, # MaxSeq + paylen, # Payload length + self.token, + b"\x00" * 10, + ) + + def send_helo(self, device_name: str = "Local Yatse Test") -> bool: + """Send HELO packet and wait for PING ACK response.""" + payload = ( + device_name.encode("utf-8") + b"\x00" + + b"\x00" + # Icon type + struct.pack("!H", 0) + # Port + b"\x00" * 8 # Reserved + ) + packet = self._build_header(PT_HELO, len(payload)) + payload + self.sock.sendto(packet, (self.host, self.port)) + + try: + data, addr = self.sock.recvfrom(1024) + if len(data) >= HEADER_SIZE: + sig, maj, minv, pkttype, seq, maxseq, plen, token, _ = struct.unpack( + HEADER_FORMAT, data[:HEADER_SIZE] + ) + if sig == b"XBMC" and pkttype == PT_PING and token == self.token: + return True + except socket.timeout: + return False + return False + + def send_ping(self) -> bool: + """Send PING packet and wait for PING ACK response.""" + packet = self._build_header(PT_PING, 0) + self.sock.sendto(packet, (self.host, self.port)) + + try: + data, addr = self.sock.recvfrom(1024) + if len(data) >= HEADER_SIZE: + sig, _, _, pkttype, _, _, _, token, _ = struct.unpack( + HEADER_FORMAT, data[:HEADER_SIZE] + ) + if sig == b"XBMC" and pkttype == PT_PING and token == self.token: + return True + except socket.timeout: + return False + return False + + def send_button(self, button_name: str, map_name: str = "R1", is_down: bool = True) -> None: + """Send BUTTON packet (e.g. up, down, left, right, select, back).""" + flags = PTB_DOWN if is_down else PTB_UP + payload = ( + struct.pack("!HHH", 0, flags, 0) + + map_name.encode("utf-8") + b"\x00" + + button_name.encode("utf-8") + b"\x00" + ) + packet = self._build_header(PT_BUTTON, len(payload)) + payload + self.sock.sendto(packet, (self.host, self.port)) + + def send_action(self, action_str: str) -> None: + """Send ACTION packet (e.g. Action(1), Action(7), Up).""" + payload = b"\x01" + action_str.encode("utf-8") + b"\x00" + packet = self._build_header(PT_ACTION, len(payload)) + payload + self.sock.sendto(packet, (self.host, self.port)) + + def send_bye(self) -> None: + """Send BYE disconnect packet.""" + packet = self._build_header(PT_BYE, 0) + self.sock.sendto(packet, (self.host, self.port)) + + +def run_automated_tests(host: str, port: int) -> bool: + print(f"Testing Kodi EventServer at {host}:{port}...") + client = EventServerClient(host, port, timeout=2.0) + + # Test 1: HELO Handshake + print(" [1/4] Testing HELO handshake (client registration)...", end=" ", flush=True) + if client.send_helo("EventServer Test Suite"): + print("OK (Received PING-ACK)") + else: + print("FAILED (No PING-ACK response received)") + print(f" Ensure jellyfin-kiosk is running and listening on UDP {port}") + client.close() + return False + + # Test 2: PING Heartbeat + print(" [2/4] Testing PING heartbeat...", end=" ", flush=True) + if client.send_ping(): + print("OK (Received PING-ACK)") + else: + print("FAILED") + client.close() + return False + + # Test 3: Button Events + print(" [3/4] Sending navigation buttons (Up, Down, Left, Right, Select, Back)...", end=" ", flush=True) + for btn in ["up", "down", "left", "right", "select", "back"]: + client.send_button(btn) + time.sleep(0.05) + print("OK (Packets sent)") + + # Test 4: Action Events + print(" [4/4] Sending action strings (Action(1), Action(7))...", end=" ", flush=True) + for act in ["Action(1)", "Action(7)"]: + client.send_action(act) + time.sleep(0.05) + print("OK (Packets sent)") + + client.send_bye() + client.close() + print("\nAll EventServer tests passed successfully!") + return True + + +def run_interactive_mode(client: EventServerClient) -> None: + print("\n--- EventServer Interactive Remote Mode ---") + print("Controls:") + print(" [W] or [Up] : Up") + print(" [S] or [Down] : Down") + print(" [A] or [Left] : Left") + print(" [D] or [Right] : Right") + print(" [Enter] / [O] : Select / OK") + print(" [B] / [Esc] : Back") + print(" [Space] / [P] : Play / Pause") + print(" [X] : Stop") + print(" [Q] : Quit\n") + + import termios + import tty + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(fd) + while True: + ch = sys.stdin.read(1) + if ch == "\x1b": # Escape or arrow sequence + # Non-blocking check for arrow keys + import select + r, _, _ = select.select([sys.stdin], [], [], 0.05) + if r: + seq = sys.stdin.read(2) + if seq == "[A": + client.send_button("up") + elif seq == "[B": + client.send_button("down") + elif seq == "[C": + client.send_button("right") + elif seq == "[D": + client.send_button("left") + else: + client.send_button("back") + elif ch in ("q", "Q", "\x03"): # Quit or Ctrl+C + break + elif ch in ("w", "W"): + client.send_button("up") + elif ch in ("s", "S"): + client.send_button("down") + elif ch in ("a", "A"): + client.send_button("left") + elif ch in ("d", "D"): + client.send_button("right") + elif ch in ("\r", "\n", "o", "O"): + client.send_button("select") + elif ch in ("b", "B"): + client.send_button("back") + elif ch in (" ", "p", "P"): + client.send_button("playpause") + elif ch in ("x", "X"): + client.send_button("stop") + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + print("\nExited interactive mode.") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Kodi EventServer Local Test Client") + parser.add_argument("command", nargs="?", default="test", + help="Command: test, ping, helo, up, down, left, right, select, back, play, stop, interactive") + parser.add_argument("--host", default="127.0.0.1", help="EventServer host (default: 127.0.0.1)") + parser.add_argument("--port", type=int, default=9777, help="EventServer UDP port (default: 9777)") + + args = parser.parse_args() + cmd = args.command.lower() + + if cmd == "test": + success = run_automated_tests(args.host, args.port) + return 0 if success else 1 + + client = EventServerClient(args.host, args.port) + try: + if cmd == "ping": + print(f"Sending PING to {args.host}:{args.port}...") + if client.send_ping(): + print("Success: Received PING-ACK from EventServer!") + else: + print("Failed: No response (timeout)") + return 1 + elif cmd == "helo": + print(f"Sending HELO to {args.host}:{args.port}...") + if client.send_helo(): + print("Success: EventServer accepted HELO and replied with PING-ACK!") + else: + print("Failed: No response (timeout)") + return 1 + elif cmd in ("up", "down", "left", "right", "select", "back", "play", "pause", "playpause", "stop"): + client.send_button(cmd) + print(f"Sent button '{cmd}' to {args.host}:{args.port}") + elif cmd == "interactive": + run_interactive_mode(client) + else: + print(f"Unknown command: {cmd}") + return 1 + finally: + client.close() + + return 0 + + +if __name__ == "__main__": + sys.exit(main())