commit eb0d4fb1c5aa23e2caa6ecf29e849c0a0209feb9 Author: Ultradesu Date: Wed Aug 5 12:32:52 2026 +0100 Init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f9a237d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +**/.git +**/.agents +**/.codex +**/target +/media +**/.DS_Store diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml new file mode 100644 index 0000000..b350577 --- /dev/null +++ b/.github/workflows/build-and-publish.yml @@ -0,0 +1,45 @@ +name: Build and Publish + +on: + push: + tags: + - 'v*.*.*' + +env: + IMAGE_NAME: ultradesu/lan-play + +jobs: + build_docker: + name: Build and Publish Docker Image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata + id: meta + run: | + VERSION=$(grep '^version' Cargo.toml | head -1 | cut -d'"' -f2) + TAG_NAME=${GITHUB_REF#refs/tags/} + echo "docker_tags=${IMAGE_NAME}:${TAG_NAME},${IMAGE_NAME}:${VERSION},${IMAGE_NAME}:latest" >> "$GITHUB_OUTPUT" + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.docker_tags }} + cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache + cache-to: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e38694c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1017 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[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", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lan-play" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "clap", + "mime_guess", + "serde", + "serde_json", + "tempfile", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[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.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[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", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[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 = "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 = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[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 = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +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 = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +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" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[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 = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[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.3", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[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", + "tracing", +] + +[[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 = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8cb41b6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "lan-play" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1" +axum = "0.8" +clap = { version = "4", features = ["derive"] } +mime_guess = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tempfile = "3" +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["io"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1", features = ["v4"] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ce28850 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM rust:1-bookworm AS builder + +WORKDIR /app + +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +COPY web ./web + +RUN cargo build --release --locked + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates ffmpeg \ + && useradd --system --uid 10001 --create-home lanplay \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/target/release/lan-play /usr/local/bin/lan-play + +USER lanplay +WORKDIR /home/lanplay + +EXPOSE 8080 +ENTRYPOINT ["lan-play"] +CMD ["/media", "--bind", "0.0.0.0:8080"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a446e7c --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# lan-play + +A small self-hosted web player for local video libraries. + +lan-play makes folders with movies, shows, and anime easy to watch on TVs and +other devices with a web browser. Point it at a media directory, open the web +interface, and play files without setting up a database or importing a library. + +## Features + +- Folder-based media browser with animated previews +- Live remuxing and transcoding to browser-compatible MPEG-DASH +- Multiple audio track selection +- Embedded and external ASS, SSA, SRT, and WebVTT subtitles +- Automatic subtitle discovery in nearby folders +- Fullscreen TV-friendly interface with episode navigation +- No database, accounts, or library indexing + +## Docker + +```bash +docker run --rm \ + -p 8080:8080 \ + -v /path/to/videos:/media:ro \ + ultradesu/lan-play:latest +``` + +Open `http://HOST:8080` on your TV or another device on the same network. + +## Run from source + +Requires Rust, `ffmpeg`, and `ffprobe`. + +```bash +cargo run --release -- /path/to/videos +``` + +Use `--bind 127.0.0.1:8080` when placing lan-play behind a reverse proxy. + +## Notes + +lan-play is intended for trusted local networks. It does not provide +authentication and should not be exposed directly to the public internet. diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..a3bea2b --- /dev/null +++ b/src/app.rs @@ -0,0 +1,61 @@ +use std::sync::Arc; + +use axum::{ + Router, + routing::{delete, get, post}, +}; +use tokio::sync::Semaphore; + +use crate::{config::Config, diagnostics, media, media::dash::DashManager, web}; + +pub struct AppState { + pub config: Config, + pub dash: DashManager, + pub preview_jobs: Semaphore, +} + +impl AppState { + pub fn new(config: Config) -> anyhow::Result { + Ok(Self { + config, + dash: DashManager::new()?, + preview_jobs: Semaphore::new(2), + }) + } +} + +pub fn router(state: Arc) -> Router { + Router::new() + .route("/", get(web::index)) + .route("/alpine.min.js", get(web::alpine)) + .route("/dash.all.min.js", get(web::dash)) + .route("/dash.all.min.js.map", get(web::dash_source_map)) + .route("/controlbar.min.js", get(web::controlbar_javascript)) + .route("/controlbar.css", get(web::controlbar_styles)) + .route("/bootstrap-icons.min.css", get(web::bootstrap_icon_styles)) + .route( + "/fonts/bootstrap-icons.woff2", + get(web::bootstrap_icon_font), + ) + .route("/js/api.js", get(web::api_javascript)) + .route("/js/library.js", get(web::library_javascript)) + .route("/js/location.js", get(web::location_javascript)) + .route("/js/preferences.js", get(web::preferences_javascript)) + .route("/js/player.js", get(web::player_javascript)) + .route("/styles.css", get(web::styles)) + .route("/api/info", get(diagnostics::info)) + .route("/api/browse", get(media::browse::browse)) + .route("/api/probe", get(media::probe::probe)) + .route("/api/preview", get(media::preview::sprite)) + .route("/api/stream", get(media::stream::stream)) + .route("/api/subtitles", get(media::subtitles::list)) + .route("/api/subtitles/{index}", get(media::subtitles::extract)) + .route( + "/api/subtitles/external/{id}", + get(media::subtitles::extract_external), + ) + .route("/api/dash", post(media::dash::start)) + .route("/api/dash/{session}", delete(media::dash::stop)) + .route("/api/dash/{session}/{asset}", get(media::dash::asset)) + .with_state(state) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..1315258 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,34 @@ +use std::{net::SocketAddr, path::PathBuf}; + +use anyhow::{Context, ensure}; +use clap::Parser; + +#[derive(Debug, Clone, Parser)] +#[command(version, about = "A small LAN media browser for smart TVs")] +pub struct Config { + /// Directory exposed as the media library. + pub media_root: PathBuf, + + /// HTTP listen address. + #[arg(long, default_value = "0.0.0.0:8080")] + pub bind: SocketAddr, + + /// ffprobe executable. + #[arg(long, default_value = "ffprobe")] + pub ffprobe: PathBuf, + + /// ffmpeg executable used for DASH packaging and transcoding. + #[arg(long, default_value = "ffmpeg")] + pub ffmpeg: PathBuf, +} + +impl Config { + pub fn prepare(mut self) -> anyhow::Result { + ensure!(self.media_root.is_dir(), "media root is not a directory"); + self.media_root = self + .media_root + .canonicalize() + .context("failed to resolve media root")?; + Ok(self) + } +} diff --git a/src/diagnostics.rs b/src/diagnostics.rs new file mode 100644 index 0000000..8dcbc35 --- /dev/null +++ b/src/diagnostics.rs @@ -0,0 +1,80 @@ +use std::sync::Arc; + +use axum::{Json, extract::State}; +use serde::Serialize; +use tokio::process::Command; + +use crate::app::AppState; + +#[derive(Serialize)] +pub struct Diagnostics { + application: &'static str, + version: &'static str, + build: &'static str, + platform: String, + ffmpeg: ToolInfo, + ffprobe: ToolInfo, + media_root: String, + bind: String, +} + +#[derive(Serialize)] +struct ToolInfo { + path: String, + version: String, +} + +pub async fn info(State(state): State>) -> Json { + let (ffmpeg, ffprobe) = tokio::join!( + tool_info(&state.config.ffmpeg), + tool_info(&state.config.ffprobe) + ); + Json(Diagnostics { + application: env!("CARGO_PKG_NAME"), + version: env!("CARGO_PKG_VERSION"), + build: if cfg!(debug_assertions) { + "debug" + } else { + "release" + }, + platform: format!("{} / {}", std::env::consts::OS, std::env::consts::ARCH), + ffmpeg, + ffprobe, + media_root: state.config.media_root.display().to_string(), + bind: state.config.bind.to_string(), + }) +} + +async fn tool_info(path: &std::path::Path) -> ToolInfo { + let version = match Command::new(path).arg("-version").output().await { + Ok(output) if output.status.success() => first_line(&output.stdout), + Ok(output) => format!("Unavailable ({})", output.status), + Err(error) => format!("Unavailable ({error})"), + }; + ToolInfo { + path: path.display().to_string(), + version, + } +} + +fn first_line(output: &[u8]) -> String { + String::from_utf8_lossy(output) + .lines() + .next() + .unwrap_or("Unknown") + .trim() + .to_owned() +} + +#[cfg(test)] +mod tests { + use super::first_line; + + #[test] + fn keeps_only_the_tool_version_line() { + assert_eq!( + first_line(b"ffmpeg version 7.1\nconfiguration: test\n"), + "ffmpeg version 7.1" + ); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..c9a9858 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,42 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde_json::json; + +#[derive(Debug)] +pub struct AppError { + status: StatusCode, + message: String, +} + +impl AppError { + pub fn bad_request(message: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: message.into(), + } + } + + pub fn not_found(message: impl Into) -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: message.into(), + } + } + + pub fn internal(error: impl std::fmt::Display) -> Self { + tracing::error!(%error, "request failed"); + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: "internal server error".into(), + } + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + (self.status, Json(json!({ "error": self.message }))).into_response() + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0400aa1 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,29 @@ +mod app; +mod config; +mod diagnostics; +mod error; +mod media; +mod web; + +use std::sync::Arc; + +use clap::Parser; +use config::Config; +use tracing::info; + +use crate::app::AppState; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let config = Config::parse().prepare()?; + let listener = tokio::net::TcpListener::bind(config.bind).await?; + info!(address = %config.bind, media_root = %config.media_root.display(), "lan-play started"); + let state = Arc::new(AppState::new(config)?); + + axum::serve(listener, app::router(state)).await?; + Ok(()) +} diff --git a/src/media/browse.rs b/src/media/browse.rs new file mode 100644 index 0000000..bb98ddc --- /dev/null +++ b/src/media/browse.rs @@ -0,0 +1,78 @@ +use std::{path::Path, sync::Arc}; + +use axum::{ + Json, + extract::{Query, State}, +}; +use serde::Serialize; +use tokio::fs; + +use crate::{ + app::AppState, + error::AppError, + media::{PathQuery, path}, +}; + +const MEDIA_EXTENSIONS: &[&str] = &["mkv", "mp4", "m4v", "avi", "mov", "webm"]; + +#[derive(Serialize)] +pub struct Entry { + name: String, + path: String, + kind: &'static str, +} + +#[derive(Serialize)] +pub struct Directory { + path: String, + parent: Option, + entries: Vec, +} + +pub async fn browse( + State(state): State>, + Query(query): Query, +) -> Result, AppError> { + let config = &state.config; + let directory = path::resolve(config, &query.path)?; + if !directory.is_dir() { + return Err(AppError::bad_request("path is not a directory")); + } + + let mut reader = fs::read_dir(&directory).await.map_err(AppError::internal)?; + let mut entries = Vec::new(); + while let Some(item) = reader.next_entry().await.map_err(AppError::internal)? { + let file_type = item.file_type().await.map_err(AppError::internal)?; + let item_path = item.path(); + let kind = if file_type.is_dir() { + "directory" + } else if file_type.is_file() && is_media(&item_path) { + "media" + } else { + continue; + }; + entries.push(Entry { + name: item.file_name().to_string_lossy().into_owned(), + path: path::relative(config, &item_path)?, + kind, + }); + } + entries.sort_by_key(|entry| (entry.kind != "directory", entry.name.to_lowercase())); + + let current = path::relative(config, &directory)?; + let parent = directory + .parent() + .filter(|parent| parent.starts_with(&config.media_root)) + .and_then(|parent| path::relative(config, parent).ok()); + Ok(Json(Directory { + path: current, + parent, + entries, + })) +} + +fn is_media(path: &Path) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| MEDIA_EXTENSIONS.contains(&value.to_ascii_lowercase().as_str())) +} diff --git a/src/media/dash/mod.rs b/src/media/dash/mod.rs new file mode 100644 index 0000000..fc8d635 --- /dev/null +++ b/src/media/dash/mod.rs @@ -0,0 +1,195 @@ +mod process; +mod session; + +use std::{collections::HashMap, path::PathBuf, sync::Arc}; + +use axum::{ + Json, + body::Body, + extract::{Path, Query, State}, + http::{StatusCode, header}, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; +use tempfile::TempDir; +use tokio::{fs, sync::Mutex}; + +use crate::{ + app::AppState, + error::AppError, + media::{path, playback}, +}; + +use self::session::DashSession; + +pub struct DashManager { + _root: TempDir, + root_path: PathBuf, + sessions: Mutex>>, +} + +#[derive(Serialize)] +pub struct StartResponse { + session_id: String, + manifest_url: String, + duration_seconds: Option, + start_seconds: f64, +} + +#[derive(Deserialize)] +pub struct StartQuery { + path: String, + #[serde(default)] + start: f64, +} + +impl DashManager { + pub fn new() -> anyhow::Result { + let root = tempfile::Builder::new() + .prefix("lan-play-dash-") + .tempdir()?; + Ok(Self { + root_path: root.path().to_owned(), + _root: root, + sessions: Mutex::new(HashMap::new()), + }) + } +} + +pub async fn start( + State(state): State>, + Query(query): Query, +) -> Result, AppError> { + if !query.start.is_finite() || query.start < 0.0 { + return Err(AppError::bad_request("invalid playback start position")); + } + let media = path::resolve(&state.config, &query.path)?; + if !media.is_file() { + return Err(AppError::bad_request("path is not a media file")); + } + + let plan = playback::plan(&state.config, &media).await?; + if plan + .duration_seconds + .is_some_and(|duration| query.start >= duration) + { + return Err(AppError::bad_request( + "playback start is beyond the file duration", + )); + } + let duration_seconds = plan.duration_seconds; + let session = DashSession::start( + &state.config.ffmpeg, + &state.dash.root_path, + &media, + &plan, + query.start, + ) + .await?; + let session_id = session.id().to_owned(); + state + .dash + .sessions + .lock() + .await + .insert(session_id.clone(), Arc::new(session)); + + Ok(Json(StartResponse { + manifest_url: format!("/api/dash/{session_id}/manifest.mpd"), + session_id, + duration_seconds, + start_seconds: query.start, + })) +} + +pub async fn asset( + State(state): State>, + Path((session_id, asset)): Path<(String, String)>, +) -> Result { + if !session::valid_asset_name(&asset) { + return Err(AppError::bad_request("invalid DASH asset name")); + } + let session = state + .dash + .sessions + .lock() + .await + .get(&session_id) + .cloned() + .ok_or_else(|| AppError::not_found("DASH session not found"))?; + let file = session.asset_path(&asset); + let bytes = fs::read(file) + .await + .map_err(|_| AppError::not_found("DASH asset not ready"))?; + let content_type = asset_content_type(&asset); + Ok(( + StatusCode::OK, + [ + (header::CONTENT_TYPE, content_type), + (header::CACHE_CONTROL, "no-store"), + ], + Body::from(bytes), + ) + .into_response()) +} + +fn asset_content_type(asset: &str) -> &'static str { + if asset.ends_with(".mpd") { + "application/dash+xml" + } else if dash_stream_index(asset).is_some_and(|stream| stream > 0) { + "audio/iso.segment" + } else if asset.ends_with(".m4s") || asset.ends_with(".mp4") { + "video/iso.segment" + } else { + "application/octet-stream" + } +} + +fn dash_stream_index(asset: &str) -> Option { + let start = asset.find("stream")? + "stream".len(); + let digits: String = asset[start..] + .chars() + .take_while(|character| character.is_ascii_digit()) + .collect(); + digits.parse().ok() +} + +pub async fn stop( + State(state): State>, + Path(session_id): Path, +) -> Result { + let session = state + .dash + .sessions + .lock() + .await + .remove(&session_id) + .ok_or_else(|| AppError::not_found("DASH session not found"))?; + session.stop().await; + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use super::asset_content_type; + + #[test] + fn uses_media_specific_content_types() { + assert_eq!(asset_content_type("manifest.mpd"), "application/dash+xml"); + assert_eq!(asset_content_type("init-stream0.m4s"), "video/iso.segment"); + assert_eq!( + asset_content_type("chunk-stream0-00001.m4s"), + "video/iso.segment" + ); + assert_eq!(asset_content_type("init-stream1.m4s"), "audio/iso.segment"); + assert_eq!( + asset_content_type("chunk-stream1-00001.m4s"), + "audio/iso.segment" + ); + assert_eq!(asset_content_type("init-stream2.m4s"), "audio/iso.segment"); + assert_eq!( + asset_content_type("chunk-stream7-00001.m4s"), + "audio/iso.segment" + ); + } +} diff --git a/src/media/dash/process.rs b/src/media/dash/process.rs new file mode 100644 index 0000000..2ae393c --- /dev/null +++ b/src/media/dash/process.rs @@ -0,0 +1,65 @@ +use std::{fs::OpenOptions, path::Path, process::Stdio}; + +use tokio::process::{Child, Command}; + +use crate::error::AppError; +use crate::media::playback::PlaybackPlan; + +const SEEK_PREROLL_SECONDS: f64 = 3.0; + +pub fn spawn( + ffmpeg: &Path, + media: &Path, + directory: &Path, + plan: &PlaybackPlan, + start_seconds: f64, +) -> Result { + let log = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(directory.join("ffmpeg.log")) + .map_err(AppError::internal)?; + let mut command = Command::new(ffmpeg); + command.current_dir(directory).args(["-v", "warning"]); + let (input_seek, output_trim) = seek_offsets(start_seconds); + if input_seek > 0.0 { + command.args(["-ss", &format!("{input_seek:.3}")]); + } + command + // Build a small lead over playback so transient encoder, disk or network + // jitter does not leave the player waiting at the live edge. + .args(["-readrate", "1.15"]); + if output_trim > 0.0 { + command.args(["-readrate_initial_burst", &format!("{output_trim:.3}")]); + } + command.args(["-i"]).arg(media); + if output_trim > 0.0 { + command.args(["-ss", &format!("{output_trim:.3}")]); + } + command + .args(plan.ffmpeg_output_args()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::from(log)) + .kill_on_drop(true) + .spawn() + .map_err(AppError::internal) +} + +fn seek_offsets(start_seconds: f64) -> (f64, f64) { + let input_seek = (start_seconds - SEEK_PREROLL_SECONDS).max(0.0); + (input_seek, start_seconds - input_seek) +} + +#[cfg(test)] +mod tests { + use super::seek_offsets; + + #[test] + fn seek_keeps_a_short_accurate_preroll() { + assert_eq!(seek_offsets(0.0), (0.0, 0.0)); + assert_eq!(seek_offsets(2.0), (0.0, 2.0)); + assert_eq!(seek_offsets(100.0), (97.0, 3.0)); + } +} diff --git a/src/media/dash/session.rs b/src/media/dash/session.rs new file mode 100644 index 0000000..082f375 --- /dev/null +++ b/src/media/dash/session.rs @@ -0,0 +1,161 @@ +use std::{ + path::{Component, Path, PathBuf}, + time::Duration, +}; + +use tokio::{fs, process::Child, sync::Mutex, time}; +use uuid::Uuid; + +use crate::{error::AppError, media::playback::PlaybackPlan}; + +use super::process; + +pub struct DashSession { + id: String, + directory: PathBuf, + process: Mutex>, +} + +impl DashSession { + pub async fn start( + ffmpeg: &Path, + root: &Path, + media: &Path, + plan: &PlaybackPlan, + start_seconds: f64, + ) -> Result { + let id = Uuid::new_v4().simple().to_string(); + let directory = root.join(&id); + fs::create_dir(&directory) + .await + .map_err(AppError::internal)?; + let mut child = match process::spawn(ffmpeg, media, &directory, plan, start_seconds) { + Ok(child) => child, + Err(error) => { + fs::remove_dir_all(&directory).await.ok(); + return Err(error); + } + }; + wait_until_ready(&mut child, &directory, plan.audio_count()).await?; + Ok(Self { + id, + directory, + process: Mutex::new(Some(child)), + }) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn asset_path(&self, name: &str) -> PathBuf { + self.directory.join(name) + } + + pub async fn stop(&self) { + if let Some(mut child) = self.process.lock().await.take() { + if child.try_wait().ok().flatten().is_none() { + child.start_kill().ok(); + } + child.wait().await.ok(); + } + fs::remove_dir_all(&self.directory).await.ok(); + } +} + +async fn wait_until_ready( + child: &mut Child, + directory: &Path, + audio_count: usize, +) -> Result<(), AppError> { + for _ in 0..1800 { + if ready(directory, audio_count).await { + return Ok(()); + } + if child.try_wait().map_err(AppError::internal)?.is_some() { + return Err(startup_error(directory, "ffmpeg stopped before DASH became ready").await); + } + time::sleep(Duration::from_millis(50)).await; + } + child.start_kill().ok(); + child.wait().await.ok(); + Err(startup_error(directory, "timed out waiting for live DASH segments").await) +} + +async fn ready(directory: &Path, audio_count: usize) -> bool { + let manifest = match fs::read_to_string(directory.join("manifest.mpd")).await { + Ok(value) => value, + Err(_) => return false, + }; + if !manifest.contains("type=\"dynamic\"") && !manifest.contains("type=\"static\"") { + return false; + } + if !stream_ready(directory, 0).await { + return false; + } + for stream in 1..=audio_count { + if !stream_ready(directory, stream).await { + return false; + } + } + true +} + +async fn stream_ready(directory: &Path, stream: usize) -> bool { + directory.join(format!("init-stream{stream}.m4s")).is_file() + && has_segment(directory, stream).await +} + +async fn has_segment(directory: &Path, stream: usize) -> bool { + let prefix = format!("chunk-stream{stream}-"); + let mut entries = match fs::read_dir(directory).await { + Ok(value) => value, + Err(_) => return false, + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with(&prefix) && name.ends_with(".m4s") { + return true; + } + } + false +} + +async fn startup_error(directory: &Path, fallback: &str) -> AppError { + let log = fs::read_to_string(directory.join("ffmpeg.log")) + .await + .unwrap_or_default(); + fs::remove_dir_all(directory).await.ok(); + let details = log.trim(); + AppError::bad_request(if details.is_empty() { + fallback.to_owned() + } else { + format!("{fallback}: {details}") + }) +} + +pub fn valid_asset_name(name: &str) -> bool { + let mut components = Path::new(name).components(); + !name.is_empty() + && matches!(components.next(), Some(Component::Normal(_))) + && components.next().is_none() + && matches!( + Path::new(name).extension().and_then(|value| value.to_str()), + Some("mpd" | "m4s" | "mp4") + ) +} + +#[cfg(test)] +mod tests { + use super::valid_asset_name; + + #[test] + fn accepts_flat_dash_assets_only() { + assert!(valid_asset_name("manifest.mpd")); + assert!(valid_asset_name("chunk-stream0-00001.m4s")); + assert!(!valid_asset_name("../secret.mp4")); + assert!(!valid_asset_name("nested/chunk.m4s")); + assert!(!valid_asset_name("notes.txt")); + } +} diff --git a/src/media/mod.rs b/src/media/mod.rs new file mode 100644 index 0000000..d1e5962 --- /dev/null +++ b/src/media/mod.rs @@ -0,0 +1,16 @@ +pub mod browse; +pub mod dash; +pub mod path; +pub mod playback; +pub mod preview; +pub mod probe; +pub mod stream; +pub mod subtitles; + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct PathQuery { + #[serde(default)] + pub path: String, +} diff --git a/src/media/path.rs b/src/media/path.rs new file mode 100644 index 0000000..a4a9758 --- /dev/null +++ b/src/media/path.rs @@ -0,0 +1,30 @@ +use std::path::{Component, Path, PathBuf}; + +use crate::{config::Config, error::AppError}; + +pub fn resolve(config: &Config, relative: &str) -> Result { + let relative = Path::new(relative); + if relative.is_absolute() + || relative + .components() + .any(|part| !matches!(part, Component::Normal(_))) + { + return Err(AppError::bad_request("invalid media path")); + } + + let candidate = config.media_root.join(relative); + let canonical = candidate + .canonicalize() + .map_err(|_| AppError::not_found("media path not found"))?; + if !canonical.starts_with(&config.media_root) { + return Err(AppError::bad_request("media path escapes the library")); + } + Ok(canonical) +} + +pub fn relative(config: &Config, absolute: &Path) -> Result { + absolute + .strip_prefix(&config.media_root) + .map(|path| path.to_string_lossy().replace('\\', "/")) + .map_err(AppError::internal) +} diff --git a/src/media/playback/encoder.rs b/src/media/playback/encoder.rs new file mode 100644 index 0000000..5ec0f59 --- /dev/null +++ b/src/media/playback/encoder.rs @@ -0,0 +1,61 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VideoPipeline { + Copy, + Encode(VideoEncoder), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VideoEncoder { + Libx264, +} + +impl VideoPipeline { + pub fn ffmpeg_args(self, segment_seconds: u8) -> Vec { + match self { + Self::Copy => vec!["-c:v".into(), "copy".into()], + Self::Encode(encoder) => encoder.ffmpeg_args(segment_seconds), + } + } +} + +impl VideoEncoder { + pub fn portable_software() -> Self { + Self::Libx264 + } + + fn ffmpeg_args(self, segment_seconds: u8) -> Vec { + match self { + Self::Libx264 => vec![ + "-c:v".into(), + "libx264".into(), + "-preset".into(), + "veryfast".into(), + "-crf".into(), + "20".into(), + "-vf".into(), + "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p".into(), + "-force_key_frames".into(), + format!("expr:gte(t,n_forced*{segment_seconds})"), + "-sc_threshold".into(), + "0".into(), + ], + } + } +} + +#[cfg(test)] +mod tests { + use super::{VideoEncoder, VideoPipeline}; + + #[test] + fn libx264_profile_is_browser_compatible_and_segment_aligned() { + let args = VideoPipeline::Encode(VideoEncoder::Libx264).ffmpeg_args(2); + assert!(args.windows(2).any(|pair| pair == ["-c:v", "libx264"])); + assert!(args.windows(2).any(|pair| pair == ["-crf", "20"])); + assert!( + args.windows(2) + .any(|pair| { pair == ["-force_key_frames", "expr:gte(t,n_forced*2)"] }) + ); + assert!(args.iter().any(|arg| arg.contains("format=yuv420p"))); + } +} diff --git a/src/media/playback/mod.rs b/src/media/playback/mod.rs new file mode 100644 index 0000000..6a92427 --- /dev/null +++ b/src/media/playback/mod.rs @@ -0,0 +1,7 @@ +mod encoder; +mod planner; +mod profile; + +pub use encoder::{VideoEncoder, VideoPipeline}; +pub use planner::plan; +pub use profile::PlaybackPlan; diff --git a/src/media/playback/planner.rs b/src/media/playback/planner.rs new file mode 100644 index 0000000..03a1310 --- /dev/null +++ b/src/media/playback/planner.rs @@ -0,0 +1,60 @@ +use std::path::Path; + +use crate::{config::Config, error::AppError, media::probe}; + +use super::{PlaybackPlan, VideoEncoder, VideoPipeline}; + +pub async fn plan(config: &Config, media: &Path) -> Result { + let metadata = probe::run(config, media).await?; + let video = metadata + .streams + .iter() + .find(|stream| stream.codec_type.as_deref() == Some("video")) + .ok_or_else(|| AppError::bad_request("media file has no video stream"))?; + let video_pipeline = + select_video_pipeline(video.codec_name.as_deref(), video.pix_fmt.as_deref()); + let audio_streams = metadata + .streams + .iter() + .filter(|stream| stream.codec_type.as_deref() == Some("audio")) + .map(|stream| stream.index) + .collect(); + let duration_seconds = metadata + .format + .and_then(|format| format.duration) + .and_then(|duration| duration.parse().ok()); + Ok(PlaybackPlan::live_dash( + video_pipeline, + audio_streams, + duration_seconds, + )) +} + +fn select_video_pipeline(codec: Option<&str>, pixel_format: Option<&str>) -> VideoPipeline { + if codec == Some("h264") && matches!(pixel_format, Some("yuv420p" | "yuvj420p")) { + VideoPipeline::Copy + } else { + VideoPipeline::Encode(VideoEncoder::portable_software()) + } +} + +#[cfg(test)] +mod tests { + use super::{VideoEncoder, VideoPipeline, select_video_pipeline}; + + #[test] + fn copies_only_browser_compatible_h264() { + assert_eq!( + select_video_pipeline(Some("h264"), Some("yuv420p")), + VideoPipeline::Copy + ); + assert_eq!( + select_video_pipeline(Some("mpeg4"), Some("yuv420p")), + VideoPipeline::Encode(VideoEncoder::Libx264) + ); + assert_eq!( + select_video_pipeline(Some("h264"), Some("yuv420p10le")), + VideoPipeline::Encode(VideoEncoder::Libx264) + ); + } +} diff --git a/src/media/playback/profile.rs b/src/media/playback/profile.rs new file mode 100644 index 0000000..c1edf13 --- /dev/null +++ b/src/media/playback/profile.rs @@ -0,0 +1,95 @@ +use super::VideoPipeline; + +#[derive(Debug, Clone)] +pub struct PlaybackPlan { + pub video_pipeline: VideoPipeline, + pub audio_streams: Vec, + pub duration_seconds: Option, + pub segment_seconds: u8, +} + +impl PlaybackPlan { + pub fn live_dash( + video_pipeline: VideoPipeline, + audio_streams: Vec, + duration_seconds: Option, + ) -> Self { + Self { + video_pipeline, + audio_streams, + duration_seconds, + segment_seconds: 2, + } + } + + pub fn audio_count(&self) -> usize { + self.audio_streams.len() + } + + pub fn ffmpeg_output_args(&self) -> Vec { + let mut args = vec!["-map".into(), "0:v:0".into()]; + args.extend(self.video_pipeline.ffmpeg_args(self.segment_seconds)); + for stream in &self.audio_streams { + args.extend(["-map".into(), format!("0:{stream}")]); + } + if !self.audio_streams.is_empty() { + args.extend([ + "-c:a".into(), + "aac".into(), + "-profile:a".into(), + "aac_low".into(), + "-b:a".into(), + "192k".into(), + "-ac".into(), + "2".into(), + ]); + } + let mut adaptation_sets = vec!["id=0,streams=0".to_owned()]; + adaptation_sets.extend( + (1..=self.audio_streams.len()).map(|output| format!("id={output},streams={output}")), + ); + args.extend([ + "-sn".into(), + "-f".into(), + "dash".into(), + "-seg_duration".into(), + self.segment_seconds.to_string(), + "-update_period".into(), + "1".into(), + "-use_template".into(), + "1".into(), + "-use_timeline".into(), + "1".into(), + "-window_size".into(), + "0".into(), + "-extra_window_size".into(), + "0".into(), + "-adaptation_sets".into(), + adaptation_sets.join(" "), + "manifest.mpd".into(), + ]); + args + } +} + +#[cfg(test)] +mod tests { + use super::{PlaybackPlan, VideoPipeline}; + + #[test] + fn live_profile_copies_video_and_encodes_audio_to_aac() { + let args = PlaybackPlan::live_dash(VideoPipeline::Copy, vec![1, 3], Some(120.0)) + .ffmpeg_output_args(); + assert!(args.windows(2).any(|pair| pair == ["-c:v", "copy"])); + assert!(args.windows(2).any(|pair| pair == ["-c:a", "aac"])); + assert!(args.windows(2).any(|pair| pair == ["-ac", "2"])); + assert!(args.windows(2).any(|pair| pair == ["-map", "0:1"])); + assert!(args.windows(2).any(|pair| pair == ["-map", "0:3"])); + assert!(args.windows(2).any(|pair| { + pair == [ + "-adaptation_sets", + "id=0,streams=0 id=1,streams=1 id=2,streams=2", + ] + })); + } +} diff --git a/src/media/preview.rs b/src/media/preview.rs new file mode 100644 index 0000000..703e68b --- /dev/null +++ b/src/media/preview.rs @@ -0,0 +1,157 @@ +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + process::Stdio, + sync::Arc, + time::Duration, +}; + +use axum::{ + body::Body, + extract::{Query, State}, + http::header, + response::{IntoResponse, Response}, +}; +use tokio::process::Command; + +use crate::{ + app::AppState, + error::AppError, + media::{PathQuery, path, probe}, +}; + +const FRAME_COUNT: usize = 10; +const FRAME_WIDTH: usize = 320; +const FRAME_HEIGHT: usize = 180; + +pub async fn sprite( + State(state): State>, + Query(query): Query, +) -> Result { + let media = path::resolve(&state.config, &query.path)?; + if !media.is_file() { + return Err(AppError::bad_request("path is not a media file")); + } + let _permit = state + .preview_jobs + .acquire() + .await + .map_err(AppError::internal)?; + let metadata = probe::run(&state.config, &media).await?; + let duration = metadata + .format + .and_then(|format| format.duration) + .and_then(|value| value.parse::().ok()) + .filter(|value| value.is_finite() && *value > 0.0) + .ok_or_else(|| AppError::bad_request("media duration is unavailable"))?; + + let timestamps = preview_timestamps(&query.path, duration); + let mut command = Command::new(&state.config.ffmpeg); + command.args(["-v", "error"]); + for timestamp in timestamps { + command + .args(["-ss", &format!("{timestamp:.3}"), "-i"]) + .arg(&media); + } + let filter = sprite_filter(); + command + .args([ + "-filter_complex", + &filter, + "-map", + "[sprite]", + "-frames:v", + "1", + "-c:v", + "mjpeg", + "-q:v", + "5", + "-f", + "image2pipe", + "pipe:1", + ]) + .stdin(Stdio::null()) + .kill_on_drop(true); + let output = tokio::time::timeout(Duration::from_secs(30), command.output()) + .await + .map_err(|_| AppError::bad_request("preview generation timed out"))? + .map_err(AppError::internal)?; + if !output.status.success() || output.stdout.is_empty() { + let details = String::from_utf8_lossy(&output.stderr); + return Err(AppError::bad_request(format!( + "failed to create preview: {}", + details.trim() + ))); + } + Ok(( + [ + (header::CONTENT_TYPE, "image/jpeg"), + (header::CACHE_CONTROL, "private, max-age=3600"), + ], + Body::from(output.stdout), + ) + .into_response()) +} + +fn preview_timestamps(path: &str, duration: f64) -> [f64; FRAME_COUNT] { + let mut hasher = DefaultHasher::new(); + path.hash(&mut hasher); + let mut seed = hasher.finish(); + std::array::from_fn(|index| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let jitter = (seed as f64 / u64::MAX as f64 - 0.5) * 0.06; + let position = ((index as f64 + 0.5) / FRAME_COUNT as f64 + jitter).clamp(0.02, 0.98); + position * duration + }) +} + +fn sprite_filter() -> String { + let mut filters = (0..FRAME_COUNT) + .map(|index| { + format!( + "[{index}:v:0]setpts=PTS-STARTPTS,scale={FRAME_WIDTH}:{FRAME_HEIGHT}:force_original_aspect_ratio=decrease,pad={FRAME_WIDTH}:{FRAME_HEIGHT}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[v{index}]" + ) + }) + .collect::>(); + let inputs = (0..FRAME_COUNT) + .map(|index| format!("[v{index}]")) + .collect::(); + let layout = (0..FRAME_COUNT) + .map(|index| { + format!( + "{}_{}", + (index % 5) * FRAME_WIDTH, + (index / 5) * FRAME_HEIGHT + ) + }) + .collect::>() + .join("|"); + filters.push(format!( + "{inputs}xstack=inputs={FRAME_COUNT}:layout={layout}[sprite]" + )); + filters.join(";") +} + +#[cfg(test)] +mod tests { + use super::{FRAME_COUNT, preview_timestamps, sprite_filter}; + + #[test] + fn timestamps_are_stable_and_inside_the_video() { + let first = preview_timestamps("series/episode.mkv", 100.0); + let second = preview_timestamps("series/episode.mkv", 100.0); + assert_eq!(first, second); + assert!(first.iter().all(|value| *value >= 2.0 && *value <= 98.0)); + } + + #[test] + fn sprite_contains_every_input() { + let filter = sprite_filter(); + assert!(filter.contains(&format!("xstack=inputs={FRAME_COUNT}"))); + for index in 0..FRAME_COUNT { + assert!(filter.contains(&format!("[{index}:v:0]"))); + } + } +} diff --git a/src/media/probe.rs b/src/media/probe.rs new file mode 100644 index 0000000..aacd653 --- /dev/null +++ b/src/media/probe.rs @@ -0,0 +1,67 @@ +use std::{path::Path, process::Stdio, sync::Arc}; + +use axum::{ + Json, + extract::{Query, State}, +}; +use serde::{Deserialize, Serialize}; +use tokio::process::Command; + +use crate::{ + app::AppState, + config::Config, + error::AppError, + media::{PathQuery, path}, +}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Probe { + #[serde(default)] + pub streams: Vec, + pub format: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Stream { + pub index: u32, + pub codec_name: Option, + pub codec_type: Option, + pub pix_fmt: Option, + #[serde(default)] + pub tags: serde_json::Map, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Format { + pub format_name: Option, + pub duration: Option, +} + +pub async fn probe( + State(state): State>, + Query(query): Query, +) -> Result, AppError> { + let media = path::resolve(&state.config, &query.path)?; + Ok(Json(run(&state.config, &media).await?)) +} + +pub async fn run(config: &Config, media: &Path) -> Result { + let output = Command::new(&config.ffprobe) + .args([ + "-v", + "error", + "-show_format", + "-show_streams", + "-of", + "json", + ]) + .arg(media) + .stdin(Stdio::null()) + .output() + .await + .map_err(AppError::internal)?; + if !output.status.success() { + return Err(AppError::internal(String::from_utf8_lossy(&output.stderr))); + } + serde_json::from_slice(&output.stdout).map_err(AppError::internal) +} diff --git a/src/media/stream.rs b/src/media/stream.rs new file mode 100644 index 0000000..649bb2a --- /dev/null +++ b/src/media/stream.rs @@ -0,0 +1,76 @@ +use std::sync::Arc; + +use axum::{ + body::Body, + extract::{Query, State}, + http::{HeaderMap, StatusCode, header}, + response::Response, +}; +use tokio::{ + fs::File, + io::{AsyncReadExt, AsyncSeekExt, SeekFrom}, +}; + +use crate::{ + app::AppState, + error::AppError, + media::{PathQuery, path}, +}; + +pub async fn stream( + State(state): State>, + Query(query): Query, + headers: HeaderMap, +) -> Result, AppError> { + let media = path::resolve(&state.config, &query.path)?; + if !media.is_file() { + return Err(AppError::bad_request("path is not a file")); + } + let mut file = File::open(&media).await.map_err(AppError::internal)?; + let size = file.metadata().await.map_err(AppError::internal)?.len(); + let (start, end, status) = match headers.get(header::RANGE).and_then(|v| v.to_str().ok()) { + Some(value) => { + let (start, end) = parse_range(value, size)?; + (start, end, StatusCode::PARTIAL_CONTENT) + } + None => (0, size.saturating_sub(1), StatusCode::OK), + }; + let length = end - start + 1; + file.seek(SeekFrom::Start(start)) + .await + .map_err(AppError::internal)?; + let body = Body::from_stream(tokio_util::io::ReaderStream::new(file.take(length))); + let mime = mime_guess::from_path(&media).first_or_octet_stream(); + + let mut response = Response::builder() + .status(status) + .header(header::ACCEPT_RANGES, "bytes") + .header(header::CONTENT_LENGTH, length) + .header(header::CONTENT_TYPE, mime.as_ref()); + if status == StatusCode::PARTIAL_CONTENT { + response = response.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{size}")); + } + response.body(body).map_err(AppError::internal) +} + +fn parse_range(value: &str, size: u64) -> Result<(u64, u64), AppError> { + let value = value + .strip_prefix("bytes=") + .ok_or_else(|| AppError::bad_request("invalid range"))?; + let (start, end) = value + .split_once('-') + .ok_or_else(|| AppError::bad_request("invalid range"))?; + let start = start + .parse::() + .map_err(|_| AppError::bad_request("invalid range"))?; + let end = if end.is_empty() { + size.saturating_sub(1) + } else { + end.parse() + .map_err(|_| AppError::bad_request("invalid range"))? + }; + if size == 0 || start > end || end >= size { + return Err(AppError::bad_request("range outside file")); + } + Ok((start, end)) +} diff --git a/src/media/subtitles.rs b/src/media/subtitles.rs new file mode 100644 index 0000000..7662acf --- /dev/null +++ b/src/media/subtitles.rs @@ -0,0 +1,160 @@ +use std::{process::Stdio, sync::Arc}; + +use axum::{ + Json, + body::Body, + extract::{Path, Query, State}, + http::header, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; +use tokio::process::Command; +use tokio_util::io::ReaderStream; + +use crate::{ + app::AppState, + error::AppError, + media::{PathQuery, path, probe}, +}; + +mod discovery; +mod matching; + +#[derive(Serialize)] +pub struct Subtitle { + index: Option, + id: Option, + source: &'static str, + codec: String, + language: Option, + title: Option, + supported: bool, + confidence: Option<&'static str>, + score: Option, +} + +#[derive(Deserialize)] +pub struct ExtractQuery { + path: String, + #[serde(default)] + start: f64, +} + +pub async fn list( + State(state): State>, + Query(query): Query, +) -> Result>, AppError> { + let media = path::resolve(&state.config, &query.path)?; + let info = probe::run(&state.config, &media).await?; + let mut subtitles = info + .streams + .into_iter() + .filter(|stream| stream.codec_type.as_deref() == Some("subtitle")) + .map(|stream| { + let codec = stream.codec_name.unwrap_or_else(|| "unknown".into()); + Subtitle { + index: Some(stream.index), + id: None, + source: "embedded", + supported: matches!( + codec.as_str(), + "subrip" | "srt" | "ass" | "ssa" | "webvtt" | "mov_text" + ), + language: tag(&stream.tags, "language"), + title: tag(&stream.tags, "title"), + codec, + confidence: None, + score: None, + } + }) + .collect::>(); + subtitles.extend( + discovery::find(&state.config, &media) + .await? + .into_iter() + .map(|candidate| Subtitle { + index: None, + id: Some(candidate.id), + source: "external", + codec: candidate.codec, + language: None, + title: Some(candidate.label), + supported: true, + confidence: Some(candidate.confidence.as_str()), + score: Some(candidate.score), + }), + ); + Ok(Json(subtitles)) +} + +pub async fn extract( + State(state): State>, + Path(index): Path, + Query(query): Query, +) -> Result { + if !query.start.is_finite() || query.start < 0.0 { + return Err(AppError::bad_request("invalid subtitle start position")); + } + let media = path::resolve(&state.config, &query.path)?; + stream_webvtt( + &state.config.ffmpeg, + &media, + &format!("0:{index}"), + query.start, + ) + .await +} + +pub async fn extract_external( + State(state): State>, + Path(id): Path, + Query(query): Query, +) -> Result { + if !query.start.is_finite() || query.start < 0.0 { + return Err(AppError::bad_request("invalid subtitle start position")); + } + let media = path::resolve(&state.config, &query.path)?; + let candidate = discovery::resolve_id(&state.config, &media, &id).await?; + stream_webvtt(&state.config.ffmpeg, &candidate.path, "0:0", query.start).await +} + +async fn stream_webvtt( + ffmpeg: &std::path::Path, + input: &std::path::Path, + map: &str, + start: f64, +) -> Result { + let mut command = Command::new(ffmpeg); + command.args(["-v", "error"]); + if start > 0.0 { + command.args(["-ss", &format!("{start:.3}")]); + } + let mut child = command + .arg("-i") + .arg(input) + .args(["-map", map, "-f", "webvtt", "pipe:1"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(AppError::internal)?; + let stdout = child + .stdout + .take() + .ok_or_else(|| AppError::internal("ffmpeg stdout unavailable"))?; + tokio::spawn(async move { + let _ = child.wait().await; + }); + Ok(( + [(header::CONTENT_TYPE, "text/vtt; charset=utf-8")], + Body::from_stream(ReaderStream::new(stdout)), + ) + .into_response()) +} + +fn tag(tags: &serde_json::Map, name: &str) -> Option { + tags.get(name) + .or_else(|| tags.get(&name.to_uppercase())) + .and_then(|value| value.as_str()) + .map(str::to_owned) +} diff --git a/src/media/subtitles/discovery.rs b/src/media/subtitles/discovery.rs new file mode 100644 index 0000000..7567f94 --- /dev/null +++ b/src/media/subtitles/discovery.rs @@ -0,0 +1,127 @@ +use std::{ + collections::{HashSet, VecDeque, hash_map::DefaultHasher}, + hash::{Hash, Hasher}, + path::{Path, PathBuf}, +}; + +use tokio::fs; + +use crate::{config::Config, error::AppError, media::path}; + +use super::matching::{self, Confidence}; + +const SUBTITLE_EXTENSIONS: &[&str] = &["ass", "ssa", "srt", "vtt"]; +const MAX_DEPTH: usize = 3; +const MAX_ENTRIES: usize = 2_000; + +#[derive(Debug, Clone)] +pub struct Candidate { + pub id: String, + pub path: PathBuf, + pub relative_path: String, + pub codec: String, + pub label: String, + pub score: u8, + pub confidence: Confidence, +} + +pub async fn find(config: &Config, video: &Path) -> Result, AppError> { + let mut roots = Vec::new(); + if let Some(parent) = video.parent() { + roots.push((parent.to_owned(), MAX_DEPTH)); + if let Some(grandparent) = parent.parent().filter(|path| *path != config.media_root) + && grandparent.starts_with(&config.media_root) + { + roots.push((grandparent.to_owned(), 2)); + } + } + + let mut queue = VecDeque::from(roots); + let mut visited = HashSet::new(); + let mut scanned = 0; + let mut candidates = Vec::new(); + while let Some((directory, depth)) = queue.pop_front() { + if !visited.insert(directory.clone()) { + continue; + } + let mut reader = match fs::read_dir(&directory).await { + Ok(reader) => reader, + Err(_) => continue, + }; + while scanned < MAX_ENTRIES { + let Some(entry) = reader.next_entry().await.map_err(AppError::internal)? else { + break; + }; + scanned += 1; + let file_type = entry.file_type().await.map_err(AppError::internal)?; + if file_type.is_symlink() { + continue; + } + let candidate_path = entry.path(); + if file_type.is_dir() && depth > 0 { + queue.push_back((candidate_path, depth - 1)); + } else if file_type.is_file() + && is_subtitle(&candidate_path) + && let Some(candidate) = make_candidate(config, video, candidate_path)? + { + candidates.push(candidate); + } + } + if scanned >= MAX_ENTRIES { + break; + } + } + candidates.sort_by(|left, right| { + right + .score + .cmp(&left.score) + .then_with(|| left.relative_path.cmp(&right.relative_path)) + }); + candidates.dedup_by(|left, right| left.path == right.path); + Ok(candidates) +} + +pub async fn resolve_id(config: &Config, video: &Path, id: &str) -> Result { + find(config, video) + .await? + .into_iter() + .find(|candidate| candidate.id == id) + .ok_or_else(|| AppError::not_found("external subtitle not found")) +} + +fn make_candidate( + config: &Config, + video: &Path, + subtitle: PathBuf, +) -> Result, AppError> { + let Some(matched) = matching::score(video, &subtitle) else { + return Ok(None); + }; + let relative_path = path::relative(config, &subtitle)?; + let codec = subtitle + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("unknown") + .to_ascii_lowercase(); + Ok(Some(Candidate { + id: candidate_id(&relative_path), + path: subtitle, + relative_path, + codec, + label: matched.label, + score: matched.score, + confidence: matched.confidence, + })) +} + +fn candidate_id(relative_path: &str) -> String { + let mut hasher = DefaultHasher::new(); + relative_path.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +fn is_subtitle(path: &Path) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| SUBTITLE_EXTENSIONS.contains(&value.to_ascii_lowercase().as_str())) +} diff --git a/src/media/subtitles/matching.rs b/src/media/subtitles/matching.rs new file mode 100644 index 0000000..837a86d --- /dev/null +++ b/src/media/subtitles/matching.rs @@ -0,0 +1,237 @@ +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Confidence { + Certain, + Likely, + Ambiguous, +} + +impl Confidence { + pub fn as_str(self) -> &'static str { + match self { + Self::Certain => "certain", + Self::Likely => "likely", + Self::Ambiguous => "ambiguous", + } + } +} + +#[derive(Debug, Clone)] +pub struct Match { + pub score: u8, + pub confidence: Confidence, + pub label: String, +} + +pub fn score(video: &Path, subtitle: &Path) -> Option { + let video_stem = stem(video)?; + let subtitle_stem = stem(subtitle)?; + let video_name = Normalized::new(&video_stem); + let subtitle_name = Normalized::new(&subtitle_stem); + if video_name.tokens.is_empty() || subtitle_name.tokens.is_empty() { + return None; + } + + let exact = video_name.tokens == subtitle_name.tokens; + let contains_video = contains_sequence(&subtitle_name.tokens, &video_name.tokens); + let same_directory = video.parent() == subtitle.parent(); + let episode_video = video_name.episode(); + let episode_subtitle = subtitle_name.episode(); + + if !contains_video + && episode_video.is_some() + && episode_subtitle.is_some() + && episode_video != episode_subtitle + { + return None; + } + + let similarity = jaccard(&video_name.tokens, &subtitle_name.tokens); + let (score, confidence) = if exact { + (100, Confidence::Certain) + } else if contains_video { + (95, Confidence::Certain) + } else { + let mut value = (similarity * 55.0).round() as u8; + if episode_video.is_some() && episode_video == episode_subtitle { + value = value.saturating_add(30); + } + if same_directory { + value = value.saturating_add(10); + } + let confidence = if value >= 75 { + Confidence::Likely + } else { + Confidence::Ambiguous + }; + (value.min(94), confidence) + }; + if score < 40 { + return None; + } + + Some(Match { + score, + confidence, + label: candidate_label(video, subtitle, contains_video), + }) +} + +fn stem(path: &Path) -> Option { + path.file_stem() + .map(|value| value.to_string_lossy().into_owned()) +} + +struct Normalized { + tokens: Vec, +} + +impl Normalized { + fn new(value: &str) -> Self { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut digit = None; + for character in value.chars().flat_map(char::to_lowercase) { + if !character.is_alphanumeric() { + push_token(&mut tokens, &mut current); + digit = None; + continue; + } + let is_digit = character.is_numeric(); + if digit.is_some_and(|previous| previous != is_digit) { + push_token(&mut tokens, &mut current); + } + digit = Some(is_digit); + current.push(character); + } + push_token(&mut tokens, &mut current); + Self { tokens } + } + + fn episode(&self) -> Option { + for window in self.tokens.windows(4) { + if window[0] == "s" + && window[2] == "e" + && let Ok(value) = window[3].parse() + { + return Some(value); + } + } + let technical_boundary = self.tokens.iter().position(|token| { + token + .parse::() + .is_ok_and(|value| is_technical_number(value, None)) + }); + self.tokens + .iter() + .enumerate() + .take(technical_boundary.unwrap_or(self.tokens.len())) + .filter_map(|(index, token)| { + let value: u32 = token.parse().ok()?; + if is_technical_number(value, self.tokens.get(index.wrapping_sub(1))) { + None + } else { + Some(value) + } + }) + .next_back() + } +} + +fn push_token(tokens: &mut Vec, current: &mut String) { + if !current.is_empty() { + tokens.push(std::mem::take(current)); + } +} + +fn is_technical_number(value: u32, previous: Option<&String>) -> bool { + matches!( + value, + 240 | 264 | 265 | 360 | 480 | 576 | 720 | 1080 | 1440 | 2160 | 4320 + ) || previous.is_some_and(|token| matches!(token.as_str(), "x" | "h")) +} + +fn contains_sequence(haystack: &[String], needle: &[String]) -> bool { + needle.len() <= haystack.len() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +fn jaccard(left: &[String], right: &[String]) -> f32 { + let common = left.iter().filter(|token| right.contains(token)).count(); + let union = left.len() + right.len() - common; + if union == 0 { + 0.0 + } else { + common as f32 / union as f32 + } +} + +fn candidate_label(video: &Path, subtitle: &Path, contains_video: bool) -> String { + let video_stem = stem(video).unwrap_or_default(); + let subtitle_stem = stem(subtitle).unwrap_or_default(); + let suffix = if contains_video { + subtitle_stem + .strip_prefix(&video_stem) + .unwrap_or("") + .trim_matches(|character: char| !character.is_alphanumeric()) + .to_owned() + } else { + String::new() + }; + let folder = (video.parent() != subtitle.parent()) + .then(|| subtitle.parent()?.file_name()?.to_str().map(str::to_owned)) + .flatten(); + match (folder, suffix.is_empty()) { + (Some(folder), false) => format!("{folder} · {suffix}"), + (Some(folder), true) => folder, + (None, false) => suffix, + (None, true) => "External".to_owned(), + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::{Confidence, score}; + + #[test] + fn exact_stems_are_certain() { + let result = score( + Path::new("05. Famous Shooter.avi"), + Path::new("05. Famous Shooter.ass"), + ) + .unwrap(); + assert_eq!(result.confidence, Confidence::Certain); + assert_eq!(result.score, 100); + } + + #[test] + fn release_suffixes_are_certain_and_become_labels() { + let result = score( + Path::new("Show - 01 [1080p WEB-DL].mkv"), + Path::new("Show - 01 [1080p WEB-DL]-RELEASE2.ass"), + ) + .unwrap(); + assert_eq!(result.confidence, Confidence::Certain); + assert_eq!(result.label, "RELEASE2"); + } + + #[test] + fn rejects_a_neighboring_episode() { + assert!(score(Path::new("Show - 01.mkv"), Path::new("Show - 02.ass")).is_none()); + } + + #[test] + fn folder_names_are_labels_not_languages() { + let result = score( + Path::new("Show/01.mkv"), + Path::new("Show/SUBS/Variant A/01.srt"), + ) + .unwrap(); + assert_eq!(result.label, "Variant A"); + } +} diff --git a/src/web.rs b/src/web.rs new file mode 100644 index 0000000..873c96d --- /dev/null +++ b/src/web.rs @@ -0,0 +1,82 @@ +use axum::{ + body::Body, + http::header, + response::{Html, IntoResponse}, +}; + +pub async fn index() -> impl IntoResponse { + ( + [(header::CACHE_CONTROL, "no-store")], + Html(include_str!("../web/index.html")), + ) +} +pub async fn alpine() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], + include_str!("../web/alpine.min.js"), + ) +} +pub async fn dash() -> impl IntoResponse { + javascript(include_str!("../web/dash.all.min.js")) +} +pub async fn dash_source_map() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "application/json; charset=utf-8")], + r#"{"version":3,"sources":[],"names":[],"mappings":""}"#, + ) +} +pub async fn controlbar_javascript() -> impl IntoResponse { + javascript(include_str!("../web/controlbar.min.js")) +} +pub async fn api_javascript() -> impl IntoResponse { + javascript(include_str!("../web/js/api.js")) +} +pub async fn library_javascript() -> impl IntoResponse { + javascript(include_str!("../web/js/library.js")) +} +pub async fn location_javascript() -> impl IntoResponse { + javascript(include_str!("../web/js/location.js")) +} +pub async fn preferences_javascript() -> impl IntoResponse { + javascript(include_str!("../web/js/preferences.js")) +} +pub async fn player_javascript() -> impl IntoResponse { + javascript(include_str!("../web/js/player.js")) +} +fn javascript(source: &'static str) -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "text/javascript; charset=utf-8"), + (header::CACHE_CONTROL, "no-store"), + ], + source, + ) +} +pub async fn styles() -> impl IntoResponse { + stylesheet(include_str!("../web/styles.css")) +} +pub async fn controlbar_styles() -> impl IntoResponse { + stylesheet(include_str!("../web/vendor/controlbar/controlbar.css")) +} +pub async fn bootstrap_icon_styles() -> impl IntoResponse { + stylesheet(include_str!( + "../web/vendor/bootstrap-icons/bootstrap-icons.min.css" + )) +} +pub async fn bootstrap_icon_font() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "font/woff2")], + Body::from( + include_bytes!("../web/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2").as_slice(), + ), + ) +} +fn stylesheet(source: &'static str) -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "text/css; charset=utf-8"), + (header::CACHE_CONTROL, "no-store"), + ], + source, + ) +} diff --git a/web/alpine.min.js b/web/alpine.min.js new file mode 100644 index 0000000..ab371ef --- /dev/null +++ b/web/alpine.min.js @@ -0,0 +1,5 @@ +(()=>{var ee=!1,re=!1,W=[],ne=-1,ie=!1;function Ve(t){Dn(t)}function Ue(){ie=!0}function qe(){ie=!1,We()}function Dn(t){W.includes(t)||W.push(t),We()}function Ke(t){let e=W.indexOf(t);e!==-1&&e>ne&&W.splice(e,1)}function We(){if(!re&&!ee){if(ie)return;ee=!0,queueMicrotask(In)}}function In(){ee=!1,re=!0;for(let t=0;tt.effect(e,{scheduler:r=>{oe?Ve(r):r()}}),se=t.raw}function ae(t){R=t}function Ye(t){let e=()=>{};return[n=>{let i=R(n);return t._x_effects||(t._x_effects=new Set,t._x_runEffects=()=>{t._x_effects.forEach(o=>o())}),t._x_effects.add(i),e=()=>{i!==void 0&&(t._x_effects.delete(i),j(i))},i},()=>{e()}]}function St(t,e){let r=!0,n,i,o=R(()=>{let s=t(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{e(s,c)})}n=s,i=a,r=!1});return()=>j(o)}async function Xe(t){Ue();try{await t(),await Promise.resolve()}finally{qe()}}var Ze=[],Qe=[],tr=[];function er(t){tr.push(t)}function et(t,e){typeof e=="function"?(t._x_cleanups||(t._x_cleanups=[]),t._x_cleanups.push(e)):(e=t,Qe.push(e))}function At(t){Ze.push(t)}function Ot(t,e,r){t._x_attributeCleanups||(t._x_attributeCleanups={}),t._x_attributeCleanups[e]||(t._x_attributeCleanups[e]=[]),t._x_attributeCleanups[e].push(r)}function ce(t,e){t._x_attributeCleanups&&Object.entries(t._x_attributeCleanups).forEach(([r,n])=>{(e===void 0||e.includes(r))&&(n.forEach(i=>i()),delete t._x_attributeCleanups[r])})}function rr(t){for(t._x_effects?.forEach(Ke);t._x_cleanups?.length;)t._x_cleanups.pop()()}var le=new MutationObserver(pe),ue=!1;function ut(){le.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ue=!0}function fe(){kn(),le.disconnect(),ue=!1}var lt=[];function kn(){let t=le.takeRecords();lt.push(()=>t.length>0&&pe(t));let e=lt.length;queueMicrotask(()=>{if(lt.length===e)for(;lt.length>0;)lt.shift()()})}function m(t){if(!ue)return t();fe();let e=t();return ut(),e}var de=!1,vt=[];function nr(){de=!0}function ir(){de=!1,pe(vt),vt=[]}function pe(t){if(de){vt=vt.concat(t);return}let e=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),t[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||e.push(s)}})),t[o].type==="attributes")){let s=t[o].target,a=t[o].attributeName,c=t[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ce(s,o)}),n.forEach((o,s)=>{Ze.forEach(a=>a(s,o))});for(let o of r)e.some(s=>s.contains(o))||Qe.forEach(s=>s(o));for(let o of e)o.isConnected&&tr.forEach(s=>s(o));e=null,r=null,n=null,i=null}function Ct(t){return P(F(t))}function N(t,e,r){return t._x_dataStack=[e,...F(r||t)],()=>{t._x_dataStack=t._x_dataStack.filter(n=>n!==e)}}function F(t){return t._x_dataStack?t._x_dataStack:typeof ShadowRoot=="function"&&t instanceof ShadowRoot?F(t.host):t.parentNode?F(t.parentNode):[]}function P(t){return new Proxy({objects:t},$n)}function or(t,e){return t===null||t===Object.prototype?null:Object.prototype.hasOwnProperty.call(t,e)?t:or(Object.getPrototypeOf(t),e)}var $n={ownKeys({objects:t}){return Array.from(new Set(t.flatMap(e=>Object.keys(e))))},has({objects:t},e){return e==Symbol.unscopables?!1:t.some(r=>Object.prototype.hasOwnProperty.call(r,e)||Reflect.has(r,e))},get({objects:t},e,r){return e=="toJSON"?Ln:Reflect.get(t.find(n=>Reflect.has(n,e))||{},e,r)},set({objects:t},e,r,n){let i;for(let s of t)if(i=or(s,e),i)break;i||(i=t[t.length-1]);let o=Object.getOwnPropertyDescriptor(i,e);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,e,r)}};function Ln(){return Reflect.ownKeys(this).reduce((e,r)=>(e[r]=Reflect.get(this,r),e),{})}function rt(t){let e=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(t,c,o):e(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(t)}function Tt(t,e=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return t(this.initialValue,()=>jn(n,i),s=>me(n,i,s),i,o)}};return e(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function jn(t,e){return e.split(".").reduce((r,n)=>r[n],t)}function me(t,e,r){if(typeof e=="string"&&(e=e.split(".")),e.length===1)t[e[0]]=r;else{if(e.length===0)throw error;return t[e[0]]||(t[e[0]]={}),me(t[e[0]],e.slice(1),r)}}var sr={};function x(t,e){sr[t]=e}function H(t,e){let r=Fn(e);return Object.entries(sr).forEach(([n,i])=>{Object.defineProperty(t,`$${n}`,{get(){return i(e,r)},enumerable:!1})}),t}function Fn(t){let[e,r]=he(t),n={interceptor:Tt,...e};return et(t,r),n}function ar(t,e,r,...n){try{return r(...n)}catch(i){nt(i,t,e)}}function nt(...t){return cr(...t)}var cr=Bn;function lr(t){cr=t}function Bn(t,e,r=void 0){t=Object.assign(t??{message:"No error message given."},{el:e,expression:r}),console.warn(`Alpine Expression Error: ${t.message} + +${r?'Expression: "'+r+`" + +`:""}`,e),setTimeout(()=>{throw t},0)}var it=!0;function Mt(t){let e=it;it=!1;let r=t();return it=e,r}function T(t,e,r={}){let n;return _(t,e)(i=>n=i,r),n}function _(...t){return ur(...t)}var ur=()=>{};function fr(t){ur=t}var dr;function pr(t){dr=t}function mr(t,e){let r={};H(r,t);let n=[r,...F(t)],i=typeof e=="function"?zn(n,e):Vn(n,e,t);return ar.bind(null,t,e,i)}function zn(t,e){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!it){ft(r,e,P([n,...t]),i);return}let s=e.apply(P([n,...t]),i);ft(r,s)}}var _e={};function Hn(t,e){if(_e[t])return _e[t];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${t}`}),s}catch(s){return nt(s,e,t),Promise.resolve()}})();return _e[t]=o,o}function Vn(t,e,r){let n=Hn(e,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=P([o,...t]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>nt(u,r,e));n.finished?(ft(i,n.result,c,s,r),n.result=void 0):l.then(u=>{ft(i,u,c,s,r)}).catch(u=>nt(u,r,e)).finally(()=>n.result=void 0)}}}function ft(t,e,r,n,i){if(it&&typeof e=="function"){let o=e.apply(r,n);o instanceof Promise?o.then(s=>ft(t,s,r,n)).catch(s=>nt(s,i,e)):t(o)}else typeof e=="object"&&e instanceof Promise?e.then(o=>t(o)):t(e)}function hr(...t){return dr(...t)}function _r(t,e,r={}){let n={};H(n,t);let i=[n,...F(t)],o=P([r.scope??{},...i]),s=r.params??[];if(e.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(()=>{ ${e} })()`:e,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&it?l.apply(o,s):l}}var ye="x-";function O(t=""){return ye+t}function gr(t){ye=t}var Rt={};function p(t,e){return Rt[t]=e,{before(r){if(!Rt[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${t}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,t)}}}function xr(t){return Object.keys(Rt).includes(t)}function pt(t,e,r){if(e=Array.from(e),t._x_virtualDirectives){let o=Object.entries(t._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=be(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),e=e.concat(o)}let n={};return e.map(wr((o,s)=>n[o]=s)).filter(Sr).map(qn(n,r)).sort(Kn).map(o=>Un(t,o))}function be(t){return Array.from(t).map(wr()).filter(e=>!Sr(e))}var ge=!1,dt=new Map,yr=Symbol();function br(t){ge=!0;let e=Symbol();yr=e,dt.set(e,[]);let r=()=>{for(;dt.get(e).length;)dt.get(e).shift()();dt.delete(e)},n=()=>{ge=!1,r()};t(r),n()}function he(t){let e=[],r=a=>e.push(a),[n,i]=Ye(t);return e.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:_.bind(_,t),evaluate:T.bind(T,t)},()=>e.forEach(a=>a())]}function Un(t,e){let r=()=>{},n=Rt[e.type]||r,[i,o]=he(t);Ot(t,e.original,o);let s=()=>{t._x_ignore||t._x_ignoreSelf||(n.inline&&n.inline(t,e,i),n=n.bind(n,t,e,i),ge?dt.get(yr).push(n):n())};return s.runCleanups=o,s}var Nt=(t,e)=>({name:r,value:n})=>(r.startsWith(t)&&(r=r.replace(t,e)),{name:r,value:n}),Pt=t=>t;function wr(t=()=>{}){return({name:e,value:r})=>{let{name:n,value:i}=Er.reduce((o,s)=>s(o),{name:e,value:r});return n!==e&&t(n,e),{name:n,value:i}}}var Er=[];function ot(t){Er.push(t)}function Sr({name:t}){return vr().test(t)}var vr=()=>new RegExp(`^${ye}([^:^.]+)\\b`);function qn(t,e){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(vr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=e||t[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var xe="DEFAULT",G=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",xe,"teleport"];function Kn(t,e){let r=G.indexOf(t.type)===-1?xe:t.type,n=G.indexOf(e.type)===-1?xe:e.type;return G.indexOf(r)-G.indexOf(n)}function J(t,e,r={},n={}){return t.dispatchEvent(new CustomEvent(e,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function D(t,e){if(typeof ShadowRoot=="function"&&t instanceof ShadowRoot){Array.from(t.children).forEach(i=>D(i,e));return}let r=!1;if(e(t,()=>r=!0),r)return;let n=t.firstElementChild;for(;n;)D(n,e,!1),n=n.nextElementSibling}function E(t,...e){console.warn(`Alpine Warning: ${t}`,...e)}var Ar=!1;function Or(){Ar&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Ar=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` + + + + + + + + + +
+

Lan Play

+
+ + + +
+
+
+

Loading…

+

+
+ + +
+
+ + + + + + +
PreviewNameType
📁..Folder
+
+
+
+ +
+
+ +
+ +
+
+ + +
+
+
+ + diff --git a/web/js/api.js b/web/js/api.js new file mode 100644 index 0000000..afb93df --- /dev/null +++ b/web/js/api.js @@ -0,0 +1,31 @@ +(function () { + async function request(url, options) { + const response = await fetch(url, options); + if (!response.ok) { + let message = `HTTP ${response.status}`; + try { message = (await response.json()).error || message; } catch (_) {} + throw new Error(message); + } + return response; + } + + window.LanPlayApi = { + async browse(path) { + return (await request(`/api/browse?path=${encodeURIComponent(path)}`)).json(); + }, + + async info() { + return (await request('/api/info')).json(); + }, + async startDash(path, startSeconds = 0) { + const query = `path=${encodeURIComponent(path)}&start=${encodeURIComponent(startSeconds)}`; + return (await request(`/api/dash?${query}`, { method: 'POST' })).json(); + }, + async stopDash(sessionId) { + await request(`/api/dash/${sessionId}`, { method: 'DELETE' }); + }, + async subtitles(path) { + return (await request(`/api/subtitles?path=${encodeURIComponent(path)}`)).json(); + } + }; +})(); diff --git a/web/js/library.js b/web/js/library.js new file mode 100644 index 0000000..ea703a2 --- /dev/null +++ b/web/js/library.js @@ -0,0 +1,262 @@ +document.addEventListener('alpine:init', () => { + Alpine.data('library', () => ({ + currentPath: '', parentPath: null, entries: [], selected: null, + loading: false, playerLoading: false, playerLoadingCompact: false, + error: '', playerController: null, viewMode: 'grid', + infoOpen: false, infoLoading: false, infoError: '', diagnostics: null, + previewFrames: {}, previewUrls: {}, previewStates: {}, previewTimers: {}, + previewActive: {}, previewImages: {}, previewGeneration: 0, + + init() { + try { this.viewMode = localStorage.getItem('lan-play:view') || 'grid'; } catch (_) {} + this.locationHandler = () => { this.restoreLocation(); }; + this.resizeHandler = () => { this.scheduleMarqueeCheck(); }; + window.addEventListener('popstate', this.locationHandler); + window.addEventListener('resize', this.resizeHandler); + }, + + destroy() { + this.stopAllPreviews(); + this.cancelPreviewLoads(); + window.removeEventListener('popstate', this.locationHandler); + window.removeEventListener('resize', this.resizeHandler); + if (this.marqueeFrame) window.cancelAnimationFrame(this.marqueeFrame); + if (this.playerController) this.playerController.dispose(); + }, + + async restoreLocation() { + const location = window.LanPlayLocation.read(); + if (this.playerController) await this.playerController.stop(); + this.selected = null; + await this.browse(location.path, false); + if (location.play) { + await this.play({ + path: location.play, + name: window.LanPlayLocation.fileName(location.play), + kind: 'media' + }, false); + } + }, + + async browse(path, updateLocation = true) { + this.stopAllPreviews(); + this.cancelPreviewLoads(); + this.loading = true; + this.error = ''; + try { + const data = await window.LanPlayApi.browse(path); + this.currentPath = data.path; + this.parentPath = data.parent; + this.entries = data.entries; + if (updateLocation) window.LanPlayLocation.write(data.path, null, false); + await this.$nextTick(); + this.preloadPreviews(data.entries); + this.scheduleMarqueeCheck(); + } catch (error) { this.error = error.message; } + finally { this.loading = false; } + }, + + async play(entry, updateLocation = true) { + this.stopAllPreviews(); + this.enterFullscreen(); + this.error = ''; + this.selected = entry; + this.playerLoading = true; + if (updateLocation) window.LanPlayLocation.write(this.currentPath, entry.path, false); + await this.$nextTick(); + if (!this.playerController) { + this.playerController = new window.LanPlayPlayer( + this.$refs.video, + this.$refs.videoWrapper, + (message) => { this.error = message; }, + (loading, compact = false) => { + this.playerLoading = loading; + this.playerLoadingCompact = loading && compact; + }, + () => { this.playNext(); } + ); + } + this.playerController.setNextAvailable(Boolean(this.nextEntry())); + try { await this.playerController.play(entry); } + catch (error) { this.error = error.message; } + finally { + this.playerLoading = false; + this.playerLoadingCompact = false; + } + }, + + nextEntry() { + if (!this.selected) return null; + const media = this.entries.filter((entry) => entry.kind === 'media'); + const index = media.findIndex((entry) => entry.path === this.selected.path); + return index >= 0 ? media[index + 1] || null : null; + }, + + async playNext() { + const next = this.nextEntry(); + if (next) await this.play(next); + }, + + mediaTitle() { + if (!this.selected || !this.selected.name) return ''; + return this.selected.name.replace(/\.[^.]+$/, ''); + }, + + setViewMode(mode) { + this.viewMode = mode === 'table' ? 'table' : 'grid'; + try { localStorage.setItem('lan-play:view', this.viewMode); } catch (_) {} + this.$nextTick(() => this.scheduleMarqueeCheck()); + }, + + scheduleMarqueeCheck() { + if (this.marqueeFrame) window.cancelAnimationFrame(this.marqueeFrame); + this.marqueeFrame = window.requestAnimationFrame(() => { + this.marqueeFrame = null; + document.querySelectorAll('.entry-name').forEach((container) => { + if (container.clientWidth <= 0) return; + const text = container.querySelector('.entry-name-track'); + container.classList.toggle('is-overflowing', Boolean(text) && text.scrollWidth > container.clientWidth + 1); + }); + }); + }, + + openEntry(entry) { + return entry.kind === 'directory' ? this.browse(entry.path) : this.play(entry); + }, + + async openInfo() { + this.infoOpen = true; + this.infoLoading = true; + this.infoError = ''; + try { + const server = await window.LanPlayApi.info(); + let dashVersion = 'Unknown'; + try { dashVersion = dashjs.MediaPlayer().create().getVersion(); } catch (_) {} + this.diagnostics = { + ...server, + interface: '20', + dashjs: dashVersion, + browser: navigator.userAgent, + display: `${window.screen.width}×${window.screen.height} @ ${window.devicePixelRatio || 1}x`, + fullscreen: Boolean(document.documentElement.requestFullscreen || document.documentElement.webkitRequestFullscreen), + mediaSource: 'MediaSource' in window + }; + } catch (error) { + this.infoError = error.message; + } finally { + this.infoLoading = false; + } + }, + + closeInfo() { + this.infoOpen = false; + }, + + previewStyle(entry) { + if (this.previewStates[entry.path] !== 'ready') return ''; + const url = this.previewUrls[entry.path]; + if (!url) return ''; + const frame = this.previewFrames[entry.path] || 0; + const column = frame % 5; + const row = Math.floor(frame / 5); + return `background-image:url("${url}");background-position:${column * 25}% ${row * 100}%`; + }, + + startPreview(entry) { + if (!entry || entry.kind !== 'media') return; + this.previewActive[entry.path] = true; + this.stopPreview(entry); + this.previewActive[entry.path] = true; + if (this.previewStates[entry.path] !== 'ready') return; + this.previewFrames[entry.path] = 0; + this.previewTimers[entry.path] = window.setInterval(() => { + this.previewFrames[entry.path] = ((this.previewFrames[entry.path] || 0) + 1) % 10; + }, 500); + }, + + stopPreview(entry) { + if (!entry) return; + this.previewActive[entry.path] = false; + const timer = this.previewTimers[entry.path]; + if (timer) window.clearInterval(timer); + delete this.previewTimers[entry.path]; + this.previewFrames[entry.path] = 0; + }, + + stopAllPreviews() { + Object.values(this.previewTimers).forEach((timer) => window.clearInterval(timer)); + this.previewTimers = {}; + this.previewActive = {}; + }, + + preloadPreviews(entries) { + const generation = this.previewGeneration; + entries.filter((entry) => entry.kind === 'media').forEach((entry) => { + const path = entry.path; + const url = `/api/preview?path=${encodeURIComponent(path)}`; + const image = new Image(); + this.previewUrls[path] = url; + this.previewStates[path] = 'loading'; + this.previewImages[path] = image; + image.onload = () => { + if (generation !== this.previewGeneration) return; + this.previewStates[path] = 'ready'; + delete this.previewImages[path]; + if (this.previewActive[path]) this.startPreview(entry); + }; + image.onerror = () => { + if (generation !== this.previewGeneration) return; + this.previewStates[path] = 'error'; + delete this.previewImages[path]; + }; + image.src = url; + }); + }, + + cancelPreviewLoads() { + this.previewGeneration += 1; + Object.values(this.previewImages).forEach((image) => { + image.onload = null; + image.onerror = null; + image.src = ''; + }); + this.previewImages = {}; + this.previewFrames = {}; + this.previewUrls = {}; + this.previewStates = {}; + }, + + enterFullscreen() { + if (document.fullscreenElement || document.webkitFullscreenElement) return; + const root = document.documentElement; + const request = root.requestFullscreen || root.webkitRequestFullscreen; + if (request) { + try { + const result = request.call(root); + if (result && result.catch) result.catch(() => {}); + } catch (_) {} + } + }, + + exitFullscreen() { + if (!document.fullscreenElement && !document.webkitFullscreenElement) return; + const exit = document.exitFullscreen || document.webkitExitFullscreen; + if (exit) { + try { + const result = exit.call(document); + if (result && result.catch) result.catch(() => {}); + } catch (_) {} + } + }, + + async closePlayer(updateLocation = true) { + this.exitFullscreen(); + this.selected = null; + this.playerLoading = false; + this.playerLoadingCompact = false; + if (updateLocation) window.LanPlayLocation.write(this.currentPath, null, false); + await this.$nextTick(); + if (this.playerController) this.playerController.stop(); + } + })); +}); diff --git a/web/js/location.js b/web/js/location.js new file mode 100644 index 0000000..9f6db57 --- /dev/null +++ b/web/js/location.js @@ -0,0 +1,24 @@ +(function () { + function read() { + const params = new URLSearchParams(window.location.search); + return { + path: params.get('path') || '', + play: params.get('play') || null + }; + } + + function write(path, play, replace) { + const url = new URL(window.location.href); + path ? url.searchParams.set('path', path) : url.searchParams.delete('path'); + play ? url.searchParams.set('play', play) : url.searchParams.delete('play'); + const next = `${url.pathname}${url.search}`; + window.history[replace ? 'replaceState' : 'pushState']({}, '', next); + } + + function fileName(path) { + const parts = path.split('/'); + return parts[parts.length - 1] || path; + } + + window.LanPlayLocation = { read, write, fileName }; +})(); diff --git a/web/js/player.js b/web/js/player.js new file mode 100644 index 0000000..7a8e143 --- /dev/null +++ b/web/js/player.js @@ -0,0 +1,265 @@ +(function () { + class LanPlayPlayer { + constructor(video, wrapper, onError, onLoading, onNext) { + this.video = video; + this.wrapper = wrapper; + this.onError = onError; + this.onLoading = onLoading; + this.onNext = onNext; + this.dashPlayer = null; + this.controlBar = null; + this.sessionId = null; + this.mediaPath = null; + this.dashPreferencesApplied = false; + this.currentEntry = null; + this.nextAvailable = false; + this.restarting = false; + this.operation = 0; + this.freezeFrame = null; + this.playingHandler = null; + this.subtitleUrls = []; + } + + async play(entry, startSeconds = 0, preserveFrame = false) { + const framePreserved = preserveFrame && this.captureFrame(); + this.onLoading(true, framePreserved); + this.stop({ preserveFrame: framePreserved }); + const operation = ++this.operation; + this.currentEntry = entry; + this.mediaPath = entry.path; + this.dashPreferencesApplied = false; + const session = await window.LanPlayApi.startDash(entry.path, startSeconds); + if (operation !== this.operation) { + window.LanPlayApi.stopDash(session.session_id).catch(() => {}); + return; + } + this.sessionId = session.session_id; + this.dashPlayer = dashjs.MediaPlayer().create(); + this.dashPlayer.updateSettings({ + streaming: { + buffer: { + bufferTimeDefault: 8, + bufferTimeAtTopQuality: 8, + bufferTimeAtTopQualityLongForm: 8 + } + } + }); + this.dashPlayer.initialize(this.video, null, true); + await this.addExternalSubtitles(entry.path, session.start_seconds, operation); + if (operation !== this.operation) return; + this.controlBar = new DashControlBarModule.ControlBar(this.dashPlayer, this.video); + this.controlBar.init(this.wrapper); + this.controlBar.setVodDuration(session.duration_seconds, session.start_seconds); + if (typeof this.controlBar.onSeekRequested === 'function') { + this.controlBar.onSeekRequested((time) => this.seek(time)); + } + if (typeof this.controlBar.onNextRequested === 'function') { + this.controlBar.onNextRequested(() => this.onNext()); + } + if (typeof this.controlBar.setNextAvailable === 'function') { + this.controlBar.setNextAvailable(this.nextAvailable); + } + this.controlBar.onTrackSelected((type, track) => this.rememberTrack(type, track)); + this.controlBar.disable(); + const controlBar = this.controlBar; + this.dashPlayer.on(dashjs.MediaPlayer.events.ERROR, (event) => { + if (operation !== this.operation) return; + const message = event && event.error && event.error.message; + this.onError(message || 'DASH playback error'); + }); + this.dashPlayer.on(dashjs.MediaPlayer.events.STREAM_INITIALIZED, () => { + if (operation !== this.operation || this.controlBar !== controlBar) return; + this.applyDashPreferences(); + controlBar.enable(); + controlBar.refreshTracks(); + }); + this.playingHandler = () => { + if (operation !== this.operation) return; + this.playingHandler = null; + this.clearFreezeFrame(); + this.onLoading(false, false); + }; + this.video.addEventListener('playing', this.playingHandler, { once: true }); + this.dashPlayer.attachSource(session.manifest_url); + if (!framePreserved) this.onLoading(false, false); + } + + async seek(time) { + if (this.restarting || !this.currentEntry) return; + this.restarting = true; + const entry = this.currentEntry; + try { await this.play(entry, time, true); } + catch (error) { + this.onError(error.message); + this.clearFreezeFrame(); + this.onLoading(false, false); + } finally { this.restarting = false; } + } + + setNextAvailable(available) { + this.nextAvailable = Boolean(available); + if (this.controlBar && typeof this.controlBar.setNextAvailable === 'function') { + this.controlBar.setNextAvailable(this.nextAvailable); + } + } + + applyDashPreferences() { + if (this.dashPreferencesApplied || !this.mediaPath) return; + ['audio', 'video'].forEach((type) => { + const tracks = this.dashPlayer.getTracksFor(type) || []; + const preferred = window.LanPlayPreferences.find(this.mediaPath, type, tracks); + if (preferred) this.dashPlayer.setCurrentTrack(preferred); + }); + const textTracks = this.dashPlayer.getTracksFor('text') || []; + const preferredText = window.LanPlayPreferences.find(this.mediaPath, 'subtitle', textTracks); + if (preferredText === null) { + this.dashPlayer.setTextTrack(-1); + } else if (preferredText) { + const index = textTracks.indexOf(preferredText); + if (index >= 0) this.dashPlayer.setTextTrack(index); + } + this.dashPreferencesApplied = true; + } + + rememberTrack(type, track) { + if (!this.mediaPath) return; + if (type === 'nativeText') { + const tracks = Array.from(this.video.textTracks || []); + window.LanPlayPreferences.remember(this.mediaPath, 'subtitle', track, tracks.indexOf(track)); + return; + } + const preferenceType = type === 'text' ? 'subtitle' : type; + const tracks = this.dashPlayer.getTracksFor(type) || []; + window.LanPlayPreferences.remember(this.mediaPath, preferenceType, track, tracks.indexOf(track)); + } + + async addExternalSubtitles(path, startSeconds = 0, operation = this.operation) { + let subtitles = []; + try { subtitles = await window.LanPlayApi.subtitles(path); } catch (_) { return; } + if (operation !== this.operation) return; + const loaded = await Promise.all(subtitles.filter((item) => item.supported).map(async (subtitle, ordinal) => { + const query = new URLSearchParams({ path, start: String(startSeconds) }); + const url = subtitle.source === 'external' + ? `/api/subtitles/external/${encodeURIComponent(subtitle.id)}?${query}` + : `/api/subtitles/${subtitle.index}?${query}`; + try { + const response = await fetch(url); + if (!response.ok) return null; + const contents = await response.text(); + const objectUrl = URL.createObjectURL(new Blob([contents], { type: 'text/vtt' })); + return { subtitle, ordinal, objectUrl }; + } catch (_) { + return null; + } + })); + if (operation !== this.operation) { + loaded.filter(Boolean).forEach((item) => URL.revokeObjectURL(item.objectUrl)); + return; + } + loaded.filter(Boolean).forEach(({ subtitle, ordinal, objectUrl }) => { + const label = subtitle.title || subtitle.language || `Subtitle ${ordinal + 1}`; + const idPart = subtitle.source === 'external' ? subtitle.id : subtitle.index; + this.subtitleUrls.push(objectUrl); + this.dashPlayer.addExternalSubtitle(new dashjs.ExternalSubtitle({ + id: `lan-play-${subtitle.source}-${idPart}`, + url: objectUrl, + language: label, + mimeType: 'text/vtt', + bandwidth: 256, + periodId: '0' + })); + }); + } + + captureFrame() { + if (this.video.readyState < 2 || !this.video.videoWidth || !this.video.videoHeight) return false; + try { + this.clearFreezeFrame(); + const canvas = document.createElement('canvas'); + const width = Math.max(1, this.wrapper.clientWidth); + const height = Math.max(1, this.wrapper.clientHeight); + canvas.width = Math.min(width, 1920); + canvas.height = Math.min(height, 1080); + canvas.className = 'player-freeze-frame'; + const context = canvas.getContext('2d'); + context.fillStyle = '#000'; + context.fillRect(0, 0, canvas.width, canvas.height); + const scale = Math.min(canvas.width / this.video.videoWidth, canvas.height / this.video.videoHeight); + const drawWidth = this.video.videoWidth * scale; + const drawHeight = this.video.videoHeight * scale; + context.drawImage( + this.video, + (canvas.width - drawWidth) / 2, + (canvas.height - drawHeight) / 2, + drawWidth, + drawHeight + ); + this.wrapper.appendChild(canvas); + this.freezeFrame = canvas; + return true; + } catch (_) { + this.clearFreezeFrame(); + return false; + } + } + + clearFreezeFrame() { + if (this.freezeFrame) this.freezeFrame.remove(); + this.freezeFrame = null; + } + + stop(options = {}) { + this.operation += 1; + if (this.playingHandler) { + this.video.removeEventListener('playing', this.playingHandler); + this.playingHandler = null; + } + if (!options.preserveFrame) this.clearFreezeFrame(); + this.revokeSubtitleUrls(); + if (this.controlBar) { + this.controlBar.destroy(); + this.controlBar = null; + } + if (this.dashPlayer) { + this.dashPlayer.destroy(); + this.dashPlayer = null; + } + this.video.pause(); + this.video.removeAttribute('src'); + while (this.video.firstChild) this.video.removeChild(this.video.firstChild); + this.video.load(); + this.mediaPath = null; + this.dashPreferencesApplied = false; + this.currentEntry = null; + if (this.sessionId) { + const sessionId = this.sessionId; + this.sessionId = null; + window.LanPlayApi.stopDash(sessionId).catch(() => {}); + } + } + + dispose() { + this.operation += 1; + if (this.playingHandler) this.video.removeEventListener('playing', this.playingHandler); + this.playingHandler = null; + this.clearFreezeFrame(); + this.revokeSubtitleUrls(); + if (this.controlBar) this.controlBar.destroy(); + if (this.dashPlayer) this.dashPlayer.destroy(); + if (this.sessionId) { + fetch(`/api/dash/${this.sessionId}`, { method: 'DELETE', keepalive: true }).catch(() => {}); + } + this.controlBar = null; + this.dashPlayer = null; + this.sessionId = null; + this.currentEntry = null; + } + + revokeSubtitleUrls() { + this.subtitleUrls.forEach((url) => URL.revokeObjectURL(url)); + this.subtitleUrls = []; + } + } + + window.LanPlayPlayer = LanPlayPlayer; +})(); diff --git a/web/js/preferences.js b/web/js/preferences.js new file mode 100644 index 0000000..9d6df41 --- /dev/null +++ b/web/js/preferences.js @@ -0,0 +1,63 @@ +(function () { + const prefix = 'lan-play:release:v1:'; + + function folder(path) { + const index = path.lastIndexOf('/'); + return index < 0 ? '' : path.slice(0, index); + } + + function key(path) { + return `${prefix}${folder(path)}`; + } + + function load(path) { + try { return JSON.parse(localStorage.getItem(key(path))) || {}; } + catch (_) { return {}; } + } + + function store(path, value) { + try { localStorage.setItem(key(path), JSON.stringify(value)); } catch (_) {} + } + + function label(track) { + const labels = track && track.labels || []; + const selected = labels.find((item) => item.text) || labels[0]; + return selected && selected.text || track && track.label || ''; + } + + function descriptor(track, ordinal) { + if (!track) return { off: true }; + return { + off: false, + lang: track.lang || track.language || '', + label: label(track), + roles: (track.roles || []).map((role) => role.value || role).filter(Boolean).sort(), + ordinal + }; + } + + function remember(path, type, track, ordinal) { + const profile = load(path); + profile[type] = descriptor(track, ordinal); + store(path, profile); + } + + function find(path, type, tracks) { + const wanted = load(path)[type]; + if (!wanted) return undefined; + if (wanted.off) return null; + const described = tracks.map((track, ordinal) => ({ track, ordinal, value: descriptor(track, ordinal) })); + let matches = described.filter((item) => wanted.lang && item.value.lang === wanted.lang); + if (wanted.label) { + const labeled = matches.filter((item) => item.value.label === wanted.label); + if (labeled.length) matches = labeled; + } + if (!matches.length && wanted.label) { + matches = described.filter((item) => item.value.label === wanted.label); + } + if (!matches.length && !wanted.lang && !wanted.label) matches = described; + return (matches.find((item) => item.ordinal === wanted.ordinal) || matches[0] || {}).track; + } + + window.LanPlayPreferences = { folder, load, remember, find }; +})(); diff --git a/web/js/preferences.test.js b/web/js/preferences.test.js new file mode 100644 index 0000000..1c89539 --- /dev/null +++ b/web/js/preferences.test.js @@ -0,0 +1,36 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const values = new Map(); +global.window = global; +global.localStorage = { + getItem: (key) => values.get(key) || null, + setItem: (key, value) => values.set(key, value) +}; +require('./preferences.js'); + +test('restores a matching track for the next file in the same folder', () => { + const firstEpisode = 'Anime/Show/episode-01.mkv'; + const nextEpisode = 'Anime/Show/episode-02.mkv'; + const tracks = [{ lang: 'en' }, { lang: 'ja' }]; + LanPlayPreferences.remember(firstEpisode, 'audio', tracks[1], 1); + assert.equal(LanPlayPreferences.find(nextEpisode, 'audio', tracks), tracks[1]); +}); + +test('does not apply a profile from another folder', () => { + const tracks = [{ lang: 'ja' }]; + assert.equal(LanPlayPreferences.find('Anime/Other/episode-01.mkv', 'audio', tracks), undefined); +}); + +test('falls back to the player default when the saved track is unavailable', () => { + const tracks = [{ lang: 'en' }]; + assert.equal(LanPlayPreferences.find('Anime/Show/episode-03.mkv', 'audio', tracks), undefined); +}); + +test('remembers disabled subtitles for the release folder', () => { + LanPlayPreferences.remember('Series/Season/episode-01.mkv', 'subtitle', null, -1); + assert.equal( + LanPlayPreferences.find('Series/Season/episode-02.mkv', 'subtitle', [{ language: 'en' }]), + null + ); +}); diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..425576e --- /dev/null +++ b/web/styles.css @@ -0,0 +1,61 @@ +:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #101216; color: #f4f4f4; } +* { box-sizing: border-box; } +body { margin: 0; } +body.player-open { overflow: hidden; } +[x-cloak] { display: none !important; } +header { position: sticky; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 2rem; background: #181b21; z-index: 1; } +h1 { margin: 0 0 .35rem; } +.path { color: #aab2c0; } +.view-switch { display: flex; gap: .35rem; padding: .3rem; border-radius: .55rem; background: #101216; } +.view-switch button { width: 2.7rem; height: 2.35rem; border: 0; border-radius: .4rem; background: transparent; color: #aab2c0; font-size: 1.15rem; cursor: pointer; } +.view-switch button.active { background: #354156; color: #fff; } +.view-switch button:hover, .view-switch button:focus { outline: none; background: #303746; color: #fff; } +main { padding: 1.5rem 2rem; } +.entries { display: grid; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); gap: .9rem; } +.entries button { min-width: 0; min-height: 3.5rem; padding: .65rem; border: 2px solid transparent; border-radius: .6rem; background: #252a33; color: inherit; text-align: left; font-size: 1.05rem; cursor: pointer; } +.entries button.media, .entries button.directory { display: grid; grid-template-rows: auto auto; gap: .65rem; } +.entry-preview { display: grid; place-items: center; min-height: 2.2rem; border-radius: .4rem; font-size: 1.35rem; } +.entry-preview-media { width: 100%; aspect-ratio: 16 / 9; min-height: 0; overflow: hidden; background-color: #171a20; background-repeat: no-repeat; background-size: 500% 200%; font-size: 2rem; transition: background-image .15s ease; } +.preview-spinner { animation: player-spin 1s linear infinite; color: #aab2c0; } +.entry-name { display: block; width: 100%; overflow: hidden; white-space: nowrap; } +.entry-name-track { display: inline-block; min-width: max-content; } +.entry-name.is-overflowing .entry-name-track { animation: entry-marquee 14s linear infinite; } +@keyframes entry-marquee { from { transform: translateX(0); } to { transform: translateX(-100%); } } +.entries button:focus { outline: none; border-color: #69a7ff; background: #303746; } +.entry-table-wrap { overflow-x: auto; border: 1px solid #343a45; border-radius: .65rem; } +.entry-table { width: 100%; border-collapse: collapse; table-layout: fixed; background: #20242c; } +.entry-table th { padding: .65rem .8rem; color: #aab2c0; background: #181b21; text-align: left; font-size: .85rem; text-transform: uppercase; letter-spacing: .04em; } +.entry-table th:first-child { width: 11rem; } +.entry-table th:last-child { width: 7rem; } +.entry-table td { height: 5.25rem; padding: .45rem .8rem; border-top: 1px solid #343a45; overflow: hidden; color: #dce1e8; } +.entry-table tbody tr { cursor: pointer; } +.entry-table tbody tr:hover, .entry-table tbody tr:focus { outline: none; background: #303746; } +.table-preview { display: grid; place-items: center; width: 8rem; aspect-ratio: 16 / 9; overflow: hidden; border-radius: .35rem; background-color: #171a20; background-repeat: no-repeat; background-size: 500% 200%; font-size: 1.35rem; } +.table-name { font-size: 1rem; } +.info-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 1.5rem; background: rgba(0,0,0,.72); } +.info-dialog { position: relative; width: min(46rem, 100%); max-height: calc(100vh - 3rem); overflow: auto; padding: 1.4rem 1.5rem 1.5rem; border: 1px solid #465064; border-radius: .8rem; background: #1d222b; box-shadow: 0 1rem 4rem rgba(0,0,0,.55); } +.info-dialog h2 { margin: 0 3rem 1.2rem 0; } +.info-close { position: absolute; top: .9rem; right: .9rem; width: 2.5rem; height: 2.5rem; border: 0; border-radius: 50%; background: #303746; color: #fff; cursor: pointer; } +.info-loading { display: flex; align-items: center; gap: .65rem; color: #aab2c0; } +.info-grid { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: .7rem 1rem; margin: 0; } +.info-grid dt { color: #9ea8b8; } +.info-grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; } +.info-grid small { display: block; margin-top: .2rem; color: #7f8999; } +.info-browser { font-size: .82rem; color: #b9c0cb; } +@media (max-width: 38rem) { .info-grid { grid-template-columns: 1fr; gap: .2rem; } .info-grid dd { margin-bottom: .65rem; } } +.error { color: #ff8989; } +.player { position: fixed; inset: 0; z-index: 5; display: flex; flex-direction: column; background: #000; } +.player-close { position: absolute; top: 1.25rem; right: 1.25rem; z-index: 40; width: 4rem; height: 4rem; display: grid; place-items: center; padding: 0; border: 2px solid rgba(255,255,255,.7); border-radius: 50%; background: rgba(0,0,0,.65); color: #fff; font-size: 1.8rem; cursor: pointer; opacity: 1; transition: opacity .2s ease, transform .2s ease; } +.player-close.player-close-hidden { opacity: 0; pointer-events: none; } +.player-close:hover, .player-close:focus { outline: none; border-color: #fff; background: rgba(30,35,45,.95); transform: scale(1.05); } +.video-wrapper { position: relative; flex: 1; min-height: 0; overflow: hidden; } +.video-wrapper video { display: block; width: 100%; height: 100%; object-fit: contain; } +.player-title { position: absolute; inset: 0 0 auto; z-index: 18; min-height: 7rem; padding: 1.4rem 6.5rem 3rem 1.6rem; overflow: hidden; color: #fff; background: linear-gradient(to bottom, rgba(0,0,0,.78) 0%, rgba(0,0,0,.42) 42%, transparent 100%); font-size: clamp(1.05rem, 2vw, 1.55rem); font-weight: 600; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; text-shadow: 0 1px 3px #000; opacity: 1; transition: opacity .2s ease; pointer-events: none; } +.player-title.player-title-hidden { opacity: 0; } +.player-freeze-frame { position: absolute; inset: 0; z-index: 15; width: 100%; height: 100%; object-fit: contain; background: #000; } +.video-wrapper .cb-controlbar { --cb-accent: #69a7ff; font-family: system-ui, sans-serif; } +.player-status { position: absolute; inset: 0; z-index: 20; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; color: #f4f4f4; background: #000; font-size: 1.1rem; } +.player-status-compact { inset: 1rem auto auto 50%; transform: translateX(-50%); flex-direction: row; padding: .6rem .9rem; border-radius: .5rem; background: rgba(0,0,0,.72); font-size: .9rem; white-space: nowrap; pointer-events: none; } +.player-status-compact .player-spinner { font-size: 1.15rem; } +.player-spinner { font-size: 3rem; animation: player-spin 1s linear infinite; } +@keyframes player-spin { to { transform: rotate(360deg); } } diff --git a/web/vendor/bootstrap-icons/LICENSE b/web/vendor/bootstrap-icons/LICENSE new file mode 100644 index 0000000..f952439 --- /dev/null +++ b/web/vendor/bootstrap-icons/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019-2024 The Bootstrap Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/web/vendor/bootstrap-icons/bootstrap-icons.min.css b/web/vendor/bootstrap-icons/bootstrap-icons.min.css new file mode 100644 index 0000000..706a5c8 --- /dev/null +++ b/web/vendor/bootstrap-icons/bootstrap-icons.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap Icons v1.13.1 (https://icons.getbootstrap.com/) + * Copyright 2019-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE) + */@font-face{font-display:block;font-family:bootstrap-icons;src:url("fonts/bootstrap-icons.woff2?e34853135f9e39acf64315236852cd5a") format("woff2"),url("fonts/bootstrap-icons.woff?e34853135f9e39acf64315236852cd5a") format("woff")}.bi::before,[class*=" bi-"]::before,[class^=bi-]::before{display:inline-block;font-family:bootstrap-icons!important;font-style:normal;font-weight:400!important;font-variant:normal;text-transform:none;line-height:1;vertical-align:-.125em;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.bi-123::before{content:"\f67f"}.bi-alarm-fill::before{content:"\f101"}.bi-alarm::before{content:"\f102"}.bi-align-bottom::before{content:"\f103"}.bi-align-center::before{content:"\f104"}.bi-align-end::before{content:"\f105"}.bi-align-middle::before{content:"\f106"}.bi-align-start::before{content:"\f107"}.bi-align-top::before{content:"\f108"}.bi-alt::before{content:"\f109"}.bi-app-indicator::before{content:"\f10a"}.bi-app::before{content:"\f10b"}.bi-archive-fill::before{content:"\f10c"}.bi-archive::before{content:"\f10d"}.bi-arrow-90deg-down::before{content:"\f10e"}.bi-arrow-90deg-left::before{content:"\f10f"}.bi-arrow-90deg-right::before{content:"\f110"}.bi-arrow-90deg-up::before{content:"\f111"}.bi-arrow-bar-down::before{content:"\f112"}.bi-arrow-bar-left::before{content:"\f113"}.bi-arrow-bar-right::before{content:"\f114"}.bi-arrow-bar-up::before{content:"\f115"}.bi-arrow-clockwise::before{content:"\f116"}.bi-arrow-counterclockwise::before{content:"\f117"}.bi-arrow-down-circle-fill::before{content:"\f118"}.bi-arrow-down-circle::before{content:"\f119"}.bi-arrow-down-left-circle-fill::before{content:"\f11a"}.bi-arrow-down-left-circle::before{content:"\f11b"}.bi-arrow-down-left-square-fill::before{content:"\f11c"}.bi-arrow-down-left-square::before{content:"\f11d"}.bi-arrow-down-left::before{content:"\f11e"}.bi-arrow-down-right-circle-fill::before{content:"\f11f"}.bi-arrow-down-right-circle::before{content:"\f120"}.bi-arrow-down-right-square-fill::before{content:"\f121"}.bi-arrow-down-right-square::before{content:"\f122"}.bi-arrow-down-right::before{content:"\f123"}.bi-arrow-down-short::before{content:"\f124"}.bi-arrow-down-square-fill::before{content:"\f125"}.bi-arrow-down-square::before{content:"\f126"}.bi-arrow-down-up::before{content:"\f127"}.bi-arrow-down::before{content:"\f128"}.bi-arrow-left-circle-fill::before{content:"\f129"}.bi-arrow-left-circle::before{content:"\f12a"}.bi-arrow-left-right::before{content:"\f12b"}.bi-arrow-left-short::before{content:"\f12c"}.bi-arrow-left-square-fill::before{content:"\f12d"}.bi-arrow-left-square::before{content:"\f12e"}.bi-arrow-left::before{content:"\f12f"}.bi-arrow-repeat::before{content:"\f130"}.bi-arrow-return-left::before{content:"\f131"}.bi-arrow-return-right::before{content:"\f132"}.bi-arrow-right-circle-fill::before{content:"\f133"}.bi-arrow-right-circle::before{content:"\f134"}.bi-arrow-right-short::before{content:"\f135"}.bi-arrow-right-square-fill::before{content:"\f136"}.bi-arrow-right-square::before{content:"\f137"}.bi-arrow-right::before{content:"\f138"}.bi-arrow-up-circle-fill::before{content:"\f139"}.bi-arrow-up-circle::before{content:"\f13a"}.bi-arrow-up-left-circle-fill::before{content:"\f13b"}.bi-arrow-up-left-circle::before{content:"\f13c"}.bi-arrow-up-left-square-fill::before{content:"\f13d"}.bi-arrow-up-left-square::before{content:"\f13e"}.bi-arrow-up-left::before{content:"\f13f"}.bi-arrow-up-right-circle-fill::before{content:"\f140"}.bi-arrow-up-right-circle::before{content:"\f141"}.bi-arrow-up-right-square-fill::before{content:"\f142"}.bi-arrow-up-right-square::before{content:"\f143"}.bi-arrow-up-right::before{content:"\f144"}.bi-arrow-up-short::before{content:"\f145"}.bi-arrow-up-square-fill::before{content:"\f146"}.bi-arrow-up-square::before{content:"\f147"}.bi-arrow-up::before{content:"\f148"}.bi-arrows-angle-contract::before{content:"\f149"}.bi-arrows-angle-expand::before{content:"\f14a"}.bi-arrows-collapse::before{content:"\f14b"}.bi-arrows-expand::before{content:"\f14c"}.bi-arrows-fullscreen::before{content:"\f14d"}.bi-arrows-move::before{content:"\f14e"}.bi-aspect-ratio-fill::before{content:"\f14f"}.bi-aspect-ratio::before{content:"\f150"}.bi-asterisk::before{content:"\f151"}.bi-at::before{content:"\f152"}.bi-award-fill::before{content:"\f153"}.bi-award::before{content:"\f154"}.bi-back::before{content:"\f155"}.bi-backspace-fill::before{content:"\f156"}.bi-backspace-reverse-fill::before{content:"\f157"}.bi-backspace-reverse::before{content:"\f158"}.bi-backspace::before{content:"\f159"}.bi-badge-3d-fill::before{content:"\f15a"}.bi-badge-3d::before{content:"\f15b"}.bi-badge-4k-fill::before{content:"\f15c"}.bi-badge-4k::before{content:"\f15d"}.bi-badge-8k-fill::before{content:"\f15e"}.bi-badge-8k::before{content:"\f15f"}.bi-badge-ad-fill::before{content:"\f160"}.bi-badge-ad::before{content:"\f161"}.bi-badge-ar-fill::before{content:"\f162"}.bi-badge-ar::before{content:"\f163"}.bi-badge-cc-fill::before{content:"\f164"}.bi-badge-cc::before{content:"\f165"}.bi-badge-hd-fill::before{content:"\f166"}.bi-badge-hd::before{content:"\f167"}.bi-badge-tm-fill::before{content:"\f168"}.bi-badge-tm::before{content:"\f169"}.bi-badge-vo-fill::before{content:"\f16a"}.bi-badge-vo::before{content:"\f16b"}.bi-badge-vr-fill::before{content:"\f16c"}.bi-badge-vr::before{content:"\f16d"}.bi-badge-wc-fill::before{content:"\f16e"}.bi-badge-wc::before{content:"\f16f"}.bi-bag-check-fill::before{content:"\f170"}.bi-bag-check::before{content:"\f171"}.bi-bag-dash-fill::before{content:"\f172"}.bi-bag-dash::before{content:"\f173"}.bi-bag-fill::before{content:"\f174"}.bi-bag-plus-fill::before{content:"\f175"}.bi-bag-plus::before{content:"\f176"}.bi-bag-x-fill::before{content:"\f177"}.bi-bag-x::before{content:"\f178"}.bi-bag::before{content:"\f179"}.bi-bar-chart-fill::before{content:"\f17a"}.bi-bar-chart-line-fill::before{content:"\f17b"}.bi-bar-chart-line::before{content:"\f17c"}.bi-bar-chart-steps::before{content:"\f17d"}.bi-bar-chart::before{content:"\f17e"}.bi-basket-fill::before{content:"\f17f"}.bi-basket::before{content:"\f180"}.bi-basket2-fill::before{content:"\f181"}.bi-basket2::before{content:"\f182"}.bi-basket3-fill::before{content:"\f183"}.bi-basket3::before{content:"\f184"}.bi-battery-charging::before{content:"\f185"}.bi-battery-full::before{content:"\f186"}.bi-battery-half::before{content:"\f187"}.bi-battery::before{content:"\f188"}.bi-bell-fill::before{content:"\f189"}.bi-bell::before{content:"\f18a"}.bi-bezier::before{content:"\f18b"}.bi-bezier2::before{content:"\f18c"}.bi-bicycle::before{content:"\f18d"}.bi-binoculars-fill::before{content:"\f18e"}.bi-binoculars::before{content:"\f18f"}.bi-blockquote-left::before{content:"\f190"}.bi-blockquote-right::before{content:"\f191"}.bi-book-fill::before{content:"\f192"}.bi-book-half::before{content:"\f193"}.bi-book::before{content:"\f194"}.bi-bookmark-check-fill::before{content:"\f195"}.bi-bookmark-check::before{content:"\f196"}.bi-bookmark-dash-fill::before{content:"\f197"}.bi-bookmark-dash::before{content:"\f198"}.bi-bookmark-fill::before{content:"\f199"}.bi-bookmark-heart-fill::before{content:"\f19a"}.bi-bookmark-heart::before{content:"\f19b"}.bi-bookmark-plus-fill::before{content:"\f19c"}.bi-bookmark-plus::before{content:"\f19d"}.bi-bookmark-star-fill::before{content:"\f19e"}.bi-bookmark-star::before{content:"\f19f"}.bi-bookmark-x-fill::before{content:"\f1a0"}.bi-bookmark-x::before{content:"\f1a1"}.bi-bookmark::before{content:"\f1a2"}.bi-bookmarks-fill::before{content:"\f1a3"}.bi-bookmarks::before{content:"\f1a4"}.bi-bookshelf::before{content:"\f1a5"}.bi-bootstrap-fill::before{content:"\f1a6"}.bi-bootstrap-reboot::before{content:"\f1a7"}.bi-bootstrap::before{content:"\f1a8"}.bi-border-all::before{content:"\f1a9"}.bi-border-bottom::before{content:"\f1aa"}.bi-border-center::before{content:"\f1ab"}.bi-border-inner::before{content:"\f1ac"}.bi-border-left::before{content:"\f1ad"}.bi-border-middle::before{content:"\f1ae"}.bi-border-outer::before{content:"\f1af"}.bi-border-right::before{content:"\f1b0"}.bi-border-style::before{content:"\f1b1"}.bi-border-top::before{content:"\f1b2"}.bi-border-width::before{content:"\f1b3"}.bi-border::before{content:"\f1b4"}.bi-bounding-box-circles::before{content:"\f1b5"}.bi-bounding-box::before{content:"\f1b6"}.bi-box-arrow-down-left::before{content:"\f1b7"}.bi-box-arrow-down-right::before{content:"\f1b8"}.bi-box-arrow-down::before{content:"\f1b9"}.bi-box-arrow-in-down-left::before{content:"\f1ba"}.bi-box-arrow-in-down-right::before{content:"\f1bb"}.bi-box-arrow-in-down::before{content:"\f1bc"}.bi-box-arrow-in-left::before{content:"\f1bd"}.bi-box-arrow-in-right::before{content:"\f1be"}.bi-box-arrow-in-up-left::before{content:"\f1bf"}.bi-box-arrow-in-up-right::before{content:"\f1c0"}.bi-box-arrow-in-up::before{content:"\f1c1"}.bi-box-arrow-left::before{content:"\f1c2"}.bi-box-arrow-right::before{content:"\f1c3"}.bi-box-arrow-up-left::before{content:"\f1c4"}.bi-box-arrow-up-right::before{content:"\f1c5"}.bi-box-arrow-up::before{content:"\f1c6"}.bi-box-seam::before{content:"\f1c7"}.bi-box::before{content:"\f1c8"}.bi-braces::before{content:"\f1c9"}.bi-bricks::before{content:"\f1ca"}.bi-briefcase-fill::before{content:"\f1cb"}.bi-briefcase::before{content:"\f1cc"}.bi-brightness-alt-high-fill::before{content:"\f1cd"}.bi-brightness-alt-high::before{content:"\f1ce"}.bi-brightness-alt-low-fill::before{content:"\f1cf"}.bi-brightness-alt-low::before{content:"\f1d0"}.bi-brightness-high-fill::before{content:"\f1d1"}.bi-brightness-high::before{content:"\f1d2"}.bi-brightness-low-fill::before{content:"\f1d3"}.bi-brightness-low::before{content:"\f1d4"}.bi-broadcast-pin::before{content:"\f1d5"}.bi-broadcast::before{content:"\f1d6"}.bi-brush-fill::before{content:"\f1d7"}.bi-brush::before{content:"\f1d8"}.bi-bucket-fill::before{content:"\f1d9"}.bi-bucket::before{content:"\f1da"}.bi-bug-fill::before{content:"\f1db"}.bi-bug::before{content:"\f1dc"}.bi-building::before{content:"\f1dd"}.bi-bullseye::before{content:"\f1de"}.bi-calculator-fill::before{content:"\f1df"}.bi-calculator::before{content:"\f1e0"}.bi-calendar-check-fill::before{content:"\f1e1"}.bi-calendar-check::before{content:"\f1e2"}.bi-calendar-date-fill::before{content:"\f1e3"}.bi-calendar-date::before{content:"\f1e4"}.bi-calendar-day-fill::before{content:"\f1e5"}.bi-calendar-day::before{content:"\f1e6"}.bi-calendar-event-fill::before{content:"\f1e7"}.bi-calendar-event::before{content:"\f1e8"}.bi-calendar-fill::before{content:"\f1e9"}.bi-calendar-minus-fill::before{content:"\f1ea"}.bi-calendar-minus::before{content:"\f1eb"}.bi-calendar-month-fill::before{content:"\f1ec"}.bi-calendar-month::before{content:"\f1ed"}.bi-calendar-plus-fill::before{content:"\f1ee"}.bi-calendar-plus::before{content:"\f1ef"}.bi-calendar-range-fill::before{content:"\f1f0"}.bi-calendar-range::before{content:"\f1f1"}.bi-calendar-week-fill::before{content:"\f1f2"}.bi-calendar-week::before{content:"\f1f3"}.bi-calendar-x-fill::before{content:"\f1f4"}.bi-calendar-x::before{content:"\f1f5"}.bi-calendar::before{content:"\f1f6"}.bi-calendar2-check-fill::before{content:"\f1f7"}.bi-calendar2-check::before{content:"\f1f8"}.bi-calendar2-date-fill::before{content:"\f1f9"}.bi-calendar2-date::before{content:"\f1fa"}.bi-calendar2-day-fill::before{content:"\f1fb"}.bi-calendar2-day::before{content:"\f1fc"}.bi-calendar2-event-fill::before{content:"\f1fd"}.bi-calendar2-event::before{content:"\f1fe"}.bi-calendar2-fill::before{content:"\f1ff"}.bi-calendar2-minus-fill::before{content:"\f200"}.bi-calendar2-minus::before{content:"\f201"}.bi-calendar2-month-fill::before{content:"\f202"}.bi-calendar2-month::before{content:"\f203"}.bi-calendar2-plus-fill::before{content:"\f204"}.bi-calendar2-plus::before{content:"\f205"}.bi-calendar2-range-fill::before{content:"\f206"}.bi-calendar2-range::before{content:"\f207"}.bi-calendar2-week-fill::before{content:"\f208"}.bi-calendar2-week::before{content:"\f209"}.bi-calendar2-x-fill::before{content:"\f20a"}.bi-calendar2-x::before{content:"\f20b"}.bi-calendar2::before{content:"\f20c"}.bi-calendar3-event-fill::before{content:"\f20d"}.bi-calendar3-event::before{content:"\f20e"}.bi-calendar3-fill::before{content:"\f20f"}.bi-calendar3-range-fill::before{content:"\f210"}.bi-calendar3-range::before{content:"\f211"}.bi-calendar3-week-fill::before{content:"\f212"}.bi-calendar3-week::before{content:"\f213"}.bi-calendar3::before{content:"\f214"}.bi-calendar4-event::before{content:"\f215"}.bi-calendar4-range::before{content:"\f216"}.bi-calendar4-week::before{content:"\f217"}.bi-calendar4::before{content:"\f218"}.bi-camera-fill::before{content:"\f219"}.bi-camera-reels-fill::before{content:"\f21a"}.bi-camera-reels::before{content:"\f21b"}.bi-camera-video-fill::before{content:"\f21c"}.bi-camera-video-off-fill::before{content:"\f21d"}.bi-camera-video-off::before{content:"\f21e"}.bi-camera-video::before{content:"\f21f"}.bi-camera::before{content:"\f220"}.bi-camera2::before{content:"\f221"}.bi-capslock-fill::before{content:"\f222"}.bi-capslock::before{content:"\f223"}.bi-card-checklist::before{content:"\f224"}.bi-card-heading::before{content:"\f225"}.bi-card-image::before{content:"\f226"}.bi-card-list::before{content:"\f227"}.bi-card-text::before{content:"\f228"}.bi-caret-down-fill::before{content:"\f229"}.bi-caret-down-square-fill::before{content:"\f22a"}.bi-caret-down-square::before{content:"\f22b"}.bi-caret-down::before{content:"\f22c"}.bi-caret-left-fill::before{content:"\f22d"}.bi-caret-left-square-fill::before{content:"\f22e"}.bi-caret-left-square::before{content:"\f22f"}.bi-caret-left::before{content:"\f230"}.bi-caret-right-fill::before{content:"\f231"}.bi-caret-right-square-fill::before{content:"\f232"}.bi-caret-right-square::before{content:"\f233"}.bi-caret-right::before{content:"\f234"}.bi-caret-up-fill::before{content:"\f235"}.bi-caret-up-square-fill::before{content:"\f236"}.bi-caret-up-square::before{content:"\f237"}.bi-caret-up::before{content:"\f238"}.bi-cart-check-fill::before{content:"\f239"}.bi-cart-check::before{content:"\f23a"}.bi-cart-dash-fill::before{content:"\f23b"}.bi-cart-dash::before{content:"\f23c"}.bi-cart-fill::before{content:"\f23d"}.bi-cart-plus-fill::before{content:"\f23e"}.bi-cart-plus::before{content:"\f23f"}.bi-cart-x-fill::before{content:"\f240"}.bi-cart-x::before{content:"\f241"}.bi-cart::before{content:"\f242"}.bi-cart2::before{content:"\f243"}.bi-cart3::before{content:"\f244"}.bi-cart4::before{content:"\f245"}.bi-cash-stack::before{content:"\f246"}.bi-cash::before{content:"\f247"}.bi-cast::before{content:"\f248"}.bi-chat-dots-fill::before{content:"\f249"}.bi-chat-dots::before{content:"\f24a"}.bi-chat-fill::before{content:"\f24b"}.bi-chat-left-dots-fill::before{content:"\f24c"}.bi-chat-left-dots::before{content:"\f24d"}.bi-chat-left-fill::before{content:"\f24e"}.bi-chat-left-quote-fill::before{content:"\f24f"}.bi-chat-left-quote::before{content:"\f250"}.bi-chat-left-text-fill::before{content:"\f251"}.bi-chat-left-text::before{content:"\f252"}.bi-chat-left::before{content:"\f253"}.bi-chat-quote-fill::before{content:"\f254"}.bi-chat-quote::before{content:"\f255"}.bi-chat-right-dots-fill::before{content:"\f256"}.bi-chat-right-dots::before{content:"\f257"}.bi-chat-right-fill::before{content:"\f258"}.bi-chat-right-quote-fill::before{content:"\f259"}.bi-chat-right-quote::before{content:"\f25a"}.bi-chat-right-text-fill::before{content:"\f25b"}.bi-chat-right-text::before{content:"\f25c"}.bi-chat-right::before{content:"\f25d"}.bi-chat-square-dots-fill::before{content:"\f25e"}.bi-chat-square-dots::before{content:"\f25f"}.bi-chat-square-fill::before{content:"\f260"}.bi-chat-square-quote-fill::before{content:"\f261"}.bi-chat-square-quote::before{content:"\f262"}.bi-chat-square-text-fill::before{content:"\f263"}.bi-chat-square-text::before{content:"\f264"}.bi-chat-square::before{content:"\f265"}.bi-chat-text-fill::before{content:"\f266"}.bi-chat-text::before{content:"\f267"}.bi-chat::before{content:"\f268"}.bi-check-all::before{content:"\f269"}.bi-check-circle-fill::before{content:"\f26a"}.bi-check-circle::before{content:"\f26b"}.bi-check-square-fill::before{content:"\f26c"}.bi-check-square::before{content:"\f26d"}.bi-check::before{content:"\f26e"}.bi-check2-all::before{content:"\f26f"}.bi-check2-circle::before{content:"\f270"}.bi-check2-square::before{content:"\f271"}.bi-check2::before{content:"\f272"}.bi-chevron-bar-contract::before{content:"\f273"}.bi-chevron-bar-down::before{content:"\f274"}.bi-chevron-bar-expand::before{content:"\f275"}.bi-chevron-bar-left::before{content:"\f276"}.bi-chevron-bar-right::before{content:"\f277"}.bi-chevron-bar-up::before{content:"\f278"}.bi-chevron-compact-down::before{content:"\f279"}.bi-chevron-compact-left::before{content:"\f27a"}.bi-chevron-compact-right::before{content:"\f27b"}.bi-chevron-compact-up::before{content:"\f27c"}.bi-chevron-contract::before{content:"\f27d"}.bi-chevron-double-down::before{content:"\f27e"}.bi-chevron-double-left::before{content:"\f27f"}.bi-chevron-double-right::before{content:"\f280"}.bi-chevron-double-up::before{content:"\f281"}.bi-chevron-down::before{content:"\f282"}.bi-chevron-expand::before{content:"\f283"}.bi-chevron-left::before{content:"\f284"}.bi-chevron-right::before{content:"\f285"}.bi-chevron-up::before{content:"\f286"}.bi-circle-fill::before{content:"\f287"}.bi-circle-half::before{content:"\f288"}.bi-circle-square::before{content:"\f289"}.bi-circle::before{content:"\f28a"}.bi-clipboard-check::before{content:"\f28b"}.bi-clipboard-data::before{content:"\f28c"}.bi-clipboard-minus::before{content:"\f28d"}.bi-clipboard-plus::before{content:"\f28e"}.bi-clipboard-x::before{content:"\f28f"}.bi-clipboard::before{content:"\f290"}.bi-clock-fill::before{content:"\f291"}.bi-clock-history::before{content:"\f292"}.bi-clock::before{content:"\f293"}.bi-cloud-arrow-down-fill::before{content:"\f294"}.bi-cloud-arrow-down::before{content:"\f295"}.bi-cloud-arrow-up-fill::before{content:"\f296"}.bi-cloud-arrow-up::before{content:"\f297"}.bi-cloud-check-fill::before{content:"\f298"}.bi-cloud-check::before{content:"\f299"}.bi-cloud-download-fill::before{content:"\f29a"}.bi-cloud-download::before{content:"\f29b"}.bi-cloud-drizzle-fill::before{content:"\f29c"}.bi-cloud-drizzle::before{content:"\f29d"}.bi-cloud-fill::before{content:"\f29e"}.bi-cloud-fog-fill::before{content:"\f29f"}.bi-cloud-fog::before{content:"\f2a0"}.bi-cloud-fog2-fill::before{content:"\f2a1"}.bi-cloud-fog2::before{content:"\f2a2"}.bi-cloud-hail-fill::before{content:"\f2a3"}.bi-cloud-hail::before{content:"\f2a4"}.bi-cloud-haze-fill::before{content:"\f2a6"}.bi-cloud-haze::before{content:"\f2a7"}.bi-cloud-haze2-fill::before{content:"\f2a8"}.bi-cloud-lightning-fill::before{content:"\f2a9"}.bi-cloud-lightning-rain-fill::before{content:"\f2aa"}.bi-cloud-lightning-rain::before{content:"\f2ab"}.bi-cloud-lightning::before{content:"\f2ac"}.bi-cloud-minus-fill::before{content:"\f2ad"}.bi-cloud-minus::before{content:"\f2ae"}.bi-cloud-moon-fill::before{content:"\f2af"}.bi-cloud-moon::before{content:"\f2b0"}.bi-cloud-plus-fill::before{content:"\f2b1"}.bi-cloud-plus::before{content:"\f2b2"}.bi-cloud-rain-fill::before{content:"\f2b3"}.bi-cloud-rain-heavy-fill::before{content:"\f2b4"}.bi-cloud-rain-heavy::before{content:"\f2b5"}.bi-cloud-rain::before{content:"\f2b6"}.bi-cloud-slash-fill::before{content:"\f2b7"}.bi-cloud-slash::before{content:"\f2b8"}.bi-cloud-sleet-fill::before{content:"\f2b9"}.bi-cloud-sleet::before{content:"\f2ba"}.bi-cloud-snow-fill::before{content:"\f2bb"}.bi-cloud-snow::before{content:"\f2bc"}.bi-cloud-sun-fill::before{content:"\f2bd"}.bi-cloud-sun::before{content:"\f2be"}.bi-cloud-upload-fill::before{content:"\f2bf"}.bi-cloud-upload::before{content:"\f2c0"}.bi-cloud::before{content:"\f2c1"}.bi-clouds-fill::before{content:"\f2c2"}.bi-clouds::before{content:"\f2c3"}.bi-cloudy-fill::before{content:"\f2c4"}.bi-cloudy::before{content:"\f2c5"}.bi-code-slash::before{content:"\f2c6"}.bi-code-square::before{content:"\f2c7"}.bi-code::before{content:"\f2c8"}.bi-collection-fill::before{content:"\f2c9"}.bi-collection-play-fill::before{content:"\f2ca"}.bi-collection-play::before{content:"\f2cb"}.bi-collection::before{content:"\f2cc"}.bi-columns-gap::before{content:"\f2cd"}.bi-columns::before{content:"\f2ce"}.bi-command::before{content:"\f2cf"}.bi-compass-fill::before{content:"\f2d0"}.bi-compass::before{content:"\f2d1"}.bi-cone-striped::before{content:"\f2d2"}.bi-cone::before{content:"\f2d3"}.bi-controller::before{content:"\f2d4"}.bi-cpu-fill::before{content:"\f2d5"}.bi-cpu::before{content:"\f2d6"}.bi-credit-card-2-back-fill::before{content:"\f2d7"}.bi-credit-card-2-back::before{content:"\f2d8"}.bi-credit-card-2-front-fill::before{content:"\f2d9"}.bi-credit-card-2-front::before{content:"\f2da"}.bi-credit-card-fill::before{content:"\f2db"}.bi-credit-card::before{content:"\f2dc"}.bi-crop::before{content:"\f2dd"}.bi-cup-fill::before{content:"\f2de"}.bi-cup-straw::before{content:"\f2df"}.bi-cup::before{content:"\f2e0"}.bi-cursor-fill::before{content:"\f2e1"}.bi-cursor-text::before{content:"\f2e2"}.bi-cursor::before{content:"\f2e3"}.bi-dash-circle-dotted::before{content:"\f2e4"}.bi-dash-circle-fill::before{content:"\f2e5"}.bi-dash-circle::before{content:"\f2e6"}.bi-dash-square-dotted::before{content:"\f2e7"}.bi-dash-square-fill::before{content:"\f2e8"}.bi-dash-square::before{content:"\f2e9"}.bi-dash::before{content:"\f2ea"}.bi-diagram-2-fill::before{content:"\f2eb"}.bi-diagram-2::before{content:"\f2ec"}.bi-diagram-3-fill::before{content:"\f2ed"}.bi-diagram-3::before{content:"\f2ee"}.bi-diamond-fill::before{content:"\f2ef"}.bi-diamond-half::before{content:"\f2f0"}.bi-diamond::before{content:"\f2f1"}.bi-dice-1-fill::before{content:"\f2f2"}.bi-dice-1::before{content:"\f2f3"}.bi-dice-2-fill::before{content:"\f2f4"}.bi-dice-2::before{content:"\f2f5"}.bi-dice-3-fill::before{content:"\f2f6"}.bi-dice-3::before{content:"\f2f7"}.bi-dice-4-fill::before{content:"\f2f8"}.bi-dice-4::before{content:"\f2f9"}.bi-dice-5-fill::before{content:"\f2fa"}.bi-dice-5::before{content:"\f2fb"}.bi-dice-6-fill::before{content:"\f2fc"}.bi-dice-6::before{content:"\f2fd"}.bi-disc-fill::before{content:"\f2fe"}.bi-disc::before{content:"\f2ff"}.bi-discord::before{content:"\f300"}.bi-display-fill::before{content:"\f301"}.bi-display::before{content:"\f302"}.bi-distribute-horizontal::before{content:"\f303"}.bi-distribute-vertical::before{content:"\f304"}.bi-door-closed-fill::before{content:"\f305"}.bi-door-closed::before{content:"\f306"}.bi-door-open-fill::before{content:"\f307"}.bi-door-open::before{content:"\f308"}.bi-dot::before{content:"\f309"}.bi-download::before{content:"\f30a"}.bi-droplet-fill::before{content:"\f30b"}.bi-droplet-half::before{content:"\f30c"}.bi-droplet::before{content:"\f30d"}.bi-earbuds::before{content:"\f30e"}.bi-easel-fill::before{content:"\f30f"}.bi-easel::before{content:"\f310"}.bi-egg-fill::before{content:"\f311"}.bi-egg-fried::before{content:"\f312"}.bi-egg::before{content:"\f313"}.bi-eject-fill::before{content:"\f314"}.bi-eject::before{content:"\f315"}.bi-emoji-angry-fill::before{content:"\f316"}.bi-emoji-angry::before{content:"\f317"}.bi-emoji-dizzy-fill::before{content:"\f318"}.bi-emoji-dizzy::before{content:"\f319"}.bi-emoji-expressionless-fill::before{content:"\f31a"}.bi-emoji-expressionless::before{content:"\f31b"}.bi-emoji-frown-fill::before{content:"\f31c"}.bi-emoji-frown::before{content:"\f31d"}.bi-emoji-heart-eyes-fill::before{content:"\f31e"}.bi-emoji-heart-eyes::before{content:"\f31f"}.bi-emoji-laughing-fill::before{content:"\f320"}.bi-emoji-laughing::before{content:"\f321"}.bi-emoji-neutral-fill::before{content:"\f322"}.bi-emoji-neutral::before{content:"\f323"}.bi-emoji-smile-fill::before{content:"\f324"}.bi-emoji-smile-upside-down-fill::before{content:"\f325"}.bi-emoji-smile-upside-down::before{content:"\f326"}.bi-emoji-smile::before{content:"\f327"}.bi-emoji-sunglasses-fill::before{content:"\f328"}.bi-emoji-sunglasses::before{content:"\f329"}.bi-emoji-wink-fill::before{content:"\f32a"}.bi-emoji-wink::before{content:"\f32b"}.bi-envelope-fill::before{content:"\f32c"}.bi-envelope-open-fill::before{content:"\f32d"}.bi-envelope-open::before{content:"\f32e"}.bi-envelope::before{content:"\f32f"}.bi-eraser-fill::before{content:"\f330"}.bi-eraser::before{content:"\f331"}.bi-exclamation-circle-fill::before{content:"\f332"}.bi-exclamation-circle::before{content:"\f333"}.bi-exclamation-diamond-fill::before{content:"\f334"}.bi-exclamation-diamond::before{content:"\f335"}.bi-exclamation-octagon-fill::before{content:"\f336"}.bi-exclamation-octagon::before{content:"\f337"}.bi-exclamation-square-fill::before{content:"\f338"}.bi-exclamation-square::before{content:"\f339"}.bi-exclamation-triangle-fill::before{content:"\f33a"}.bi-exclamation-triangle::before{content:"\f33b"}.bi-exclamation::before{content:"\f33c"}.bi-exclude::before{content:"\f33d"}.bi-eye-fill::before{content:"\f33e"}.bi-eye-slash-fill::before{content:"\f33f"}.bi-eye-slash::before{content:"\f340"}.bi-eye::before{content:"\f341"}.bi-eyedropper::before{content:"\f342"}.bi-eyeglasses::before{content:"\f343"}.bi-facebook::before{content:"\f344"}.bi-file-arrow-down-fill::before{content:"\f345"}.bi-file-arrow-down::before{content:"\f346"}.bi-file-arrow-up-fill::before{content:"\f347"}.bi-file-arrow-up::before{content:"\f348"}.bi-file-bar-graph-fill::before{content:"\f349"}.bi-file-bar-graph::before{content:"\f34a"}.bi-file-binary-fill::before{content:"\f34b"}.bi-file-binary::before{content:"\f34c"}.bi-file-break-fill::before{content:"\f34d"}.bi-file-break::before{content:"\f34e"}.bi-file-check-fill::before{content:"\f34f"}.bi-file-check::before{content:"\f350"}.bi-file-code-fill::before{content:"\f351"}.bi-file-code::before{content:"\f352"}.bi-file-diff-fill::before{content:"\f353"}.bi-file-diff::before{content:"\f354"}.bi-file-earmark-arrow-down-fill::before{content:"\f355"}.bi-file-earmark-arrow-down::before{content:"\f356"}.bi-file-earmark-arrow-up-fill::before{content:"\f357"}.bi-file-earmark-arrow-up::before{content:"\f358"}.bi-file-earmark-bar-graph-fill::before{content:"\f359"}.bi-file-earmark-bar-graph::before{content:"\f35a"}.bi-file-earmark-binary-fill::before{content:"\f35b"}.bi-file-earmark-binary::before{content:"\f35c"}.bi-file-earmark-break-fill::before{content:"\f35d"}.bi-file-earmark-break::before{content:"\f35e"}.bi-file-earmark-check-fill::before{content:"\f35f"}.bi-file-earmark-check::before{content:"\f360"}.bi-file-earmark-code-fill::before{content:"\f361"}.bi-file-earmark-code::before{content:"\f362"}.bi-file-earmark-diff-fill::before{content:"\f363"}.bi-file-earmark-diff::before{content:"\f364"}.bi-file-earmark-easel-fill::before{content:"\f365"}.bi-file-earmark-easel::before{content:"\f366"}.bi-file-earmark-excel-fill::before{content:"\f367"}.bi-file-earmark-excel::before{content:"\f368"}.bi-file-earmark-fill::before{content:"\f369"}.bi-file-earmark-font-fill::before{content:"\f36a"}.bi-file-earmark-font::before{content:"\f36b"}.bi-file-earmark-image-fill::before{content:"\f36c"}.bi-file-earmark-image::before{content:"\f36d"}.bi-file-earmark-lock-fill::before{content:"\f36e"}.bi-file-earmark-lock::before{content:"\f36f"}.bi-file-earmark-lock2-fill::before{content:"\f370"}.bi-file-earmark-lock2::before{content:"\f371"}.bi-file-earmark-medical-fill::before{content:"\f372"}.bi-file-earmark-medical::before{content:"\f373"}.bi-file-earmark-minus-fill::before{content:"\f374"}.bi-file-earmark-minus::before{content:"\f375"}.bi-file-earmark-music-fill::before{content:"\f376"}.bi-file-earmark-music::before{content:"\f377"}.bi-file-earmark-person-fill::before{content:"\f378"}.bi-file-earmark-person::before{content:"\f379"}.bi-file-earmark-play-fill::before{content:"\f37a"}.bi-file-earmark-play::before{content:"\f37b"}.bi-file-earmark-plus-fill::before{content:"\f37c"}.bi-file-earmark-plus::before{content:"\f37d"}.bi-file-earmark-post-fill::before{content:"\f37e"}.bi-file-earmark-post::before{content:"\f37f"}.bi-file-earmark-ppt-fill::before{content:"\f380"}.bi-file-earmark-ppt::before{content:"\f381"}.bi-file-earmark-richtext-fill::before{content:"\f382"}.bi-file-earmark-richtext::before{content:"\f383"}.bi-file-earmark-ruled-fill::before{content:"\f384"}.bi-file-earmark-ruled::before{content:"\f385"}.bi-file-earmark-slides-fill::before{content:"\f386"}.bi-file-earmark-slides::before{content:"\f387"}.bi-file-earmark-spreadsheet-fill::before{content:"\f388"}.bi-file-earmark-spreadsheet::before{content:"\f389"}.bi-file-earmark-text-fill::before{content:"\f38a"}.bi-file-earmark-text::before{content:"\f38b"}.bi-file-earmark-word-fill::before{content:"\f38c"}.bi-file-earmark-word::before{content:"\f38d"}.bi-file-earmark-x-fill::before{content:"\f38e"}.bi-file-earmark-x::before{content:"\f38f"}.bi-file-earmark-zip-fill::before{content:"\f390"}.bi-file-earmark-zip::before{content:"\f391"}.bi-file-earmark::before{content:"\f392"}.bi-file-easel-fill::before{content:"\f393"}.bi-file-easel::before{content:"\f394"}.bi-file-excel-fill::before{content:"\f395"}.bi-file-excel::before{content:"\f396"}.bi-file-fill::before{content:"\f397"}.bi-file-font-fill::before{content:"\f398"}.bi-file-font::before{content:"\f399"}.bi-file-image-fill::before{content:"\f39a"}.bi-file-image::before{content:"\f39b"}.bi-file-lock-fill::before{content:"\f39c"}.bi-file-lock::before{content:"\f39d"}.bi-file-lock2-fill::before{content:"\f39e"}.bi-file-lock2::before{content:"\f39f"}.bi-file-medical-fill::before{content:"\f3a0"}.bi-file-medical::before{content:"\f3a1"}.bi-file-minus-fill::before{content:"\f3a2"}.bi-file-minus::before{content:"\f3a3"}.bi-file-music-fill::before{content:"\f3a4"}.bi-file-music::before{content:"\f3a5"}.bi-file-person-fill::before{content:"\f3a6"}.bi-file-person::before{content:"\f3a7"}.bi-file-play-fill::before{content:"\f3a8"}.bi-file-play::before{content:"\f3a9"}.bi-file-plus-fill::before{content:"\f3aa"}.bi-file-plus::before{content:"\f3ab"}.bi-file-post-fill::before{content:"\f3ac"}.bi-file-post::before{content:"\f3ad"}.bi-file-ppt-fill::before{content:"\f3ae"}.bi-file-ppt::before{content:"\f3af"}.bi-file-richtext-fill::before{content:"\f3b0"}.bi-file-richtext::before{content:"\f3b1"}.bi-file-ruled-fill::before{content:"\f3b2"}.bi-file-ruled::before{content:"\f3b3"}.bi-file-slides-fill::before{content:"\f3b4"}.bi-file-slides::before{content:"\f3b5"}.bi-file-spreadsheet-fill::before{content:"\f3b6"}.bi-file-spreadsheet::before{content:"\f3b7"}.bi-file-text-fill::before{content:"\f3b8"}.bi-file-text::before{content:"\f3b9"}.bi-file-word-fill::before{content:"\f3ba"}.bi-file-word::before{content:"\f3bb"}.bi-file-x-fill::before{content:"\f3bc"}.bi-file-x::before{content:"\f3bd"}.bi-file-zip-fill::before{content:"\f3be"}.bi-file-zip::before{content:"\f3bf"}.bi-file::before{content:"\f3c0"}.bi-files-alt::before{content:"\f3c1"}.bi-files::before{content:"\f3c2"}.bi-film::before{content:"\f3c3"}.bi-filter-circle-fill::before{content:"\f3c4"}.bi-filter-circle::before{content:"\f3c5"}.bi-filter-left::before{content:"\f3c6"}.bi-filter-right::before{content:"\f3c7"}.bi-filter-square-fill::before{content:"\f3c8"}.bi-filter-square::before{content:"\f3c9"}.bi-filter::before{content:"\f3ca"}.bi-flag-fill::before{content:"\f3cb"}.bi-flag::before{content:"\f3cc"}.bi-flower1::before{content:"\f3cd"}.bi-flower2::before{content:"\f3ce"}.bi-flower3::before{content:"\f3cf"}.bi-folder-check::before{content:"\f3d0"}.bi-folder-fill::before{content:"\f3d1"}.bi-folder-minus::before{content:"\f3d2"}.bi-folder-plus::before{content:"\f3d3"}.bi-folder-symlink-fill::before{content:"\f3d4"}.bi-folder-symlink::before{content:"\f3d5"}.bi-folder-x::before{content:"\f3d6"}.bi-folder::before{content:"\f3d7"}.bi-folder2-open::before{content:"\f3d8"}.bi-folder2::before{content:"\f3d9"}.bi-fonts::before{content:"\f3da"}.bi-forward-fill::before{content:"\f3db"}.bi-forward::before{content:"\f3dc"}.bi-front::before{content:"\f3dd"}.bi-fullscreen-exit::before{content:"\f3de"}.bi-fullscreen::before{content:"\f3df"}.bi-funnel-fill::before{content:"\f3e0"}.bi-funnel::before{content:"\f3e1"}.bi-gear-fill::before{content:"\f3e2"}.bi-gear-wide-connected::before{content:"\f3e3"}.bi-gear-wide::before{content:"\f3e4"}.bi-gear::before{content:"\f3e5"}.bi-gem::before{content:"\f3e6"}.bi-geo-alt-fill::before{content:"\f3e7"}.bi-geo-alt::before{content:"\f3e8"}.bi-geo-fill::before{content:"\f3e9"}.bi-geo::before{content:"\f3ea"}.bi-gift-fill::before{content:"\f3eb"}.bi-gift::before{content:"\f3ec"}.bi-github::before{content:"\f3ed"}.bi-globe::before{content:"\f3ee"}.bi-globe2::before{content:"\f3ef"}.bi-google::before{content:"\f3f0"}.bi-graph-down::before{content:"\f3f1"}.bi-graph-up::before{content:"\f3f2"}.bi-grid-1x2-fill::before{content:"\f3f3"}.bi-grid-1x2::before{content:"\f3f4"}.bi-grid-3x2-gap-fill::before{content:"\f3f5"}.bi-grid-3x2-gap::before{content:"\f3f6"}.bi-grid-3x2::before{content:"\f3f7"}.bi-grid-3x3-gap-fill::before{content:"\f3f8"}.bi-grid-3x3-gap::before{content:"\f3f9"}.bi-grid-3x3::before{content:"\f3fa"}.bi-grid-fill::before{content:"\f3fb"}.bi-grid::before{content:"\f3fc"}.bi-grip-horizontal::before{content:"\f3fd"}.bi-grip-vertical::before{content:"\f3fe"}.bi-hammer::before{content:"\f3ff"}.bi-hand-index-fill::before{content:"\f400"}.bi-hand-index-thumb-fill::before{content:"\f401"}.bi-hand-index-thumb::before{content:"\f402"}.bi-hand-index::before{content:"\f403"}.bi-hand-thumbs-down-fill::before{content:"\f404"}.bi-hand-thumbs-down::before{content:"\f405"}.bi-hand-thumbs-up-fill::before{content:"\f406"}.bi-hand-thumbs-up::before{content:"\f407"}.bi-handbag-fill::before{content:"\f408"}.bi-handbag::before{content:"\f409"}.bi-hash::before{content:"\f40a"}.bi-hdd-fill::before{content:"\f40b"}.bi-hdd-network-fill::before{content:"\f40c"}.bi-hdd-network::before{content:"\f40d"}.bi-hdd-rack-fill::before{content:"\f40e"}.bi-hdd-rack::before{content:"\f40f"}.bi-hdd-stack-fill::before{content:"\f410"}.bi-hdd-stack::before{content:"\f411"}.bi-hdd::before{content:"\f412"}.bi-headphones::before{content:"\f413"}.bi-headset::before{content:"\f414"}.bi-heart-fill::before{content:"\f415"}.bi-heart-half::before{content:"\f416"}.bi-heart::before{content:"\f417"}.bi-heptagon-fill::before{content:"\f418"}.bi-heptagon-half::before{content:"\f419"}.bi-heptagon::before{content:"\f41a"}.bi-hexagon-fill::before{content:"\f41b"}.bi-hexagon-half::before{content:"\f41c"}.bi-hexagon::before{content:"\f41d"}.bi-hourglass-bottom::before{content:"\f41e"}.bi-hourglass-split::before{content:"\f41f"}.bi-hourglass-top::before{content:"\f420"}.bi-hourglass::before{content:"\f421"}.bi-house-door-fill::before{content:"\f422"}.bi-house-door::before{content:"\f423"}.bi-house-fill::before{content:"\f424"}.bi-house::before{content:"\f425"}.bi-hr::before{content:"\f426"}.bi-hurricane::before{content:"\f427"}.bi-image-alt::before{content:"\f428"}.bi-image-fill::before{content:"\f429"}.bi-image::before{content:"\f42a"}.bi-images::before{content:"\f42b"}.bi-inbox-fill::before{content:"\f42c"}.bi-inbox::before{content:"\f42d"}.bi-inboxes-fill::before{content:"\f42e"}.bi-inboxes::before{content:"\f42f"}.bi-info-circle-fill::before{content:"\f430"}.bi-info-circle::before{content:"\f431"}.bi-info-square-fill::before{content:"\f432"}.bi-info-square::before{content:"\f433"}.bi-info::before{content:"\f434"}.bi-input-cursor-text::before{content:"\f435"}.bi-input-cursor::before{content:"\f436"}.bi-instagram::before{content:"\f437"}.bi-intersect::before{content:"\f438"}.bi-journal-album::before{content:"\f439"}.bi-journal-arrow-down::before{content:"\f43a"}.bi-journal-arrow-up::before{content:"\f43b"}.bi-journal-bookmark-fill::before{content:"\f43c"}.bi-journal-bookmark::before{content:"\f43d"}.bi-journal-check::before{content:"\f43e"}.bi-journal-code::before{content:"\f43f"}.bi-journal-medical::before{content:"\f440"}.bi-journal-minus::before{content:"\f441"}.bi-journal-plus::before{content:"\f442"}.bi-journal-richtext::before{content:"\f443"}.bi-journal-text::before{content:"\f444"}.bi-journal-x::before{content:"\f445"}.bi-journal::before{content:"\f446"}.bi-journals::before{content:"\f447"}.bi-joystick::before{content:"\f448"}.bi-justify-left::before{content:"\f449"}.bi-justify-right::before{content:"\f44a"}.bi-justify::before{content:"\f44b"}.bi-kanban-fill::before{content:"\f44c"}.bi-kanban::before{content:"\f44d"}.bi-key-fill::before{content:"\f44e"}.bi-key::before{content:"\f44f"}.bi-keyboard-fill::before{content:"\f450"}.bi-keyboard::before{content:"\f451"}.bi-ladder::before{content:"\f452"}.bi-lamp-fill::before{content:"\f453"}.bi-lamp::before{content:"\f454"}.bi-laptop-fill::before{content:"\f455"}.bi-laptop::before{content:"\f456"}.bi-layer-backward::before{content:"\f457"}.bi-layer-forward::before{content:"\f458"}.bi-layers-fill::before{content:"\f459"}.bi-layers-half::before{content:"\f45a"}.bi-layers::before{content:"\f45b"}.bi-layout-sidebar-inset-reverse::before{content:"\f45c"}.bi-layout-sidebar-inset::before{content:"\f45d"}.bi-layout-sidebar-reverse::before{content:"\f45e"}.bi-layout-sidebar::before{content:"\f45f"}.bi-layout-split::before{content:"\f460"}.bi-layout-text-sidebar-reverse::before{content:"\f461"}.bi-layout-text-sidebar::before{content:"\f462"}.bi-layout-text-window-reverse::before{content:"\f463"}.bi-layout-text-window::before{content:"\f464"}.bi-layout-three-columns::before{content:"\f465"}.bi-layout-wtf::before{content:"\f466"}.bi-life-preserver::before{content:"\f467"}.bi-lightbulb-fill::before{content:"\f468"}.bi-lightbulb-off-fill::before{content:"\f469"}.bi-lightbulb-off::before{content:"\f46a"}.bi-lightbulb::before{content:"\f46b"}.bi-lightning-charge-fill::before{content:"\f46c"}.bi-lightning-charge::before{content:"\f46d"}.bi-lightning-fill::before{content:"\f46e"}.bi-lightning::before{content:"\f46f"}.bi-link-45deg::before{content:"\f470"}.bi-link::before{content:"\f471"}.bi-linkedin::before{content:"\f472"}.bi-list-check::before{content:"\f473"}.bi-list-nested::before{content:"\f474"}.bi-list-ol::before{content:"\f475"}.bi-list-stars::before{content:"\f476"}.bi-list-task::before{content:"\f477"}.bi-list-ul::before{content:"\f478"}.bi-list::before{content:"\f479"}.bi-lock-fill::before{content:"\f47a"}.bi-lock::before{content:"\f47b"}.bi-mailbox::before{content:"\f47c"}.bi-mailbox2::before{content:"\f47d"}.bi-map-fill::before{content:"\f47e"}.bi-map::before{content:"\f47f"}.bi-markdown-fill::before{content:"\f480"}.bi-markdown::before{content:"\f481"}.bi-mask::before{content:"\f482"}.bi-megaphone-fill::before{content:"\f483"}.bi-megaphone::before{content:"\f484"}.bi-menu-app-fill::before{content:"\f485"}.bi-menu-app::before{content:"\f486"}.bi-menu-button-fill::before{content:"\f487"}.bi-menu-button-wide-fill::before{content:"\f488"}.bi-menu-button-wide::before{content:"\f489"}.bi-menu-button::before{content:"\f48a"}.bi-menu-down::before{content:"\f48b"}.bi-menu-up::before{content:"\f48c"}.bi-mic-fill::before{content:"\f48d"}.bi-mic-mute-fill::before{content:"\f48e"}.bi-mic-mute::before{content:"\f48f"}.bi-mic::before{content:"\f490"}.bi-minecart-loaded::before{content:"\f491"}.bi-minecart::before{content:"\f492"}.bi-moisture::before{content:"\f493"}.bi-moon-fill::before{content:"\f494"}.bi-moon-stars-fill::before{content:"\f495"}.bi-moon-stars::before{content:"\f496"}.bi-moon::before{content:"\f497"}.bi-mouse-fill::before{content:"\f498"}.bi-mouse::before{content:"\f499"}.bi-mouse2-fill::before{content:"\f49a"}.bi-mouse2::before{content:"\f49b"}.bi-mouse3-fill::before{content:"\f49c"}.bi-mouse3::before{content:"\f49d"}.bi-music-note-beamed::before{content:"\f49e"}.bi-music-note-list::before{content:"\f49f"}.bi-music-note::before{content:"\f4a0"}.bi-music-player-fill::before{content:"\f4a1"}.bi-music-player::before{content:"\f4a2"}.bi-newspaper::before{content:"\f4a3"}.bi-node-minus-fill::before{content:"\f4a4"}.bi-node-minus::before{content:"\f4a5"}.bi-node-plus-fill::before{content:"\f4a6"}.bi-node-plus::before{content:"\f4a7"}.bi-nut-fill::before{content:"\f4a8"}.bi-nut::before{content:"\f4a9"}.bi-octagon-fill::before{content:"\f4aa"}.bi-octagon-half::before{content:"\f4ab"}.bi-octagon::before{content:"\f4ac"}.bi-option::before{content:"\f4ad"}.bi-outlet::before{content:"\f4ae"}.bi-paint-bucket::before{content:"\f4af"}.bi-palette-fill::before{content:"\f4b0"}.bi-palette::before{content:"\f4b1"}.bi-palette2::before{content:"\f4b2"}.bi-paperclip::before{content:"\f4b3"}.bi-paragraph::before{content:"\f4b4"}.bi-patch-check-fill::before{content:"\f4b5"}.bi-patch-check::before{content:"\f4b6"}.bi-patch-exclamation-fill::before{content:"\f4b7"}.bi-patch-exclamation::before{content:"\f4b8"}.bi-patch-minus-fill::before{content:"\f4b9"}.bi-patch-minus::before{content:"\f4ba"}.bi-patch-plus-fill::before{content:"\f4bb"}.bi-patch-plus::before{content:"\f4bc"}.bi-patch-question-fill::before{content:"\f4bd"}.bi-patch-question::before{content:"\f4be"}.bi-pause-btn-fill::before{content:"\f4bf"}.bi-pause-btn::before{content:"\f4c0"}.bi-pause-circle-fill::before{content:"\f4c1"}.bi-pause-circle::before{content:"\f4c2"}.bi-pause-fill::before{content:"\f4c3"}.bi-pause::before{content:"\f4c4"}.bi-peace-fill::before{content:"\f4c5"}.bi-peace::before{content:"\f4c6"}.bi-pen-fill::before{content:"\f4c7"}.bi-pen::before{content:"\f4c8"}.bi-pencil-fill::before{content:"\f4c9"}.bi-pencil-square::before{content:"\f4ca"}.bi-pencil::before{content:"\f4cb"}.bi-pentagon-fill::before{content:"\f4cc"}.bi-pentagon-half::before{content:"\f4cd"}.bi-pentagon::before{content:"\f4ce"}.bi-people-fill::before{content:"\f4cf"}.bi-people::before{content:"\f4d0"}.bi-percent::before{content:"\f4d1"}.bi-person-badge-fill::before{content:"\f4d2"}.bi-person-badge::before{content:"\f4d3"}.bi-person-bounding-box::before{content:"\f4d4"}.bi-person-check-fill::before{content:"\f4d5"}.bi-person-check::before{content:"\f4d6"}.bi-person-circle::before{content:"\f4d7"}.bi-person-dash-fill::before{content:"\f4d8"}.bi-person-dash::before{content:"\f4d9"}.bi-person-fill::before{content:"\f4da"}.bi-person-lines-fill::before{content:"\f4db"}.bi-person-plus-fill::before{content:"\f4dc"}.bi-person-plus::before{content:"\f4dd"}.bi-person-square::before{content:"\f4de"}.bi-person-x-fill::before{content:"\f4df"}.bi-person-x::before{content:"\f4e0"}.bi-person::before{content:"\f4e1"}.bi-phone-fill::before{content:"\f4e2"}.bi-phone-landscape-fill::before{content:"\f4e3"}.bi-phone-landscape::before{content:"\f4e4"}.bi-phone-vibrate-fill::before{content:"\f4e5"}.bi-phone-vibrate::before{content:"\f4e6"}.bi-phone::before{content:"\f4e7"}.bi-pie-chart-fill::before{content:"\f4e8"}.bi-pie-chart::before{content:"\f4e9"}.bi-pin-angle-fill::before{content:"\f4ea"}.bi-pin-angle::before{content:"\f4eb"}.bi-pin-fill::before{content:"\f4ec"}.bi-pin::before{content:"\f4ed"}.bi-pip-fill::before{content:"\f4ee"}.bi-pip::before{content:"\f4ef"}.bi-play-btn-fill::before{content:"\f4f0"}.bi-play-btn::before{content:"\f4f1"}.bi-play-circle-fill::before{content:"\f4f2"}.bi-play-circle::before{content:"\f4f3"}.bi-play-fill::before{content:"\f4f4"}.bi-play::before{content:"\f4f5"}.bi-plug-fill::before{content:"\f4f6"}.bi-plug::before{content:"\f4f7"}.bi-plus-circle-dotted::before{content:"\f4f8"}.bi-plus-circle-fill::before{content:"\f4f9"}.bi-plus-circle::before{content:"\f4fa"}.bi-plus-square-dotted::before{content:"\f4fb"}.bi-plus-square-fill::before{content:"\f4fc"}.bi-plus-square::before{content:"\f4fd"}.bi-plus::before{content:"\f4fe"}.bi-power::before{content:"\f4ff"}.bi-printer-fill::before{content:"\f500"}.bi-printer::before{content:"\f501"}.bi-puzzle-fill::before{content:"\f502"}.bi-puzzle::before{content:"\f503"}.bi-question-circle-fill::before{content:"\f504"}.bi-question-circle::before{content:"\f505"}.bi-question-diamond-fill::before{content:"\f506"}.bi-question-diamond::before{content:"\f507"}.bi-question-octagon-fill::before{content:"\f508"}.bi-question-octagon::before{content:"\f509"}.bi-question-square-fill::before{content:"\f50a"}.bi-question-square::before{content:"\f50b"}.bi-question::before{content:"\f50c"}.bi-rainbow::before{content:"\f50d"}.bi-receipt-cutoff::before{content:"\f50e"}.bi-receipt::before{content:"\f50f"}.bi-reception-0::before{content:"\f510"}.bi-reception-1::before{content:"\f511"}.bi-reception-2::before{content:"\f512"}.bi-reception-3::before{content:"\f513"}.bi-reception-4::before{content:"\f514"}.bi-record-btn-fill::before{content:"\f515"}.bi-record-btn::before{content:"\f516"}.bi-record-circle-fill::before{content:"\f517"}.bi-record-circle::before{content:"\f518"}.bi-record-fill::before{content:"\f519"}.bi-record::before{content:"\f51a"}.bi-record2-fill::before{content:"\f51b"}.bi-record2::before{content:"\f51c"}.bi-reply-all-fill::before{content:"\f51d"}.bi-reply-all::before{content:"\f51e"}.bi-reply-fill::before{content:"\f51f"}.bi-reply::before{content:"\f520"}.bi-rss-fill::before{content:"\f521"}.bi-rss::before{content:"\f522"}.bi-rulers::before{content:"\f523"}.bi-save-fill::before{content:"\f524"}.bi-save::before{content:"\f525"}.bi-save2-fill::before{content:"\f526"}.bi-save2::before{content:"\f527"}.bi-scissors::before{content:"\f528"}.bi-screwdriver::before{content:"\f529"}.bi-search::before{content:"\f52a"}.bi-segmented-nav::before{content:"\f52b"}.bi-server::before{content:"\f52c"}.bi-share-fill::before{content:"\f52d"}.bi-share::before{content:"\f52e"}.bi-shield-check::before{content:"\f52f"}.bi-shield-exclamation::before{content:"\f530"}.bi-shield-fill-check::before{content:"\f531"}.bi-shield-fill-exclamation::before{content:"\f532"}.bi-shield-fill-minus::before{content:"\f533"}.bi-shield-fill-plus::before{content:"\f534"}.bi-shield-fill-x::before{content:"\f535"}.bi-shield-fill::before{content:"\f536"}.bi-shield-lock-fill::before{content:"\f537"}.bi-shield-lock::before{content:"\f538"}.bi-shield-minus::before{content:"\f539"}.bi-shield-plus::before{content:"\f53a"}.bi-shield-shaded::before{content:"\f53b"}.bi-shield-slash-fill::before{content:"\f53c"}.bi-shield-slash::before{content:"\f53d"}.bi-shield-x::before{content:"\f53e"}.bi-shield::before{content:"\f53f"}.bi-shift-fill::before{content:"\f540"}.bi-shift::before{content:"\f541"}.bi-shop-window::before{content:"\f542"}.bi-shop::before{content:"\f543"}.bi-shuffle::before{content:"\f544"}.bi-signpost-2-fill::before{content:"\f545"}.bi-signpost-2::before{content:"\f546"}.bi-signpost-fill::before{content:"\f547"}.bi-signpost-split-fill::before{content:"\f548"}.bi-signpost-split::before{content:"\f549"}.bi-signpost::before{content:"\f54a"}.bi-sim-fill::before{content:"\f54b"}.bi-sim::before{content:"\f54c"}.bi-skip-backward-btn-fill::before{content:"\f54d"}.bi-skip-backward-btn::before{content:"\f54e"}.bi-skip-backward-circle-fill::before{content:"\f54f"}.bi-skip-backward-circle::before{content:"\f550"}.bi-skip-backward-fill::before{content:"\f551"}.bi-skip-backward::before{content:"\f552"}.bi-skip-end-btn-fill::before{content:"\f553"}.bi-skip-end-btn::before{content:"\f554"}.bi-skip-end-circle-fill::before{content:"\f555"}.bi-skip-end-circle::before{content:"\f556"}.bi-skip-end-fill::before{content:"\f557"}.bi-skip-end::before{content:"\f558"}.bi-skip-forward-btn-fill::before{content:"\f559"}.bi-skip-forward-btn::before{content:"\f55a"}.bi-skip-forward-circle-fill::before{content:"\f55b"}.bi-skip-forward-circle::before{content:"\f55c"}.bi-skip-forward-fill::before{content:"\f55d"}.bi-skip-forward::before{content:"\f55e"}.bi-skip-start-btn-fill::before{content:"\f55f"}.bi-skip-start-btn::before{content:"\f560"}.bi-skip-start-circle-fill::before{content:"\f561"}.bi-skip-start-circle::before{content:"\f562"}.bi-skip-start-fill::before{content:"\f563"}.bi-skip-start::before{content:"\f564"}.bi-slack::before{content:"\f565"}.bi-slash-circle-fill::before{content:"\f566"}.bi-slash-circle::before{content:"\f567"}.bi-slash-square-fill::before{content:"\f568"}.bi-slash-square::before{content:"\f569"}.bi-slash::before{content:"\f56a"}.bi-sliders::before{content:"\f56b"}.bi-smartwatch::before{content:"\f56c"}.bi-snow::before{content:"\f56d"}.bi-snow2::before{content:"\f56e"}.bi-snow3::before{content:"\f56f"}.bi-sort-alpha-down-alt::before{content:"\f570"}.bi-sort-alpha-down::before{content:"\f571"}.bi-sort-alpha-up-alt::before{content:"\f572"}.bi-sort-alpha-up::before{content:"\f573"}.bi-sort-down-alt::before{content:"\f574"}.bi-sort-down::before{content:"\f575"}.bi-sort-numeric-down-alt::before{content:"\f576"}.bi-sort-numeric-down::before{content:"\f577"}.bi-sort-numeric-up-alt::before{content:"\f578"}.bi-sort-numeric-up::before{content:"\f579"}.bi-sort-up-alt::before{content:"\f57a"}.bi-sort-up::before{content:"\f57b"}.bi-soundwave::before{content:"\f57c"}.bi-speaker-fill::before{content:"\f57d"}.bi-speaker::before{content:"\f57e"}.bi-speedometer::before{content:"\f57f"}.bi-speedometer2::before{content:"\f580"}.bi-spellcheck::before{content:"\f581"}.bi-square-fill::before{content:"\f582"}.bi-square-half::before{content:"\f583"}.bi-square::before{content:"\f584"}.bi-stack::before{content:"\f585"}.bi-star-fill::before{content:"\f586"}.bi-star-half::before{content:"\f587"}.bi-star::before{content:"\f588"}.bi-stars::before{content:"\f589"}.bi-stickies-fill::before{content:"\f58a"}.bi-stickies::before{content:"\f58b"}.bi-sticky-fill::before{content:"\f58c"}.bi-sticky::before{content:"\f58d"}.bi-stop-btn-fill::before{content:"\f58e"}.bi-stop-btn::before{content:"\f58f"}.bi-stop-circle-fill::before{content:"\f590"}.bi-stop-circle::before{content:"\f591"}.bi-stop-fill::before{content:"\f592"}.bi-stop::before{content:"\f593"}.bi-stoplights-fill::before{content:"\f594"}.bi-stoplights::before{content:"\f595"}.bi-stopwatch-fill::before{content:"\f596"}.bi-stopwatch::before{content:"\f597"}.bi-subtract::before{content:"\f598"}.bi-suit-club-fill::before{content:"\f599"}.bi-suit-club::before{content:"\f59a"}.bi-suit-diamond-fill::before{content:"\f59b"}.bi-suit-diamond::before{content:"\f59c"}.bi-suit-heart-fill::before{content:"\f59d"}.bi-suit-heart::before{content:"\f59e"}.bi-suit-spade-fill::before{content:"\f59f"}.bi-suit-spade::before{content:"\f5a0"}.bi-sun-fill::before{content:"\f5a1"}.bi-sun::before{content:"\f5a2"}.bi-sunglasses::before{content:"\f5a3"}.bi-sunrise-fill::before{content:"\f5a4"}.bi-sunrise::before{content:"\f5a5"}.bi-sunset-fill::before{content:"\f5a6"}.bi-sunset::before{content:"\f5a7"}.bi-symmetry-horizontal::before{content:"\f5a8"}.bi-symmetry-vertical::before{content:"\f5a9"}.bi-table::before{content:"\f5aa"}.bi-tablet-fill::before{content:"\f5ab"}.bi-tablet-landscape-fill::before{content:"\f5ac"}.bi-tablet-landscape::before{content:"\f5ad"}.bi-tablet::before{content:"\f5ae"}.bi-tag-fill::before{content:"\f5af"}.bi-tag::before{content:"\f5b0"}.bi-tags-fill::before{content:"\f5b1"}.bi-tags::before{content:"\f5b2"}.bi-telegram::before{content:"\f5b3"}.bi-telephone-fill::before{content:"\f5b4"}.bi-telephone-forward-fill::before{content:"\f5b5"}.bi-telephone-forward::before{content:"\f5b6"}.bi-telephone-inbound-fill::before{content:"\f5b7"}.bi-telephone-inbound::before{content:"\f5b8"}.bi-telephone-minus-fill::before{content:"\f5b9"}.bi-telephone-minus::before{content:"\f5ba"}.bi-telephone-outbound-fill::before{content:"\f5bb"}.bi-telephone-outbound::before{content:"\f5bc"}.bi-telephone-plus-fill::before{content:"\f5bd"}.bi-telephone-plus::before{content:"\f5be"}.bi-telephone-x-fill::before{content:"\f5bf"}.bi-telephone-x::before{content:"\f5c0"}.bi-telephone::before{content:"\f5c1"}.bi-terminal-fill::before{content:"\f5c2"}.bi-terminal::before{content:"\f5c3"}.bi-text-center::before{content:"\f5c4"}.bi-text-indent-left::before{content:"\f5c5"}.bi-text-indent-right::before{content:"\f5c6"}.bi-text-left::before{content:"\f5c7"}.bi-text-paragraph::before{content:"\f5c8"}.bi-text-right::before{content:"\f5c9"}.bi-textarea-resize::before{content:"\f5ca"}.bi-textarea-t::before{content:"\f5cb"}.bi-textarea::before{content:"\f5cc"}.bi-thermometer-half::before{content:"\f5cd"}.bi-thermometer-high::before{content:"\f5ce"}.bi-thermometer-low::before{content:"\f5cf"}.bi-thermometer-snow::before{content:"\f5d0"}.bi-thermometer-sun::before{content:"\f5d1"}.bi-thermometer::before{content:"\f5d2"}.bi-three-dots-vertical::before{content:"\f5d3"}.bi-three-dots::before{content:"\f5d4"}.bi-toggle-off::before{content:"\f5d5"}.bi-toggle-on::before{content:"\f5d6"}.bi-toggle2-off::before{content:"\f5d7"}.bi-toggle2-on::before{content:"\f5d8"}.bi-toggles::before{content:"\f5d9"}.bi-toggles2::before{content:"\f5da"}.bi-tools::before{content:"\f5db"}.bi-tornado::before{content:"\f5dc"}.bi-trash-fill::before{content:"\f5dd"}.bi-trash::before{content:"\f5de"}.bi-trash2-fill::before{content:"\f5df"}.bi-trash2::before{content:"\f5e0"}.bi-tree-fill::before{content:"\f5e1"}.bi-tree::before{content:"\f5e2"}.bi-triangle-fill::before{content:"\f5e3"}.bi-triangle-half::before{content:"\f5e4"}.bi-triangle::before{content:"\f5e5"}.bi-trophy-fill::before{content:"\f5e6"}.bi-trophy::before{content:"\f5e7"}.bi-tropical-storm::before{content:"\f5e8"}.bi-truck-flatbed::before{content:"\f5e9"}.bi-truck::before{content:"\f5ea"}.bi-tsunami::before{content:"\f5eb"}.bi-tv-fill::before{content:"\f5ec"}.bi-tv::before{content:"\f5ed"}.bi-twitch::before{content:"\f5ee"}.bi-twitter::before{content:"\f5ef"}.bi-type-bold::before{content:"\f5f0"}.bi-type-h1::before{content:"\f5f1"}.bi-type-h2::before{content:"\f5f2"}.bi-type-h3::before{content:"\f5f3"}.bi-type-italic::before{content:"\f5f4"}.bi-type-strikethrough::before{content:"\f5f5"}.bi-type-underline::before{content:"\f5f6"}.bi-type::before{content:"\f5f7"}.bi-ui-checks-grid::before{content:"\f5f8"}.bi-ui-checks::before{content:"\f5f9"}.bi-ui-radios-grid::before{content:"\f5fa"}.bi-ui-radios::before{content:"\f5fb"}.bi-umbrella-fill::before{content:"\f5fc"}.bi-umbrella::before{content:"\f5fd"}.bi-union::before{content:"\f5fe"}.bi-unlock-fill::before{content:"\f5ff"}.bi-unlock::before{content:"\f600"}.bi-upc-scan::before{content:"\f601"}.bi-upc::before{content:"\f602"}.bi-upload::before{content:"\f603"}.bi-vector-pen::before{content:"\f604"}.bi-view-list::before{content:"\f605"}.bi-view-stacked::before{content:"\f606"}.bi-vinyl-fill::before{content:"\f607"}.bi-vinyl::before{content:"\f608"}.bi-voicemail::before{content:"\f609"}.bi-volume-down-fill::before{content:"\f60a"}.bi-volume-down::before{content:"\f60b"}.bi-volume-mute-fill::before{content:"\f60c"}.bi-volume-mute::before{content:"\f60d"}.bi-volume-off-fill::before{content:"\f60e"}.bi-volume-off::before{content:"\f60f"}.bi-volume-up-fill::before{content:"\f610"}.bi-volume-up::before{content:"\f611"}.bi-vr::before{content:"\f612"}.bi-wallet-fill::before{content:"\f613"}.bi-wallet::before{content:"\f614"}.bi-wallet2::before{content:"\f615"}.bi-watch::before{content:"\f616"}.bi-water::before{content:"\f617"}.bi-whatsapp::before{content:"\f618"}.bi-wifi-1::before{content:"\f619"}.bi-wifi-2::before{content:"\f61a"}.bi-wifi-off::before{content:"\f61b"}.bi-wifi::before{content:"\f61c"}.bi-wind::before{content:"\f61d"}.bi-window-dock::before{content:"\f61e"}.bi-window-sidebar::before{content:"\f61f"}.bi-window::before{content:"\f620"}.bi-wrench::before{content:"\f621"}.bi-x-circle-fill::before{content:"\f622"}.bi-x-circle::before{content:"\f623"}.bi-x-diamond-fill::before{content:"\f624"}.bi-x-diamond::before{content:"\f625"}.bi-x-octagon-fill::before{content:"\f626"}.bi-x-octagon::before{content:"\f627"}.bi-x-square-fill::before{content:"\f628"}.bi-x-square::before{content:"\f629"}.bi-x::before{content:"\f62a"}.bi-youtube::before{content:"\f62b"}.bi-zoom-in::before{content:"\f62c"}.bi-zoom-out::before{content:"\f62d"}.bi-bank::before{content:"\f62e"}.bi-bank2::before{content:"\f62f"}.bi-bell-slash-fill::before{content:"\f630"}.bi-bell-slash::before{content:"\f631"}.bi-cash-coin::before{content:"\f632"}.bi-check-lg::before{content:"\f633"}.bi-coin::before{content:"\f634"}.bi-currency-bitcoin::before{content:"\f635"}.bi-currency-dollar::before{content:"\f636"}.bi-currency-euro::before{content:"\f637"}.bi-currency-exchange::before{content:"\f638"}.bi-currency-pound::before{content:"\f639"}.bi-currency-yen::before{content:"\f63a"}.bi-dash-lg::before{content:"\f63b"}.bi-exclamation-lg::before{content:"\f63c"}.bi-file-earmark-pdf-fill::before{content:"\f63d"}.bi-file-earmark-pdf::before{content:"\f63e"}.bi-file-pdf-fill::before{content:"\f63f"}.bi-file-pdf::before{content:"\f640"}.bi-gender-ambiguous::before{content:"\f641"}.bi-gender-female::before{content:"\f642"}.bi-gender-male::before{content:"\f643"}.bi-gender-trans::before{content:"\f644"}.bi-headset-vr::before{content:"\f645"}.bi-info-lg::before{content:"\f646"}.bi-mastodon::before{content:"\f647"}.bi-messenger::before{content:"\f648"}.bi-piggy-bank-fill::before{content:"\f649"}.bi-piggy-bank::before{content:"\f64a"}.bi-pin-map-fill::before{content:"\f64b"}.bi-pin-map::before{content:"\f64c"}.bi-plus-lg::before{content:"\f64d"}.bi-question-lg::before{content:"\f64e"}.bi-recycle::before{content:"\f64f"}.bi-reddit::before{content:"\f650"}.bi-safe-fill::before{content:"\f651"}.bi-safe2-fill::before{content:"\f652"}.bi-safe2::before{content:"\f653"}.bi-sd-card-fill::before{content:"\f654"}.bi-sd-card::before{content:"\f655"}.bi-skype::before{content:"\f656"}.bi-slash-lg::before{content:"\f657"}.bi-translate::before{content:"\f658"}.bi-x-lg::before{content:"\f659"}.bi-safe::before{content:"\f65a"}.bi-apple::before{content:"\f65b"}.bi-microsoft::before{content:"\f65d"}.bi-windows::before{content:"\f65e"}.bi-behance::before{content:"\f65c"}.bi-dribbble::before{content:"\f65f"}.bi-line::before{content:"\f660"}.bi-medium::before{content:"\f661"}.bi-paypal::before{content:"\f662"}.bi-pinterest::before{content:"\f663"}.bi-signal::before{content:"\f664"}.bi-snapchat::before{content:"\f665"}.bi-spotify::before{content:"\f666"}.bi-stack-overflow::before{content:"\f667"}.bi-strava::before{content:"\f668"}.bi-wordpress::before{content:"\f669"}.bi-vimeo::before{content:"\f66a"}.bi-activity::before{content:"\f66b"}.bi-easel2-fill::before{content:"\f66c"}.bi-easel2::before{content:"\f66d"}.bi-easel3-fill::before{content:"\f66e"}.bi-easel3::before{content:"\f66f"}.bi-fan::before{content:"\f670"}.bi-fingerprint::before{content:"\f671"}.bi-graph-down-arrow::before{content:"\f672"}.bi-graph-up-arrow::before{content:"\f673"}.bi-hypnotize::before{content:"\f674"}.bi-magic::before{content:"\f675"}.bi-person-rolodex::before{content:"\f676"}.bi-person-video::before{content:"\f677"}.bi-person-video2::before{content:"\f678"}.bi-person-video3::before{content:"\f679"}.bi-person-workspace::before{content:"\f67a"}.bi-radioactive::before{content:"\f67b"}.bi-webcam-fill::before{content:"\f67c"}.bi-webcam::before{content:"\f67d"}.bi-yin-yang::before{content:"\f67e"}.bi-bandaid-fill::before{content:"\f680"}.bi-bandaid::before{content:"\f681"}.bi-bluetooth::before{content:"\f682"}.bi-body-text::before{content:"\f683"}.bi-boombox::before{content:"\f684"}.bi-boxes::before{content:"\f685"}.bi-dpad-fill::before{content:"\f686"}.bi-dpad::before{content:"\f687"}.bi-ear-fill::before{content:"\f688"}.bi-ear::before{content:"\f689"}.bi-envelope-check-fill::before{content:"\f68b"}.bi-envelope-check::before{content:"\f68c"}.bi-envelope-dash-fill::before{content:"\f68e"}.bi-envelope-dash::before{content:"\f68f"}.bi-envelope-exclamation-fill::before{content:"\f691"}.bi-envelope-exclamation::before{content:"\f692"}.bi-envelope-plus-fill::before{content:"\f693"}.bi-envelope-plus::before{content:"\f694"}.bi-envelope-slash-fill::before{content:"\f696"}.bi-envelope-slash::before{content:"\f697"}.bi-envelope-x-fill::before{content:"\f699"}.bi-envelope-x::before{content:"\f69a"}.bi-explicit-fill::before{content:"\f69b"}.bi-explicit::before{content:"\f69c"}.bi-git::before{content:"\f69d"}.bi-infinity::before{content:"\f69e"}.bi-list-columns-reverse::before{content:"\f69f"}.bi-list-columns::before{content:"\f6a0"}.bi-meta::before{content:"\f6a1"}.bi-nintendo-switch::before{content:"\f6a4"}.bi-pc-display-horizontal::before{content:"\f6a5"}.bi-pc-display::before{content:"\f6a6"}.bi-pc-horizontal::before{content:"\f6a7"}.bi-pc::before{content:"\f6a8"}.bi-playstation::before{content:"\f6a9"}.bi-plus-slash-minus::before{content:"\f6aa"}.bi-projector-fill::before{content:"\f6ab"}.bi-projector::before{content:"\f6ac"}.bi-qr-code-scan::before{content:"\f6ad"}.bi-qr-code::before{content:"\f6ae"}.bi-quora::before{content:"\f6af"}.bi-quote::before{content:"\f6b0"}.bi-robot::before{content:"\f6b1"}.bi-send-check-fill::before{content:"\f6b2"}.bi-send-check::before{content:"\f6b3"}.bi-send-dash-fill::before{content:"\f6b4"}.bi-send-dash::before{content:"\f6b5"}.bi-send-exclamation-fill::before{content:"\f6b7"}.bi-send-exclamation::before{content:"\f6b8"}.bi-send-fill::before{content:"\f6b9"}.bi-send-plus-fill::before{content:"\f6ba"}.bi-send-plus::before{content:"\f6bb"}.bi-send-slash-fill::before{content:"\f6bc"}.bi-send-slash::before{content:"\f6bd"}.bi-send-x-fill::before{content:"\f6be"}.bi-send-x::before{content:"\f6bf"}.bi-send::before{content:"\f6c0"}.bi-steam::before{content:"\f6c1"}.bi-terminal-dash::before{content:"\f6c3"}.bi-terminal-plus::before{content:"\f6c4"}.bi-terminal-split::before{content:"\f6c5"}.bi-ticket-detailed-fill::before{content:"\f6c6"}.bi-ticket-detailed::before{content:"\f6c7"}.bi-ticket-fill::before{content:"\f6c8"}.bi-ticket-perforated-fill::before{content:"\f6c9"}.bi-ticket-perforated::before{content:"\f6ca"}.bi-ticket::before{content:"\f6cb"}.bi-tiktok::before{content:"\f6cc"}.bi-window-dash::before{content:"\f6cd"}.bi-window-desktop::before{content:"\f6ce"}.bi-window-fullscreen::before{content:"\f6cf"}.bi-window-plus::before{content:"\f6d0"}.bi-window-split::before{content:"\f6d1"}.bi-window-stack::before{content:"\f6d2"}.bi-window-x::before{content:"\f6d3"}.bi-xbox::before{content:"\f6d4"}.bi-ethernet::before{content:"\f6d5"}.bi-hdmi-fill::before{content:"\f6d6"}.bi-hdmi::before{content:"\f6d7"}.bi-usb-c-fill::before{content:"\f6d8"}.bi-usb-c::before{content:"\f6d9"}.bi-usb-fill::before{content:"\f6da"}.bi-usb-plug-fill::before{content:"\f6db"}.bi-usb-plug::before{content:"\f6dc"}.bi-usb-symbol::before{content:"\f6dd"}.bi-usb::before{content:"\f6de"}.bi-boombox-fill::before{content:"\f6df"}.bi-displayport::before{content:"\f6e1"}.bi-gpu-card::before{content:"\f6e2"}.bi-memory::before{content:"\f6e3"}.bi-modem-fill::before{content:"\f6e4"}.bi-modem::before{content:"\f6e5"}.bi-motherboard-fill::before{content:"\f6e6"}.bi-motherboard::before{content:"\f6e7"}.bi-optical-audio-fill::before{content:"\f6e8"}.bi-optical-audio::before{content:"\f6e9"}.bi-pci-card::before{content:"\f6ea"}.bi-router-fill::before{content:"\f6eb"}.bi-router::before{content:"\f6ec"}.bi-thunderbolt-fill::before{content:"\f6ef"}.bi-thunderbolt::before{content:"\f6f0"}.bi-usb-drive-fill::before{content:"\f6f1"}.bi-usb-drive::before{content:"\f6f2"}.bi-usb-micro-fill::before{content:"\f6f3"}.bi-usb-micro::before{content:"\f6f4"}.bi-usb-mini-fill::before{content:"\f6f5"}.bi-usb-mini::before{content:"\f6f6"}.bi-cloud-haze2::before{content:"\f6f7"}.bi-device-hdd-fill::before{content:"\f6f8"}.bi-device-hdd::before{content:"\f6f9"}.bi-device-ssd-fill::before{content:"\f6fa"}.bi-device-ssd::before{content:"\f6fb"}.bi-displayport-fill::before{content:"\f6fc"}.bi-mortarboard-fill::before{content:"\f6fd"}.bi-mortarboard::before{content:"\f6fe"}.bi-terminal-x::before{content:"\f6ff"}.bi-arrow-through-heart-fill::before{content:"\f700"}.bi-arrow-through-heart::before{content:"\f701"}.bi-badge-sd-fill::before{content:"\f702"}.bi-badge-sd::before{content:"\f703"}.bi-bag-heart-fill::before{content:"\f704"}.bi-bag-heart::before{content:"\f705"}.bi-balloon-fill::before{content:"\f706"}.bi-balloon-heart-fill::before{content:"\f707"}.bi-balloon-heart::before{content:"\f708"}.bi-balloon::before{content:"\f709"}.bi-box2-fill::before{content:"\f70a"}.bi-box2-heart-fill::before{content:"\f70b"}.bi-box2-heart::before{content:"\f70c"}.bi-box2::before{content:"\f70d"}.bi-braces-asterisk::before{content:"\f70e"}.bi-calendar-heart-fill::before{content:"\f70f"}.bi-calendar-heart::before{content:"\f710"}.bi-calendar2-heart-fill::before{content:"\f711"}.bi-calendar2-heart::before{content:"\f712"}.bi-chat-heart-fill::before{content:"\f713"}.bi-chat-heart::before{content:"\f714"}.bi-chat-left-heart-fill::before{content:"\f715"}.bi-chat-left-heart::before{content:"\f716"}.bi-chat-right-heart-fill::before{content:"\f717"}.bi-chat-right-heart::before{content:"\f718"}.bi-chat-square-heart-fill::before{content:"\f719"}.bi-chat-square-heart::before{content:"\f71a"}.bi-clipboard-check-fill::before{content:"\f71b"}.bi-clipboard-data-fill::before{content:"\f71c"}.bi-clipboard-fill::before{content:"\f71d"}.bi-clipboard-heart-fill::before{content:"\f71e"}.bi-clipboard-heart::before{content:"\f71f"}.bi-clipboard-minus-fill::before{content:"\f720"}.bi-clipboard-plus-fill::before{content:"\f721"}.bi-clipboard-pulse::before{content:"\f722"}.bi-clipboard-x-fill::before{content:"\f723"}.bi-clipboard2-check-fill::before{content:"\f724"}.bi-clipboard2-check::before{content:"\f725"}.bi-clipboard2-data-fill::before{content:"\f726"}.bi-clipboard2-data::before{content:"\f727"}.bi-clipboard2-fill::before{content:"\f728"}.bi-clipboard2-heart-fill::before{content:"\f729"}.bi-clipboard2-heart::before{content:"\f72a"}.bi-clipboard2-minus-fill::before{content:"\f72b"}.bi-clipboard2-minus::before{content:"\f72c"}.bi-clipboard2-plus-fill::before{content:"\f72d"}.bi-clipboard2-plus::before{content:"\f72e"}.bi-clipboard2-pulse-fill::before{content:"\f72f"}.bi-clipboard2-pulse::before{content:"\f730"}.bi-clipboard2-x-fill::before{content:"\f731"}.bi-clipboard2-x::before{content:"\f732"}.bi-clipboard2::before{content:"\f733"}.bi-emoji-kiss-fill::before{content:"\f734"}.bi-emoji-kiss::before{content:"\f735"}.bi-envelope-heart-fill::before{content:"\f736"}.bi-envelope-heart::before{content:"\f737"}.bi-envelope-open-heart-fill::before{content:"\f738"}.bi-envelope-open-heart::before{content:"\f739"}.bi-envelope-paper-fill::before{content:"\f73a"}.bi-envelope-paper-heart-fill::before{content:"\f73b"}.bi-envelope-paper-heart::before{content:"\f73c"}.bi-envelope-paper::before{content:"\f73d"}.bi-filetype-aac::before{content:"\f73e"}.bi-filetype-ai::before{content:"\f73f"}.bi-filetype-bmp::before{content:"\f740"}.bi-filetype-cs::before{content:"\f741"}.bi-filetype-css::before{content:"\f742"}.bi-filetype-csv::before{content:"\f743"}.bi-filetype-doc::before{content:"\f744"}.bi-filetype-docx::before{content:"\f745"}.bi-filetype-exe::before{content:"\f746"}.bi-filetype-gif::before{content:"\f747"}.bi-filetype-heic::before{content:"\f748"}.bi-filetype-html::before{content:"\f749"}.bi-filetype-java::before{content:"\f74a"}.bi-filetype-jpg::before{content:"\f74b"}.bi-filetype-js::before{content:"\f74c"}.bi-filetype-jsx::before{content:"\f74d"}.bi-filetype-key::before{content:"\f74e"}.bi-filetype-m4p::before{content:"\f74f"}.bi-filetype-md::before{content:"\f750"}.bi-filetype-mdx::before{content:"\f751"}.bi-filetype-mov::before{content:"\f752"}.bi-filetype-mp3::before{content:"\f753"}.bi-filetype-mp4::before{content:"\f754"}.bi-filetype-otf::before{content:"\f755"}.bi-filetype-pdf::before{content:"\f756"}.bi-filetype-php::before{content:"\f757"}.bi-filetype-png::before{content:"\f758"}.bi-filetype-ppt::before{content:"\f75a"}.bi-filetype-psd::before{content:"\f75b"}.bi-filetype-py::before{content:"\f75c"}.bi-filetype-raw::before{content:"\f75d"}.bi-filetype-rb::before{content:"\f75e"}.bi-filetype-sass::before{content:"\f75f"}.bi-filetype-scss::before{content:"\f760"}.bi-filetype-sh::before{content:"\f761"}.bi-filetype-svg::before{content:"\f762"}.bi-filetype-tiff::before{content:"\f763"}.bi-filetype-tsx::before{content:"\f764"}.bi-filetype-ttf::before{content:"\f765"}.bi-filetype-txt::before{content:"\f766"}.bi-filetype-wav::before{content:"\f767"}.bi-filetype-woff::before{content:"\f768"}.bi-filetype-xls::before{content:"\f76a"}.bi-filetype-xml::before{content:"\f76b"}.bi-filetype-yml::before{content:"\f76c"}.bi-heart-arrow::before{content:"\f76d"}.bi-heart-pulse-fill::before{content:"\f76e"}.bi-heart-pulse::before{content:"\f76f"}.bi-heartbreak-fill::before{content:"\f770"}.bi-heartbreak::before{content:"\f771"}.bi-hearts::before{content:"\f772"}.bi-hospital-fill::before{content:"\f773"}.bi-hospital::before{content:"\f774"}.bi-house-heart-fill::before{content:"\f775"}.bi-house-heart::before{content:"\f776"}.bi-incognito::before{content:"\f777"}.bi-magnet-fill::before{content:"\f778"}.bi-magnet::before{content:"\f779"}.bi-person-heart::before{content:"\f77a"}.bi-person-hearts::before{content:"\f77b"}.bi-phone-flip::before{content:"\f77c"}.bi-plugin::before{content:"\f77d"}.bi-postage-fill::before{content:"\f77e"}.bi-postage-heart-fill::before{content:"\f77f"}.bi-postage-heart::before{content:"\f780"}.bi-postage::before{content:"\f781"}.bi-postcard-fill::before{content:"\f782"}.bi-postcard-heart-fill::before{content:"\f783"}.bi-postcard-heart::before{content:"\f784"}.bi-postcard::before{content:"\f785"}.bi-search-heart-fill::before{content:"\f786"}.bi-search-heart::before{content:"\f787"}.bi-sliders2-vertical::before{content:"\f788"}.bi-sliders2::before{content:"\f789"}.bi-trash3-fill::before{content:"\f78a"}.bi-trash3::before{content:"\f78b"}.bi-valentine::before{content:"\f78c"}.bi-valentine2::before{content:"\f78d"}.bi-wrench-adjustable-circle-fill::before{content:"\f78e"}.bi-wrench-adjustable-circle::before{content:"\f78f"}.bi-wrench-adjustable::before{content:"\f790"}.bi-filetype-json::before{content:"\f791"}.bi-filetype-pptx::before{content:"\f792"}.bi-filetype-xlsx::before{content:"\f793"}.bi-1-circle-fill::before{content:"\f796"}.bi-1-circle::before{content:"\f797"}.bi-1-square-fill::before{content:"\f798"}.bi-1-square::before{content:"\f799"}.bi-2-circle-fill::before{content:"\f79c"}.bi-2-circle::before{content:"\f79d"}.bi-2-square-fill::before{content:"\f79e"}.bi-2-square::before{content:"\f79f"}.bi-3-circle-fill::before{content:"\f7a2"}.bi-3-circle::before{content:"\f7a3"}.bi-3-square-fill::before{content:"\f7a4"}.bi-3-square::before{content:"\f7a5"}.bi-4-circle-fill::before{content:"\f7a8"}.bi-4-circle::before{content:"\f7a9"}.bi-4-square-fill::before{content:"\f7aa"}.bi-4-square::before{content:"\f7ab"}.bi-5-circle-fill::before{content:"\f7ae"}.bi-5-circle::before{content:"\f7af"}.bi-5-square-fill::before{content:"\f7b0"}.bi-5-square::before{content:"\f7b1"}.bi-6-circle-fill::before{content:"\f7b4"}.bi-6-circle::before{content:"\f7b5"}.bi-6-square-fill::before{content:"\f7b6"}.bi-6-square::before{content:"\f7b7"}.bi-7-circle-fill::before{content:"\f7ba"}.bi-7-circle::before{content:"\f7bb"}.bi-7-square-fill::before{content:"\f7bc"}.bi-7-square::before{content:"\f7bd"}.bi-8-circle-fill::before{content:"\f7c0"}.bi-8-circle::before{content:"\f7c1"}.bi-8-square-fill::before{content:"\f7c2"}.bi-8-square::before{content:"\f7c3"}.bi-9-circle-fill::before{content:"\f7c6"}.bi-9-circle::before{content:"\f7c7"}.bi-9-square-fill::before{content:"\f7c8"}.bi-9-square::before{content:"\f7c9"}.bi-airplane-engines-fill::before{content:"\f7ca"}.bi-airplane-engines::before{content:"\f7cb"}.bi-airplane-fill::before{content:"\f7cc"}.bi-airplane::before{content:"\f7cd"}.bi-alexa::before{content:"\f7ce"}.bi-alipay::before{content:"\f7cf"}.bi-android::before{content:"\f7d0"}.bi-android2::before{content:"\f7d1"}.bi-box-fill::before{content:"\f7d2"}.bi-box-seam-fill::before{content:"\f7d3"}.bi-browser-chrome::before{content:"\f7d4"}.bi-browser-edge::before{content:"\f7d5"}.bi-browser-firefox::before{content:"\f7d6"}.bi-browser-safari::before{content:"\f7d7"}.bi-c-circle-fill::before{content:"\f7da"}.bi-c-circle::before{content:"\f7db"}.bi-c-square-fill::before{content:"\f7dc"}.bi-c-square::before{content:"\f7dd"}.bi-capsule-pill::before{content:"\f7de"}.bi-capsule::before{content:"\f7df"}.bi-car-front-fill::before{content:"\f7e0"}.bi-car-front::before{content:"\f7e1"}.bi-cassette-fill::before{content:"\f7e2"}.bi-cassette::before{content:"\f7e3"}.bi-cc-circle-fill::before{content:"\f7e6"}.bi-cc-circle::before{content:"\f7e7"}.bi-cc-square-fill::before{content:"\f7e8"}.bi-cc-square::before{content:"\f7e9"}.bi-cup-hot-fill::before{content:"\f7ea"}.bi-cup-hot::before{content:"\f7eb"}.bi-currency-rupee::before{content:"\f7ec"}.bi-dropbox::before{content:"\f7ed"}.bi-escape::before{content:"\f7ee"}.bi-fast-forward-btn-fill::before{content:"\f7ef"}.bi-fast-forward-btn::before{content:"\f7f0"}.bi-fast-forward-circle-fill::before{content:"\f7f1"}.bi-fast-forward-circle::before{content:"\f7f2"}.bi-fast-forward-fill::before{content:"\f7f3"}.bi-fast-forward::before{content:"\f7f4"}.bi-filetype-sql::before{content:"\f7f5"}.bi-fire::before{content:"\f7f6"}.bi-google-play::before{content:"\f7f7"}.bi-h-circle-fill::before{content:"\f7fa"}.bi-h-circle::before{content:"\f7fb"}.bi-h-square-fill::before{content:"\f7fc"}.bi-h-square::before{content:"\f7fd"}.bi-indent::before{content:"\f7fe"}.bi-lungs-fill::before{content:"\f7ff"}.bi-lungs::before{content:"\f800"}.bi-microsoft-teams::before{content:"\f801"}.bi-p-circle-fill::before{content:"\f804"}.bi-p-circle::before{content:"\f805"}.bi-p-square-fill::before{content:"\f806"}.bi-p-square::before{content:"\f807"}.bi-pass-fill::before{content:"\f808"}.bi-pass::before{content:"\f809"}.bi-prescription::before{content:"\f80a"}.bi-prescription2::before{content:"\f80b"}.bi-r-circle-fill::before{content:"\f80e"}.bi-r-circle::before{content:"\f80f"}.bi-r-square-fill::before{content:"\f810"}.bi-r-square::before{content:"\f811"}.bi-repeat-1::before{content:"\f812"}.bi-repeat::before{content:"\f813"}.bi-rewind-btn-fill::before{content:"\f814"}.bi-rewind-btn::before{content:"\f815"}.bi-rewind-circle-fill::before{content:"\f816"}.bi-rewind-circle::before{content:"\f817"}.bi-rewind-fill::before{content:"\f818"}.bi-rewind::before{content:"\f819"}.bi-train-freight-front-fill::before{content:"\f81a"}.bi-train-freight-front::before{content:"\f81b"}.bi-train-front-fill::before{content:"\f81c"}.bi-train-front::before{content:"\f81d"}.bi-train-lightrail-front-fill::before{content:"\f81e"}.bi-train-lightrail-front::before{content:"\f81f"}.bi-truck-front-fill::before{content:"\f820"}.bi-truck-front::before{content:"\f821"}.bi-ubuntu::before{content:"\f822"}.bi-unindent::before{content:"\f823"}.bi-unity::before{content:"\f824"}.bi-universal-access-circle::before{content:"\f825"}.bi-universal-access::before{content:"\f826"}.bi-virus::before{content:"\f827"}.bi-virus2::before{content:"\f828"}.bi-wechat::before{content:"\f829"}.bi-yelp::before{content:"\f82a"}.bi-sign-stop-fill::before{content:"\f82b"}.bi-sign-stop-lights-fill::before{content:"\f82c"}.bi-sign-stop-lights::before{content:"\f82d"}.bi-sign-stop::before{content:"\f82e"}.bi-sign-turn-left-fill::before{content:"\f82f"}.bi-sign-turn-left::before{content:"\f830"}.bi-sign-turn-right-fill::before{content:"\f831"}.bi-sign-turn-right::before{content:"\f832"}.bi-sign-turn-slight-left-fill::before{content:"\f833"}.bi-sign-turn-slight-left::before{content:"\f834"}.bi-sign-turn-slight-right-fill::before{content:"\f835"}.bi-sign-turn-slight-right::before{content:"\f836"}.bi-sign-yield-fill::before{content:"\f837"}.bi-sign-yield::before{content:"\f838"}.bi-ev-station-fill::before{content:"\f839"}.bi-ev-station::before{content:"\f83a"}.bi-fuel-pump-diesel-fill::before{content:"\f83b"}.bi-fuel-pump-diesel::before{content:"\f83c"}.bi-fuel-pump-fill::before{content:"\f83d"}.bi-fuel-pump::before{content:"\f83e"}.bi-0-circle-fill::before{content:"\f83f"}.bi-0-circle::before{content:"\f840"}.bi-0-square-fill::before{content:"\f841"}.bi-0-square::before{content:"\f842"}.bi-rocket-fill::before{content:"\f843"}.bi-rocket-takeoff-fill::before{content:"\f844"}.bi-rocket-takeoff::before{content:"\f845"}.bi-rocket::before{content:"\f846"}.bi-stripe::before{content:"\f847"}.bi-subscript::before{content:"\f848"}.bi-superscript::before{content:"\f849"}.bi-trello::before{content:"\f84a"}.bi-envelope-at-fill::before{content:"\f84b"}.bi-envelope-at::before{content:"\f84c"}.bi-regex::before{content:"\f84d"}.bi-text-wrap::before{content:"\f84e"}.bi-sign-dead-end-fill::before{content:"\f84f"}.bi-sign-dead-end::before{content:"\f850"}.bi-sign-do-not-enter-fill::before{content:"\f851"}.bi-sign-do-not-enter::before{content:"\f852"}.bi-sign-intersection-fill::before{content:"\f853"}.bi-sign-intersection-side-fill::before{content:"\f854"}.bi-sign-intersection-side::before{content:"\f855"}.bi-sign-intersection-t-fill::before{content:"\f856"}.bi-sign-intersection-t::before{content:"\f857"}.bi-sign-intersection-y-fill::before{content:"\f858"}.bi-sign-intersection-y::before{content:"\f859"}.bi-sign-intersection::before{content:"\f85a"}.bi-sign-merge-left-fill::before{content:"\f85b"}.bi-sign-merge-left::before{content:"\f85c"}.bi-sign-merge-right-fill::before{content:"\f85d"}.bi-sign-merge-right::before{content:"\f85e"}.bi-sign-no-left-turn-fill::before{content:"\f85f"}.bi-sign-no-left-turn::before{content:"\f860"}.bi-sign-no-parking-fill::before{content:"\f861"}.bi-sign-no-parking::before{content:"\f862"}.bi-sign-no-right-turn-fill::before{content:"\f863"}.bi-sign-no-right-turn::before{content:"\f864"}.bi-sign-railroad-fill::before{content:"\f865"}.bi-sign-railroad::before{content:"\f866"}.bi-building-add::before{content:"\f867"}.bi-building-check::before{content:"\f868"}.bi-building-dash::before{content:"\f869"}.bi-building-down::before{content:"\f86a"}.bi-building-exclamation::before{content:"\f86b"}.bi-building-fill-add::before{content:"\f86c"}.bi-building-fill-check::before{content:"\f86d"}.bi-building-fill-dash::before{content:"\f86e"}.bi-building-fill-down::before{content:"\f86f"}.bi-building-fill-exclamation::before{content:"\f870"}.bi-building-fill-gear::before{content:"\f871"}.bi-building-fill-lock::before{content:"\f872"}.bi-building-fill-slash::before{content:"\f873"}.bi-building-fill-up::before{content:"\f874"}.bi-building-fill-x::before{content:"\f875"}.bi-building-fill::before{content:"\f876"}.bi-building-gear::before{content:"\f877"}.bi-building-lock::before{content:"\f878"}.bi-building-slash::before{content:"\f879"}.bi-building-up::before{content:"\f87a"}.bi-building-x::before{content:"\f87b"}.bi-buildings-fill::before{content:"\f87c"}.bi-buildings::before{content:"\f87d"}.bi-bus-front-fill::before{content:"\f87e"}.bi-bus-front::before{content:"\f87f"}.bi-ev-front-fill::before{content:"\f880"}.bi-ev-front::before{content:"\f881"}.bi-globe-americas::before{content:"\f882"}.bi-globe-asia-australia::before{content:"\f883"}.bi-globe-central-south-asia::before{content:"\f884"}.bi-globe-europe-africa::before{content:"\f885"}.bi-house-add-fill::before{content:"\f886"}.bi-house-add::before{content:"\f887"}.bi-house-check-fill::before{content:"\f888"}.bi-house-check::before{content:"\f889"}.bi-house-dash-fill::before{content:"\f88a"}.bi-house-dash::before{content:"\f88b"}.bi-house-down-fill::before{content:"\f88c"}.bi-house-down::before{content:"\f88d"}.bi-house-exclamation-fill::before{content:"\f88e"}.bi-house-exclamation::before{content:"\f88f"}.bi-house-gear-fill::before{content:"\f890"}.bi-house-gear::before{content:"\f891"}.bi-house-lock-fill::before{content:"\f892"}.bi-house-lock::before{content:"\f893"}.bi-house-slash-fill::before{content:"\f894"}.bi-house-slash::before{content:"\f895"}.bi-house-up-fill::before{content:"\f896"}.bi-house-up::before{content:"\f897"}.bi-house-x-fill::before{content:"\f898"}.bi-house-x::before{content:"\f899"}.bi-person-add::before{content:"\f89a"}.bi-person-down::before{content:"\f89b"}.bi-person-exclamation::before{content:"\f89c"}.bi-person-fill-add::before{content:"\f89d"}.bi-person-fill-check::before{content:"\f89e"}.bi-person-fill-dash::before{content:"\f89f"}.bi-person-fill-down::before{content:"\f8a0"}.bi-person-fill-exclamation::before{content:"\f8a1"}.bi-person-fill-gear::before{content:"\f8a2"}.bi-person-fill-lock::before{content:"\f8a3"}.bi-person-fill-slash::before{content:"\f8a4"}.bi-person-fill-up::before{content:"\f8a5"}.bi-person-fill-x::before{content:"\f8a6"}.bi-person-gear::before{content:"\f8a7"}.bi-person-lock::before{content:"\f8a8"}.bi-person-slash::before{content:"\f8a9"}.bi-person-up::before{content:"\f8aa"}.bi-scooter::before{content:"\f8ab"}.bi-taxi-front-fill::before{content:"\f8ac"}.bi-taxi-front::before{content:"\f8ad"}.bi-amd::before{content:"\f8ae"}.bi-database-add::before{content:"\f8af"}.bi-database-check::before{content:"\f8b0"}.bi-database-dash::before{content:"\f8b1"}.bi-database-down::before{content:"\f8b2"}.bi-database-exclamation::before{content:"\f8b3"}.bi-database-fill-add::before{content:"\f8b4"}.bi-database-fill-check::before{content:"\f8b5"}.bi-database-fill-dash::before{content:"\f8b6"}.bi-database-fill-down::before{content:"\f8b7"}.bi-database-fill-exclamation::before{content:"\f8b8"}.bi-database-fill-gear::before{content:"\f8b9"}.bi-database-fill-lock::before{content:"\f8ba"}.bi-database-fill-slash::before{content:"\f8bb"}.bi-database-fill-up::before{content:"\f8bc"}.bi-database-fill-x::before{content:"\f8bd"}.bi-database-fill::before{content:"\f8be"}.bi-database-gear::before{content:"\f8bf"}.bi-database-lock::before{content:"\f8c0"}.bi-database-slash::before{content:"\f8c1"}.bi-database-up::before{content:"\f8c2"}.bi-database-x::before{content:"\f8c3"}.bi-database::before{content:"\f8c4"}.bi-houses-fill::before{content:"\f8c5"}.bi-houses::before{content:"\f8c6"}.bi-nvidia::before{content:"\f8c7"}.bi-person-vcard-fill::before{content:"\f8c8"}.bi-person-vcard::before{content:"\f8c9"}.bi-sina-weibo::before{content:"\f8ca"}.bi-tencent-qq::before{content:"\f8cb"}.bi-wikipedia::before{content:"\f8cc"}.bi-alphabet-uppercase::before{content:"\f2a5"}.bi-alphabet::before{content:"\f68a"}.bi-amazon::before{content:"\f68d"}.bi-arrows-collapse-vertical::before{content:"\f690"}.bi-arrows-expand-vertical::before{content:"\f695"}.bi-arrows-vertical::before{content:"\f698"}.bi-arrows::before{content:"\f6a2"}.bi-ban-fill::before{content:"\f6a3"}.bi-ban::before{content:"\f6b6"}.bi-bing::before{content:"\f6c2"}.bi-cake::before{content:"\f6e0"}.bi-cake2::before{content:"\f6ed"}.bi-cookie::before{content:"\f6ee"}.bi-copy::before{content:"\f759"}.bi-crosshair::before{content:"\f769"}.bi-crosshair2::before{content:"\f794"}.bi-emoji-astonished-fill::before{content:"\f795"}.bi-emoji-astonished::before{content:"\f79a"}.bi-emoji-grimace-fill::before{content:"\f79b"}.bi-emoji-grimace::before{content:"\f7a0"}.bi-emoji-grin-fill::before{content:"\f7a1"}.bi-emoji-grin::before{content:"\f7a6"}.bi-emoji-surprise-fill::before{content:"\f7a7"}.bi-emoji-surprise::before{content:"\f7ac"}.bi-emoji-tear-fill::before{content:"\f7ad"}.bi-emoji-tear::before{content:"\f7b2"}.bi-envelope-arrow-down-fill::before{content:"\f7b3"}.bi-envelope-arrow-down::before{content:"\f7b8"}.bi-envelope-arrow-up-fill::before{content:"\f7b9"}.bi-envelope-arrow-up::before{content:"\f7be"}.bi-feather::before{content:"\f7bf"}.bi-feather2::before{content:"\f7c4"}.bi-floppy-fill::before{content:"\f7c5"}.bi-floppy::before{content:"\f7d8"}.bi-floppy2-fill::before{content:"\f7d9"}.bi-floppy2::before{content:"\f7e4"}.bi-gitlab::before{content:"\f7e5"}.bi-highlighter::before{content:"\f7f8"}.bi-marker-tip::before{content:"\f802"}.bi-nvme-fill::before{content:"\f803"}.bi-nvme::before{content:"\f80c"}.bi-opencollective::before{content:"\f80d"}.bi-pci-card-network::before{content:"\f8cd"}.bi-pci-card-sound::before{content:"\f8ce"}.bi-radar::before{content:"\f8cf"}.bi-send-arrow-down-fill::before{content:"\f8d0"}.bi-send-arrow-down::before{content:"\f8d1"}.bi-send-arrow-up-fill::before{content:"\f8d2"}.bi-send-arrow-up::before{content:"\f8d3"}.bi-sim-slash-fill::before{content:"\f8d4"}.bi-sim-slash::before{content:"\f8d5"}.bi-sourceforge::before{content:"\f8d6"}.bi-substack::before{content:"\f8d7"}.bi-threads-fill::before{content:"\f8d8"}.bi-threads::before{content:"\f8d9"}.bi-transparency::before{content:"\f8da"}.bi-twitter-x::before{content:"\f8db"}.bi-type-h4::before{content:"\f8dc"}.bi-type-h5::before{content:"\f8dd"}.bi-type-h6::before{content:"\f8de"}.bi-backpack-fill::before{content:"\f8df"}.bi-backpack::before{content:"\f8e0"}.bi-backpack2-fill::before{content:"\f8e1"}.bi-backpack2::before{content:"\f8e2"}.bi-backpack3-fill::before{content:"\f8e3"}.bi-backpack3::before{content:"\f8e4"}.bi-backpack4-fill::before{content:"\f8e5"}.bi-backpack4::before{content:"\f8e6"}.bi-brilliance::before{content:"\f8e7"}.bi-cake-fill::before{content:"\f8e8"}.bi-cake2-fill::before{content:"\f8e9"}.bi-duffle-fill::before{content:"\f8ea"}.bi-duffle::before{content:"\f8eb"}.bi-exposure::before{content:"\f8ec"}.bi-gender-neuter::before{content:"\f8ed"}.bi-highlights::before{content:"\f8ee"}.bi-luggage-fill::before{content:"\f8ef"}.bi-luggage::before{content:"\f8f0"}.bi-mailbox-flag::before{content:"\f8f1"}.bi-mailbox2-flag::before{content:"\f8f2"}.bi-noise-reduction::before{content:"\f8f3"}.bi-passport-fill::before{content:"\f8f4"}.bi-passport::before{content:"\f8f5"}.bi-person-arms-up::before{content:"\f8f6"}.bi-person-raised-hand::before{content:"\f8f7"}.bi-person-standing-dress::before{content:"\f8f8"}.bi-person-standing::before{content:"\f8f9"}.bi-person-walking::before{content:"\f8fa"}.bi-person-wheelchair::before{content:"\f8fb"}.bi-shadows::before{content:"\f8fc"}.bi-suitcase-fill::before{content:"\f8fd"}.bi-suitcase-lg-fill::before{content:"\f8fe"}.bi-suitcase-lg::before{content:"\f8ff"}.bi-suitcase::before{content:"\f900"}.bi-suitcase2-fill::before{content:"\f901"}.bi-suitcase2::before{content:"\f902"}.bi-vignette::before{content:"\f903"}.bi-bluesky::before{content:"\f7f9"}.bi-tux::before{content:"\f904"}.bi-beaker-fill::before{content:"\f905"}.bi-beaker::before{content:"\f906"}.bi-flask-fill::before{content:"\f907"}.bi-flask-florence-fill::before{content:"\f908"}.bi-flask-florence::before{content:"\f909"}.bi-flask::before{content:"\f90a"}.bi-leaf-fill::before{content:"\f90b"}.bi-leaf::before{content:"\f90c"}.bi-measuring-cup-fill::before{content:"\f90d"}.bi-measuring-cup::before{content:"\f90e"}.bi-unlock2-fill::before{content:"\f90f"}.bi-unlock2::before{content:"\f910"}.bi-battery-low::before{content:"\f911"}.bi-anthropic::before{content:"\f912"}.bi-apple-music::before{content:"\f913"}.bi-claude::before{content:"\f914"}.bi-openai::before{content:"\f915"}.bi-perplexity::before{content:"\f916"}.bi-css::before{content:"\f917"}.bi-javascript::before{content:"\f918"}.bi-typescript::before{content:"\f919"}.bi-fork-knife::before{content:"\f91a"}.bi-globe-americas-fill::before{content:"\f91b"}.bi-globe-asia-australia-fill::before{content:"\f91c"}.bi-globe-central-south-asia-fill::before{content:"\f91d"}.bi-globe-europe-africa-fill::before{content:"\f91e"} \ No newline at end of file diff --git a/web/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 b/web/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 new file mode 100644 index 0000000..4d8c490 Binary files /dev/null and b/web/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 differ diff --git a/web/vendor/controlbar/ControlBar.js b/web/vendor/controlbar/ControlBar.js new file mode 100644 index 0000000..c95423f --- /dev/null +++ b/web/vendor/controlbar/ControlBar.js @@ -0,0 +1,1317 @@ +/** + * ControlBar.js - Reusable dash.js video control bar + * + * A self-contained, self-generating control bar component for dash.js. + * Generates its own DOM structure inside a consumer-provided container element. + * + * Features: play/pause, seekbar (div-based with buffer + played overlay), + * volume, mute, fullscreen, bitrate/track/caption menus, thumbnail preview, + * playback rate controls, auto-hide on hover with configurable timeout. + * + * Requirements: + * - dash.js MediaPlayer instance (global `dashjs` must be available) + * - Bootstrap Icons CSS loaded in the page + * - controlbar.css loaded in the page + * + * Usage: + * import { ControlBar } from './ControlBar.js'; + * const cb = new ControlBar(player, videoElement); + * cb.init(document.getElementById('my-container')); + * cb.enable(); + */ + +const HIDE_DELAY = 3000; + +// ---- Inline helpers (replaces UIHelpers.js dependency) ---- + +function formatTime(seconds) { + if (isNaN(seconds) || !isFinite(seconds)) { + return '00:00'; + } + const negative = seconds < 0; + seconds = Math.abs(Math.floor(seconds)); + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + const pad = (n) => String(n).padStart(2, '0'); + const prefix = negative ? '- ' : ''; + if (h > 0) { + return `${prefix}${pad(h)}:${pad(m)}:${pad(s)}`; + } + return `${prefix}${pad(m)}:${pad(s)}`; +} + +function createElement(tag, attrs = {}, ...children) { + const el = document.createElement(tag); + for (const [key, value] of Object.entries(attrs)) { + if (key === 'className') { + el.className = value; + } else if (key === 'textContent') { + el.textContent = value; + } else if (key === 'innerHTML') { + el.innerHTML = value; + } else if (key.startsWith('on') && typeof value === 'function') { + el.addEventListener(key.slice(2).toLowerCase(), value); + } else { + el.setAttribute(key, value); + } + } + for (const child of children) { + if (typeof child === 'string') { + el.appendChild(document.createTextNode(child)); + } else if (child) { + el.appendChild(child); + } + } + return el; +} + +export class ControlBar { + + /** + * @param {object} player - A dash.js MediaPlayer instance + * @param {HTMLVideoElement} videoElement - The video element managed by the player + */ + constructor(player, videoElement) { + this.player = player; + this.video = videoElement; + + // DOM references (populated by _buildDOM) + this.container = null; // the controlbar root element we create + this.wrapper = null; // the consumer-provided wrapper / container parent + this.playPauseBtn = null; + this.playPauseIcon = null; + this.seekBackBtn = null; + this.seekForwardBtn = null; + this.nextBtn = null; + this.timeDisplay = null; + this.durationDisplay = null; + this.seekbarContainer = null; + this.seekbarPlayed = null; + this.seekbarBuffer = null; + this.volumeSlider = null; + this.muteBtn = null; + this.muteIcon = null; + this.fullscreenBtn = null; + this.fullscreenIcon = null; + this.bitrateBtn = null; + this.bitrateMenu = null; + this.trackBtn = null; + this.trackMenu = null; + this.captionBtn = null; + this.captionMenu = null; + this.timeSeparator = null; + this.rateDownBtn = null; + this.rateUpBtn = null; + this.rateDisplay = null; + this.thumbnailContainer = null; + this.thumbnailElem = null; + this.thumbnailTimeLabel = null; + + // State + this._seeking = false; + this._hideTimer = null; + this._enabled = false; + this._duration = 0; + this._isDynamic = false; + this._vodDuration = null; + this._vodOffset = 0; + this._lastVolume = 1; + this._isFullscreen = false; + this._activeMenu = null; + this._selectionHandler = null; + this._seekHandler = null; + this._nextHandler = null; + this._domListeners = []; + this._playerListeners = []; + + // Bound handlers for cleanup + this._onMouseMove = this._onMouseMoveBound.bind(this); + this._onMouseLeave = this._onMouseLeaveBound.bind(this); + this._onFullscreenChange = this._onFullscreenChangeBound.bind(this); + this._onVideoClick = () => this._togglePlayPause(); + } + + /** + * Initialize the control bar: build DOM, inject into container, register events. + * + * @param {HTMLElement|string} wrapperElement - The wrapper element (or CSS selector) + * that contains the video element. The controlbar will be appended inside this + * wrapper. It should have `position: relative` so the controlbar overlays correctly. + */ + init(wrapperElement) { + if (typeof wrapperElement === 'string') { + this.wrapper = document.querySelector(wrapperElement); + } else { + this.wrapper = wrapperElement; + } + + if (!this.wrapper) { + throw new Error('ControlBar: wrapper element not found'); + } + + this._buildDOM(); + this.wrapper.appendChild(this.container); + + this._attachDOMEvents(); + this._attachPlayerEvents(); + + // Initial state + this.volumeSlider.value = 1; + this._updatePlayPauseIcon(); + } + + /** + * Enable the control bar (interactive). + */ + enable() { + this._enabled = true; + this.container.classList.remove('cb-disabled'); + } + + /** + * Disable the control bar (non-interactive, dimmed). + */ + disable() { + this._enabled = false; + this.container.classList.add('cb-disabled'); + this._closeAllMenus(); + } + + /** + * Reset control bar state (call on new stream load). + */ + reset() { + this._seeking = false; + this._duration = 0; + this._isDynamic = false; + this.seekbarPlayed.style.width = '0%'; + this.seekbarBuffer.style.width = '0%'; + this.timeDisplay.textContent = '00:00'; + this.durationDisplay.textContent = '00:00'; + this.durationDisplay.classList.remove('cb-live-indicator', 'cb-at-live-edge'); + if (this.timeSeparator) { + this.timeSeparator.classList.remove('cb-hidden-element'); + } + this._updateRateDisplay(1); + this._closeAllMenus(); + this._destroyMenus(); + } + + /** + * Programmatically set the control bar to muted or unmuted state. + * Updates slider and icon without touching the player (use syncMuteState for that). + * @param {boolean} muted + */ + setMuted(muted) { + if (muted) { + this._lastVolume = parseFloat(this.volumeSlider.value) || 1; + this.volumeSlider.value = 0; + } else { + this.volumeSlider.value = this._lastVolume || 1; + } + this.muteIcon.className = muted ? 'bi bi-volume-mute-fill' : 'bi bi-volume-up-fill'; + } + + /** + * Re-apply the control bar's current volume/mute state to the player. + * Call after a new source is attached to keep mute state in sync. + */ + syncMuteState() { + try { + const vol = parseFloat(this.volumeSlider.value); + const isMuted = vol === 0 || this.muteIcon.className === 'bi bi-volume-mute-fill'; + this.player.setVolume(isMuted ? 0 : vol); + this.player.setMute(isMuted); + } catch (e) { + // Player not ready + } + } + + refreshTracks() { + this._rebuildTrackMenu(); + this._rebuildCaptionMenu(); + } + + setVodDuration(seconds, offset = 0) { + const duration = Number(seconds); + this._vodDuration = Number.isFinite(duration) && duration > 0 ? duration : null; + const start = Number(offset); + this._vodOffset = Number.isFinite(start) && start > 0 ? start : 0; + } + + onSeekRequested(handler) { + this._seekHandler = typeof handler === 'function' ? handler : null; + } + + onNextRequested(handler) { + this._nextHandler = typeof handler === 'function' ? handler : null; + } + + setNextAvailable(available) { + this.nextBtn?.classList.toggle('cb-hidden-element', !available); + } + + onTrackSelected(handler) { + this._selectionHandler = typeof handler === 'function' ? handler : null; + } + + _notifyTrackSelected(type, track) { + if (this._selectionHandler) { + this._selectionHandler(type, track); + } + } + + /** + * Destroy the control bar: remove all event listeners and remove DOM. + */ + destroy() { + this._detachDOMEvents(); + this._detachPlayerEvents(); + this._closeAllMenus(); + this._destroyMenus(); + clearTimeout(this._hideTimer); + this._setCloseButtonHidden(false); + if (this.container && this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + } + + // ================================================================ + // DOM generation + // ================================================================ + + _buildDOM() { + // Thumbnail preview + this.thumbnailElem = createElement('div', { className: 'cb-thumbnail-elem' }); + this.thumbnailTimeLabel = createElement('div', { className: 'cb-thumbnail-time' }); + this.thumbnailContainer = createElement('div', { className: 'cb-thumbnail-container cb-hidden-element' }, + this.thumbnailElem, + this.thumbnailTimeLabel + ); + + // Seekbar + this.seekbarBuffer = createElement('div', { className: 'cb-seekbar-buffer', style: 'width: 0%' }); + this.seekbarPlayed = createElement('div', { className: 'cb-seekbar-played', style: 'width: 0%' }); + this.seekbarContainer = createElement('div', { className: 'cb-seekbar' }, + this.seekbarBuffer, + this.seekbarPlayed + ); + const seekbarRow = createElement('div', { className: 'cb-seekbar-row' }, this.seekbarContainer); + + // Play / Pause + this.playPauseIcon = createElement('i', { className: 'bi bi-play-fill' }); + this.playPauseBtn = createElement('button', { className: 'cb-btn', title: 'Play/Pause' }, this.playPauseIcon); + this.seekBackBtn = createElement('button', { + className: 'cb-btn cb-skip-btn', title: 'Back 15 seconds', textContent: '-15' + }); + this.seekForwardBtn = createElement('button', { + className: 'cb-btn cb-skip-btn', title: 'Forward 15 seconds', textContent: '+15' + }); + this.nextBtn = createElement('button', { className: 'cb-btn cb-hidden-element', title: 'Next file' }, + createElement('i', { className: 'bi bi-skip-forward-fill' }) + ); + + // Time display + this.timeDisplay = createElement('span', { className: 'cb-time', textContent: '00:00' }); + this.timeSeparator = createElement('span', { className: 'cb-time cb-time-separator', textContent: '/' }); + this.durationDisplay = createElement('span', { className: 'cb-time', textContent: '00:00' }); + + // Spacer + const spacer = createElement('div', { className: 'cb-spacer' }); + + // Volume / Mute + this.muteIcon = createElement('i', { className: 'bi bi-volume-up-fill' }); + this.muteBtn = createElement('button', { className: 'cb-btn', title: 'Mute/Unmute' }, this.muteIcon); + this.volumeSlider = createElement('input', { + type: 'range', + className: 'cb-volume-slider', + min: '0', + max: '1', + step: '0.05', + value: '1' + }); + const volumeGroup = createElement('div', { className: 'cb-volume-group' }, + this.muteBtn, + this.volumeSlider + ); + + // Playback rate + this.rateDownBtn = createElement('button', { className: 'cb-btn cb-btn-sm', title: 'Decrease speed' }, + createElement('i', { className: 'bi bi-dash' }) + ); + this.rateDisplay = createElement('span', { className: 'cb-rate-display', title: 'Reset to 1x', textContent: '1.00x' }); + this.rateUpBtn = createElement('button', { className: 'cb-btn cb-btn-sm', title: 'Increase speed' }, + createElement('i', { className: 'bi bi-plus' }) + ); + const rateGroup = createElement('div', { className: 'cb-rate-group' }, + this.rateDownBtn, + this.rateDisplay, + this.rateUpBtn + ); + + // Bitrate menu + this.bitrateMenu = createElement('div', { className: 'cb-menu cb-hidden-element' }); + this.bitrateBtn = createElement('button', { className: 'cb-btn cb-hidden-element', title: 'Quality' }, + createElement('i', { className: 'bi bi-sliders' }) + ); + const bitrateAnchor = createElement('div', { className: 'cb-menu-anchor' }, + this.bitrateBtn, + this.bitrateMenu + ); + + // Track menu + this.trackMenu = createElement('div', { className: 'cb-menu cb-hidden-element' }); + this.trackBtn = createElement('button', { className: 'cb-btn cb-hidden-element', title: 'Tracks' }, + createElement('i', { className: 'bi bi-music-note-list' }) + ); + const trackAnchor = createElement('div', { className: 'cb-menu-anchor' }, + this.trackBtn, + this.trackMenu + ); + + // Caption menu + this.captionMenu = createElement('div', { className: 'cb-menu cb-hidden-element' }); + this.captionBtn = createElement('button', { className: 'cb-btn cb-hidden-element', title: 'Captions' }, + createElement('i', { className: 'bi bi-badge-cc' }) + ); + const captionAnchor = createElement('div', { className: 'cb-menu-anchor' }, + this.captionBtn, + this.captionMenu + ); + + // Fullscreen + this.fullscreenIcon = createElement('i', { className: 'bi bi-fullscreen' }); + this.fullscreenBtn = createElement('button', { className: 'cb-btn', title: 'Fullscreen' }, this.fullscreenIcon); + + // Controls row + const controlsRow = createElement('div', { className: 'cb-controls-row' }, + this.playPauseBtn, + this.seekBackBtn, + this.seekForwardBtn, + this.nextBtn, + this.timeDisplay, + this.timeSeparator, + this.durationDisplay, + spacer, + volumeGroup, + rateGroup, + bitrateAnchor, + trackAnchor, + captionAnchor, + this.fullscreenBtn + ); + + // Root container + this.container = createElement('div', { className: 'cb-controlbar cb-disabled' }, + this.thumbnailContainer, + seekbarRow, + controlsRow + ); + } + + // ================================================================ + // DOM event handlers + // ================================================================ + + _attachDOMEvents() { + this._listen(this.playPauseBtn, 'click', () => this._togglePlayPause()); + this._listen(this.seekBackBtn, 'click', () => this._requestRelativeSeek(-15)); + this._listen(this.seekForwardBtn, 'click', () => this._requestRelativeSeek(15)); + this._listen(this.nextBtn, 'click', () => this._nextHandler?.()); + this._listen(this.muteBtn, 'click', () => this._toggleMute()); + this._listen(this.fullscreenBtn, 'click', () => this._toggleFullscreen()); + this._listen(this.volumeSlider, 'input', () => this._onVolumeInput()); + this._listen(this.video, 'click', this._onVideoClick); + + // Live indicator click — seek to live edge + this._listen(this.durationDisplay, 'click', () => this._seekToLiveEdge()); + + // Playback rate controls + this._listen(this.rateDownBtn, 'click', () => this._changeRate(-0.25)); + this._listen(this.rateUpBtn, 'click', () => this._changeRate(0.25)); + this._listen(this.rateDisplay, 'click', () => this._resetRate()); + + // Seekbar mouse events + this._listen(this.seekbarContainer, 'mousedown', (e) => this._onSeekMouseDown(e)); + this._listen(this.seekbarContainer, 'mousemove', (e) => this._onSeekMouseMove(e)); + this._listen(this.seekbarContainer, 'mouseleave', () => this._onSeekMouseLeave()); + this._listen(document, 'mousemove', (e) => this._onDocumentMouseMove(e)); + this._listen(document, 'mouseup', (e) => this._onDocumentMouseUp(e)); + + // Touch events for seekbar + this._listen(this.seekbarContainer, 'touchstart', (e) => this._onSeekTouchStart(e), { passive: false }); + this._listen(this.seekbarContainer, 'touchmove', (e) => this._onSeekTouchMove(e), { passive: false }); + this._listen(this.seekbarContainer, 'touchend', (e) => this._onSeekTouchEnd(e)); + + // Auto-hide: show on mouse move over wrapper, hide after delay + this._listen(this.wrapper, 'mousemove', this._onMouseMove); + this._listen(this.wrapper, 'mouseleave', this._onMouseLeave); + this._listen(this.wrapper, 'touchstart', this._onMouseMove, { passive: true }); + + // Fullscreen change + this._listen(document, 'fullscreenchange', this._onFullscreenChange); + this._listen(document, 'webkitfullscreenchange', this._onFullscreenChange); + + // Bitrate / track / caption buttons + this._listen(this.bitrateBtn, 'click', (e) => { + e.stopPropagation(); + this._toggleMenu('bitrate'); + }); + this._listen(this.trackBtn, 'click', (e) => { + e.stopPropagation(); + this._toggleMenu('track'); + }); + this._listen(this.captionBtn, 'click', (e) => { + e.stopPropagation(); + this._toggleMenu('caption'); + }); + + // Close menus on click outside + this._listen(document, 'click', () => this._closeAllMenus()); + + // Prevent control bar clicks from closing menus + this._listen(this.container, 'click', (e) => e.stopPropagation()); + } + + _detachDOMEvents() { + this._domListeners.forEach(({ target, type, handler, options }) => { + target.removeEventListener(type, handler, options); + }); + this._domListeners = []; + } + + _listen(target, type, handler, options) { + target.addEventListener(type, handler, options); + this._domListeners.push({ target, type, handler, options }); + } + + _attachPlayerEvents() { + const events = dashjs.MediaPlayer.events; + + this._listenPlayer(events.PLAYBACK_STARTED, () => this._onPlaybackStarted()); + this._listenPlayer(events.PLAYBACK_PAUSED, () => this._onPlaybackPaused()); + this._listenPlayer(events.PLAYBACK_TIME_UPDATED, (e) => this._onTimeUpdate(e)); + this._listenPlayer(events.STREAM_ACTIVATED, () => this._onStreamActivated()); + this._listenPlayer(events.STREAM_TEARDOWN_COMPLETE, () => this._onStreamTeardown()); + this._listenPlayer(events.BUFFER_LEVEL_UPDATED, (e) => this._onBufferLevelUpdated(e)); + this._listenPlayer(events.TEXT_TRACKS_ADDED, (e) => this._onTextTracksAdded(e)); + + if (dashjs.Protection && dashjs.Protection.events) { + this._listenPlayer(dashjs.Protection.events.KEY_STATUSES_MAP_UPDATED, () => this._rebuildBitrateMenu()); + } + } + + _detachPlayerEvents() { + this._playerListeners.forEach(({ event, handler }) => this.player.off(event, handler)); + this._playerListeners = []; + } + + _listenPlayer(event, handler) { + this.player.on(event, handler); + this._playerListeners.push({ event, handler }); + } + + // ================================================================ + // Play / Pause + // ================================================================ + + _togglePlayPause() { + if (!this._enabled) { + return; + } + if (this.player.isPaused()) { + this.player.play(); + } else { + this.player.pause(); + } + } + + _updatePlayPauseIcon() { + if (!this.playPauseIcon) { + return; + } + try { + const isPaused = this.player.isPaused(); + this.playPauseIcon.className = isPaused ? 'bi bi-play-fill' : 'bi bi-pause-fill'; + } catch (e) { + // Player not ready yet — default to play icon + this.playPauseIcon.className = 'bi bi-play-fill'; + } + } + + _onPlaybackStarted() { + this._updatePlayPauseIcon(); + this._startHideTimer(); + } + + _onPlaybackPaused() { + this._updatePlayPauseIcon(); + this._showControlBar(); + this._clearHideTimer(); + } + + // ================================================================ + // Time / Duration + // ================================================================ + + _onTimeUpdate() { + if (this._seeking) { + return; + } + + try { + const time = this._vodOffset + this._sessionTime(); + const duration = this._vodDuration || this.player.duration(); + this._duration = duration; + this._isDynamic = this.player.isDynamic() && !this._vodDuration; + + if (this._isDynamic) { + // Show negative latency (e.g. -00:20) instead of DVR position + const liveLatency = this.player.getCurrentLiveLatency() || 0; + this.timeDisplay.textContent = `-${formatTime(Math.round(liveLatency))}`; + + this.durationDisplay.innerHTML = ' LIVE'; + this.durationDisplay.classList.add('cb-live-indicator'); + + // Hide separator for live + if (this.timeSeparator) { + this.timeSeparator.classList.add('cb-hidden-element'); + } + + // Check if at live edge + const targetDelay = this.player.getTargetLiveDelay() || 0; + const atLiveEdge = liveLatency <= targetDelay + 1; + this.durationDisplay.classList.toggle('cb-at-live-edge', atLiveEdge); + } else { + this.timeDisplay.textContent = formatTime(time); + this.durationDisplay.textContent = formatTime(duration); + this.durationDisplay.classList.remove('cb-live-indicator', 'cb-at-live-edge'); + + // Show separator for VoD + if (this.timeSeparator) { + this.timeSeparator.classList.remove('cb-hidden-element'); + } + } + + // Update seekbar progress + if (duration > 0) { + const pct = (time / duration) * 100; + this.seekbarPlayed.style.width = `${Math.min(pct, 100)}%`; + } + + // Sync rate display with actual playback rate + const actualRate = this.player.getPlaybackRate(); + if (actualRate !== undefined && actualRate !== null) { + this._updateRateDisplay(actualRate); + } + } catch (err) { + // Player may not be ready + } + } + + _seekToLiveEdge() { + if (!this._enabled || !this._isDynamic) { + return; + } + try { + this.player.seekToOriginalLive(); + } catch (e) { + // Fallback: seek to duration (end of DVR window) + try { + this.player.seek(this.player.duration()); + } catch (e2) { + // Player not ready + } + } + } + + // ================================================================ + // Playback Rate + // ================================================================ + + _changeRate(delta) { + if (!this._enabled) { + return; + } + try { + const current = this.player.getPlaybackRate() || 1; + const next = Math.min(4, Math.max(0.25, Math.round((current + delta) * 4) / 4)); + this.player.setPlaybackRate(next); + this._updateRateDisplay(next); + } catch (e) { + // Player not ready + } + } + + _resetRate() { + if (!this._enabled) { + return; + } + try { + this.player.setPlaybackRate(1); + this._updateRateDisplay(1); + } catch (e) { + // Player not ready + } + } + + _updateRateDisplay(rate) { + if (this.rateDisplay) { + this.rateDisplay.textContent = `${rate.toFixed(2)}x`; + } + } + + // ================================================================ + // Seekbar + // ================================================================ + + _getSeekTime(clientX) { + const rect = this.seekbarContainer.getBoundingClientRect(); + let pct = (clientX - rect.left) / rect.width; + pct = Math.max(0, Math.min(1, pct)); + return pct * (this._duration || 0); + } + + _currentAbsoluteTime() { + return this._vodOffset + this._sessionTime(); + } + + _sessionTime() { + const time = Number(this.video.currentTime); + return Number.isFinite(time) && time > 0 ? time : 0; + } + + _requestRelativeSeek(delta) { + this._requestSeek(this._currentAbsoluteTime() + delta); + } + + _requestSeek(time) { + const maximum = this._vodDuration || this.player.duration(); + const target = Math.max(0, Math.min(time, Math.max(0, maximum - 0.1))); + if (this._seekHandler) { + this._seekHandler(target); + } else { + this.player.seek(Math.max(0, target - this._vodOffset)); + } + } + + _onSeekMouseDown(e) { + if (!this._enabled) { + return; + } + this._seeking = true; + e.preventDefault(); + } + + _onDocumentMouseMove(e) { + if (!this._seeking) { + return; + } + const time = this._getSeekTime(e.clientX); + const pct = (this._duration > 0) ? (time / this._duration) * 100 : 0; + this.seekbarPlayed.style.width = `${pct}%`; + this.timeDisplay.textContent = formatTime(time); + } + + _onDocumentMouseUp(e) { + if (!this._seeking) { + return; + } + this._seeking = false; + const time = this._getSeekTime(e.clientX); + this._requestSeek(time); + } + + _onSeekMouseMove(e) { + if (!this._enabled || !this._duration) { + return; + } + const time = this._getSeekTime(e.clientX); + this._showThumbnail(e.clientX, time); + } + + _onSeekMouseLeave() { + this._hideThumbnail(); + } + + _onSeekTouchStart(e) { + if (!this._enabled) { + return; + } + this._seeking = true; + e.preventDefault(); + } + + _onSeekTouchMove(e) { + if (!this._seeking || !e.touches[0]) { + return; + } + e.preventDefault(); + const time = this._getSeekTime(e.touches[0].clientX); + const pct = (this._duration > 0) ? (time / this._duration) * 100 : 0; + this.seekbarPlayed.style.width = `${pct}%`; + this.timeDisplay.textContent = formatTime(time); + } + + _onSeekTouchEnd(e) { + if (!this._seeking) { + return; + } + this._seeking = false; + if (e.changedTouches && e.changedTouches[0]) { + const time = this._getSeekTime(e.changedTouches[0].clientX); + this._requestSeek(time); + } + } + + // ================================================================ + // Thumbnails + // ================================================================ + + _showThumbnail(clientX, time) { + if (!this.player.provideThumbnail) { + return; + } + + this.player.provideThumbnail(time, (thumbnail) => { + if (!thumbnail || !thumbnail.url) { + this._hideThumbnail(); + return; + } + + const containerRect = this.seekbarContainer.getBoundingClientRect(); + const wrapperRect = this.wrapper.getBoundingClientRect(); + + // Scale thumbnail + const maxHeight = wrapperRect.height * 0.15; + const scale = Math.min(maxHeight / thumbnail.height, 2); + const width = thumbnail.width * scale; + const height = thumbnail.height * scale; + + this.thumbnailElem.style.width = `${thumbnail.width}px`; + this.thumbnailElem.style.height = `${thumbnail.height}px`; + this.thumbnailElem.style.background = `url("${thumbnail.url}") -${thumbnail.x}px -${thumbnail.y}px`; + this.thumbnailElem.style.backgroundSize = ''; + this.thumbnailElem.style.transform = `scale(${scale})`; + + // Position horizontally centered on mouse + let left = clientX - containerRect.left - width / 2; + left = Math.max(0, Math.min(left, containerRect.width - width)); + + this.thumbnailContainer.style.left = `${left}px`; + this.thumbnailContainer.style.bottom = `${containerRect.height + 10}px`; + this.thumbnailTimeLabel.textContent = formatTime(time); + this.thumbnailContainer.classList.remove('cb-hidden-element'); + }); + } + + _hideThumbnail() { + if (this.thumbnailContainer) { + this.thumbnailContainer.classList.add('cb-hidden-element'); + } + } + + // ================================================================ + // Buffer + // ================================================================ + + _onBufferLevelUpdated(e) { + if (!e || e.mediaType !== 'video' || !this._duration || this._isDynamic) { + return; + } + try { + const dashMetrics = this.player.getDashMetrics(); + const bufferLevel = dashMetrics.getCurrentBufferLevel('video', true) || 0; + const time = this._vodOffset + this._sessionTime(); + const bufferEnd = time + bufferLevel; + const pct = (bufferEnd / this._duration) * 100; + this.seekbarBuffer.style.width = `${Math.min(pct, 100)}%`; + } catch (err) { + // ignore + } + } + + // ================================================================ + // Volume / Mute + // ================================================================ + + _onVolumeInput() { + const val = parseFloat(this.volumeSlider.value); + try { + this.player.setVolume(val); + this.player.setMute(val === 0); + } catch (e) { + // Player not ready + } + this._updateMuteIcon(); + } + + _toggleMute() { + try { + if (this.player.isMuted()) { + this.player.setMute(false); + this.player.setVolume(this._lastVolume || 1); + this.volumeSlider.value = this._lastVolume || 1; + } else { + this._lastVolume = this.player.getVolume(); + this.player.setMute(true); + this.volumeSlider.value = 0; + } + } catch (e) { + // Player not ready + } + this._updateMuteIcon(); + } + + _updateMuteIcon() { + try { + const muted = this.player.isMuted() || parseFloat(this.volumeSlider.value) === 0; + this.muteIcon.className = muted ? 'bi bi-volume-mute-fill' : 'bi bi-volume-up-fill'; + } catch (e) { + // Player not ready — default to unmuted icon + this.muteIcon.className = 'bi bi-volume-up-fill'; + } + } + + // ================================================================ + // Fullscreen + // ================================================================ + + _toggleFullscreen() { + if (!document.fullscreenElement && !document.webkitFullscreenElement) { + if (this.wrapper.requestFullscreen) { + this.wrapper.requestFullscreen(); + } else if (this.wrapper.webkitRequestFullscreen) { + this.wrapper.webkitRequestFullscreen(); + } + } else { + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } + } + } + + _onFullscreenChangeBound() { + this._isFullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement); + this.fullscreenIcon.className = this._isFullscreen + ? 'bi bi-fullscreen-exit' + : 'bi bi-fullscreen'; + this.wrapper.classList.toggle('cb-fullscreen', this._isFullscreen); + } + + // ================================================================ + // Auto-hide + // ================================================================ + + _onMouseMoveBound() { + this._showControlBar(); + this._startHideTimer(); + } + + _onMouseLeaveBound() { + try { + if (!this.player.isPaused()) { + this._startHideTimer(); + } + } catch (e) { + // Player not ready + } + } + + _showControlBar() { + this.container.classList.remove('cb-hidden'); + this._setCloseButtonHidden(false); + this.wrapper.style.cursor = ''; + } + + _hideControlBar() { + try { + if (this.player.isPaused() || this._seeking || this._activeMenu) { + return; + } + } catch (e) { + return; + } + this.container.classList.add('cb-hidden'); + this._setCloseButtonHidden(true); + this.wrapper.style.cursor = 'none'; + } + + _setCloseButtonHidden(hidden) { + const playerShell = this.wrapper.closest('.player'); + const closeButton = playerShell && playerShell.querySelector('.player-close'); + const title = playerShell && playerShell.querySelector('.player-title'); + if (closeButton) { + closeButton.classList.toggle('player-close-hidden', hidden); + } + if (title) { + title.classList.toggle('player-title-hidden', hidden); + } + } + + _startHideTimer() { + this._clearHideTimer(); + try { + if (this.player.isPaused()) { + return; + } + } catch (e) { + return; + } + this._hideTimer = setTimeout(() => this._hideControlBar(), HIDE_DELAY); + } + + _clearHideTimer() { + if (this._hideTimer) { + clearTimeout(this._hideTimer); + this._hideTimer = null; + } + } + + // ================================================================ + // Stream events + // ================================================================ + + _onStreamActivated() { + this._rebuildBitrateMenu(); + this._rebuildTrackMenu(); + this._rebuildCaptionMenu(); + + // Update duration + try { + this._duration = this._vodDuration || this.player.duration(); + this._isDynamic = this.player.isDynamic() && !this._vodDuration; + } catch (err) { + // ignore + } + } + + _onStreamTeardown() { + this.reset(); + } + + _onTextTracksAdded() { + this._rebuildCaptionMenu(); + } + + // ================================================================ + // Menus + // ================================================================ + + _toggleMenu(menuName) { + const menuEl = this._getMenuElement(menuName); + if (!menuEl) { + return; + } + + if (this._activeMenu === menuName) { + this._closeAllMenus(); + } else { + this._closeAllMenus(); + menuEl.classList.remove('cb-hidden-element'); + this._activeMenu = menuName; + } + } + + _closeAllMenus() { + for (const name of ['bitrate', 'track', 'caption']) { + const menuEl = this._getMenuElement(name); + if (menuEl) { + menuEl.classList.add('cb-hidden-element'); + } + } + this._activeMenu = null; + } + + _getMenuElement(name) { + switch (name) { + case 'bitrate': + return this.bitrateMenu; + case 'track': + return this.trackMenu; + case 'caption': + return this.captionMenu; + } + return null; + } + + _destroyMenus() { + if (this.bitrateMenu) { + this.bitrateMenu.innerHTML = ''; + } + if (this.trackMenu) { + this.trackMenu.innerHTML = ''; + } + if (this.captionMenu) { + this.captionMenu.innerHTML = ''; + } + } + + _rebuildBitrateMenu() { + if (!this.bitrateMenu) { + return; + } + this.bitrateMenu.innerHTML = ''; + + let hasItems = false; + + for (const type of ['video', 'audio']) { + try { + const reps = this.player.getRepresentationsByType(type); + if (!reps || reps.length < 1) { + continue; + } + + hasItems = true; + const title = createElement('div', { className: 'cb-menu-title' }, type.charAt(0).toUpperCase() + type.slice(1)); + this.bitrateMenu.appendChild(title); + + // Auto switch option + const autoItem = createElement('div', { + className: 'cb-menu-item cb-menu-item-selected', + textContent: 'Auto', + onClick: () => { + this.player.updateSettings({ + streaming: { abr: { autoSwitchBitrate: { [type]: true } } } + }); + this._rebuildBitrateMenu(); + } + }); + this.bitrateMenu.appendChild(autoItem); + + const settings = this.player.getSettings(); + const autoSwitch = settings?.streaming?.abr?.autoSwitchBitrate?.[type] !== false; + const currentRep = this.player.getCurrentRepresentationForType(type); + + reps.forEach((rep, idx) => { + const bitrate = Math.round(rep.bandwidth / 1000); + let label = `${bitrate} kbps`; + if (rep.width && rep.height) { + label += ` (${rep.width}x${rep.height})`; + } + + const isCurrent = !autoSwitch && currentRep && currentRep.id === rep.id; + const item = createElement('div', { + className: `cb-menu-item ${isCurrent ? 'cb-menu-item-selected' : ''}`, + textContent: label, + onClick: () => { + this.player.updateSettings({ + streaming: { abr: { autoSwitchBitrate: { [type]: false } } } + }); + this.player.setRepresentationForTypeByIndex(type, idx, false); + this._rebuildBitrateMenu(); + } + }); + this.bitrateMenu.appendChild(item); + }); + + // Update auto item class + if (!autoSwitch) { + autoItem.classList.remove('cb-menu-item-selected'); + } + + } catch (err) { + // ignore + } + } + + // Show/hide button + if (this.bitrateBtn) { + this.bitrateBtn.classList.toggle('cb-hidden-element', !hasItems); + } + } + + _rebuildTrackMenu() { + if (!this.trackMenu) { + return; + } + this.trackMenu.innerHTML = ''; + + let hasItems = false; + + for (const type of ['audio', 'video']) { + try { + const tracks = this.player.getTracksFor(type); + if (!tracks || tracks.length < 1) { + continue; + } + + hasItems = true; + const title = createElement('div', { className: 'cb-menu-title' }, type.charAt(0).toUpperCase() + type.slice(1)); + this.trackMenu.appendChild(title); + + const currentTrack = this.player.getCurrentTrackFor(type); + + tracks.forEach((track) => { + let label = ''; + + // Prefer explicit label if available + if (track.labels && track.labels.length > 0) { + const textLabel = track.labels.find(l => l.text) || track.labels[0]; + if (textLabel && textLabel.text) { + label = textLabel.text; + } + } + + // Fall back to language, then to media type name + if (!label) { + label = track.lang || type.charAt(0).toUpperCase() + type.slice(1); + } + + const isCurrent = currentTrack && this._isTracksEqual(currentTrack, track); + const item = createElement('div', { + className: `cb-menu-item ${isCurrent ? 'cb-menu-item-selected' : ''}`, + textContent: label, + onClick: () => { + this.player.setCurrentTrack(track); + this._notifyTrackSelected(type, track); + this._rebuildTrackMenu(); + } + }); + this.trackMenu.appendChild(item); + }); + } catch (err) { + // ignore + } + } + + if (this.trackBtn) { + this.trackBtn.classList.toggle('cb-hidden-element', !hasItems); + } + } + + _isTracksEqual(t1, t2) { + try { + if (!t1 || !t2) { + return false; + } + return t1.id === t2.id + && t1.lang === t2.lang + && t1.viewpoint === t2.viewpoint + && String(t1.roles || '') === String(t2.roles || '') + && String(t1.accessibility || '') === String(t2.accessibility || '') + && String(t1.audioChannelConfiguration || '') === String(t2.audioChannelConfiguration || ''); + } catch (e) { + return false; + } + } + + _formatChannels(track) { + if (!track.audioChannelConfiguration || !track.audioChannelConfiguration.length) { + return null; + } + const value = track.audioChannelConfiguration[0].value; + switch (value) { + case '1': + return 'mono'; + case '2': + return 'stereo'; + case '6': + return '5.1'; + case '8': + return '7.1'; + default: + return value ? `${value}ch` : null; + } + } + + _rebuildCaptionMenu() { + if (!this.captionMenu) { + return; + } + this.captionMenu.innerHTML = ''; + + try { + // Prefer dash.js tracks. dash.js also exposes them as native TextTracks, + // but selecting those directly bypasses its text timeline controller. + const tracks = this.player.getTracksFor('text'); + if (!tracks || tracks.length === 0) { + this._rebuildNativeCaptionMenu(); + return; + } + + const currentIdx = this.player.getCurrentTextTrackIndex(); + + // OFF option + const offItem = createElement('div', { + className: `cb-menu-item ${currentIdx === -1 ? 'cb-menu-item-selected' : ''}`, + textContent: 'Off', + onClick: () => { + this.player.setTextTrack(-1); + this._notifyTrackSelected('text', null); + this._rebuildCaptionMenu(); + } + }); + this.captionMenu.appendChild(offItem); + + for (let i = 0; i < tracks.length; i++) { + const track = tracks[i]; + + // Build label: prefer Label element text, fall back to lang + let label = ''; + if (track.labels && track.labels.length > 0) { + const textLabel = track.labels.find(l => l.text); + if (textLabel) { + label = textLabel.text; + } + } + if (!label) { + label = track.lang || 'Und'; + } + + const isCurrent = currentIdx === i; + const item = createElement('div', { + className: `cb-menu-item ${isCurrent ? 'cb-menu-item-selected' : ''}`, + textContent: label, + onClick: () => { + this.player.setTextTrack(i); + this._notifyTrackSelected('text', track); + this._rebuildCaptionMenu(); + } + }); + this.captionMenu.appendChild(item); + } + + if (this.captionBtn) { + this.captionBtn.classList.toggle('cb-hidden-element', tracks.length === 0); + } + } catch (err) { + if (this.captionBtn) { + this.captionBtn.classList.add('cb-hidden-element'); + } + } + } + + _rebuildNativeCaptionMenu() { + const tracks = Array.from(this.video.textTracks || []); + if (!tracks.length) { + this.captionBtn?.classList.add('cb-hidden-element'); + return; + } + + const currentIdx = tracks.findIndex(track => track.mode === 'showing'); + const select = (selected) => { + tracks.forEach((track, index) => { + track.mode = index === selected ? 'showing' : 'disabled'; + }); + this._notifyTrackSelected('nativeText', selected >= 0 ? tracks[selected] : null); + this._rebuildCaptionMenu(); + }; + this.captionMenu.appendChild(createElement('div', { + className: `cb-menu-item ${currentIdx === -1 ? 'cb-menu-item-selected' : ''}`, + textContent: 'Off', + onClick: () => select(-1) + })); + tracks.forEach((track, index) => { + this.captionMenu.appendChild(createElement('div', { + className: `cb-menu-item ${currentIdx === index ? 'cb-menu-item-selected' : ''}`, + textContent: track.label || track.language || `Subtitle ${index + 1}`, + onClick: () => select(index) + })); + }); + this.captionBtn?.classList.remove('cb-hidden-element'); + } +} diff --git a/web/vendor/controlbar/controlbar.css b/web/vendor/controlbar/controlbar.css new file mode 100644 index 0000000..3b5e78e --- /dev/null +++ b/web/vendor/controlbar/controlbar.css @@ -0,0 +1,339 @@ +/* ============================================================ + dash.js Controlbar – Standalone CSS + ============================================================ + Include this stylesheet alongside ControlBar.js. + Requires Bootstrap Icons CSS for icon display. + + Theming: override --cb-accent and --cb-danger on the + .cb-controlbar selector (or any ancestor) to customise + highlight and live-indicator colours. + ============================================================ */ + +/* ---- Default theme variables ---- */ +.cb-controlbar { + --cb-accent: #5b8def; + --cb-danger: #e74c3c; +} + +.cb-skip-btn { + min-width: 2.5rem; + font-size: 0.8rem; + font-weight: 700; +} + +/* ---- Utility: hidden element (replaces Bootstrap d-none) ---- */ +.cb-hidden-element { + display: none !important; +} + +/* ---- Control Bar container ---- */ +.cb-controlbar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.85)); + padding: 2rem 1rem 0.5rem 1rem; + z-index: 10; + transition: opacity 0.3s ease, visibility 0.3s ease; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.cb-controlbar.cb-hidden { + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.cb-controlbar.cb-disabled { + pointer-events: none; + opacity: 0.3; +} + +/* ---- Seekbar ---- */ +.cb-seekbar-row { + display: flex; + align-items: center; + gap: 0.5rem; + position: relative; +} + +.cb-seekbar { + flex: 1; + height: 4px; + background: rgba(255, 255, 255, 0.2); + border-radius: 2px; + position: relative; + cursor: pointer; + transition: height 0.1s; +} + +.cb-seekbar:hover { + height: 6px; +} + +.cb-seekbar-buffer { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: rgba(255, 255, 255, 0.3); + border-radius: 2px; + pointer-events: none; +} + +.cb-seekbar-played { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--cb-accent); + border-radius: 2px; + pointer-events: none; +} + +.cb-seekbar-played::after { + content: ''; + position: absolute; + right: -6px; + top: 50%; + transform: translateY(-50%); + width: 12px; + height: 12px; + background: var(--cb-accent); + border-radius: 50%; + opacity: 0; + transition: opacity 0.1s; +} + +.cb-seekbar:hover .cb-seekbar-played::after { + opacity: 1; +} + +/* ---- Control buttons row ---- */ +.cb-controls-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.cb-btn { + background: none; + border: none; + color: #fff; + font-size: 1.1rem; + cursor: pointer; + padding: 0.25rem; + opacity: 0.85; + transition: opacity 0.15s; + display: flex; + align-items: center; + justify-content: center; +} + +.cb-btn:hover { + opacity: 1; +} + +.cb-time { + font-size: 0.8rem; + color: #fff; + font-variant-numeric: tabular-nums; + white-space: nowrap; + user-select: none; +} + +.cb-time-separator { + opacity: 0.4; +} + +.cb-spacer { + flex: 1; +} + +/* ---- Volume ---- */ +.cb-volume-group { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.cb-volume-slider { + width: 80px; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.3); + border-radius: 2px; + outline: none; + cursor: pointer; +} + +.cb-volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + background: #fff; + border-radius: 50%; + cursor: pointer; +} + +.cb-volume-slider::-moz-range-thumb { + width: 12px; + height: 12px; + background: #fff; + border-radius: 50%; + cursor: pointer; + border: none; +} + +/* ---- Playback rate controls ---- */ +.cb-rate-group { + display: flex; + align-items: center; + gap: 0.15rem; + margin: 0 0.25rem; +} + +.cb-rate-display { + color: #e0e0e0; + font-size: 0.75rem; + min-width: 2.5rem; + text-align: center; + cursor: pointer; + user-select: none; + line-height: 1; +} + +.cb-rate-display:hover { + color: #ffffff; +} + +.cb-btn-sm { + font-size: 0.7rem; + padding: 0.1rem 0.3rem; +} + +/* ---- Live indicator ---- */ +.cb-live-indicator { + cursor: pointer; + color: #8a8aa0; +} + +.cb-live-indicator.cb-at-live-edge { + color: var(--cb-danger); +} + +.cb-live-indicator .bi-circle-fill { + font-size: 0.5rem; + vertical-align: middle; +} + +/* ---- Thumbnail preview ---- */ +.cb-thumbnail-container { + position: absolute; + bottom: 100%; + pointer-events: none; + z-index: 20; + text-align: center; +} + +.cb-thumbnail-elem { + border: 2px solid #fff; + border-radius: 4px; + overflow: hidden; + transform-origin: center bottom; +} + +.cb-thumbnail-time { + font-size: 0.7rem; + color: #fff; + background: rgba(0, 0, 0, 0.7); + padding: 1px 6px; + border-radius: 3px; + margin-top: 4px; + display: inline-block; +} + +/* ---- Menu anchors and menus ---- */ +.cb-menu-anchor { + position: relative; +} + +/* Menus — always dark (overlays video) */ +.cb-menu { + position: absolute; + bottom: 100%; + right: 0; + background: rgba(20, 20, 40, 0.95); + border: 1px solid #2d2d50; + border-radius: 6px; + min-width: 180px; + max-height: min(400px, 60vh); + overflow-y: auto; + padding: 0.25rem 0; + margin-bottom: 0.5rem; + backdrop-filter: blur(10px); + z-index: 30; +} + +/* Scrollbar styling for menus */ +.cb-menu::-webkit-scrollbar { + width: 6px; +} + +.cb-menu::-webkit-scrollbar-track { + background: transparent; +} + +.cb-menu::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; +} + +.cb-menu::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.35); +} + +/* Firefox scrollbar */ +.cb-menu { + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.2) transparent; +} + +.cb-menu-title { + padding: 0.35rem 0.75rem; + font-size: 0.7rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.cb-menu-item { + padding: 0.35rem 0.75rem; + font-size: 0.8rem; + cursor: pointer; + color: #e0e0e0; + transition: background 0.1s; +} + +.cb-menu-item:hover { + background: rgba(255, 255, 255, 0.1); +} + +.cb-menu-item-selected { + color: var(--cb-accent); + font-weight: 600; +} + +.cb-menu-item-selected::before { + content: '✓ '; +} + +/* ---- Fullscreen wrapper helper ---- */ +.cb-fullscreen { + /* Applied to the wrapper element when entering fullscreen */ +}