commit 15cf412840ffeb16b8ab15fbe994aa726a912ec6 Author: Théo Barnouin Date: Wed Nov 13 16:41:51 2024 +0100 first commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6cb4b9c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true +charset = utf-8 + +[*.{build,yml,ui,yaml,css,blp}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..283f09d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.po diff=po diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..cdb8d7b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,39 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**General information:** + - Distribution: + - Installation method [e. g. built from source, installed from Flathub...]: + - Version [e.g. 0.1.0]: + - Device used [e. g. desktop, phone...]: + +**Stack trace:** +If applicable, run the application from a terminal and paste relevant log output. +``` +flatpak run --env=RUST_BACKTRACE=full dev.alextren.Spot +``` + +**Additional context** +Add any other context about the problem here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c1ce3c3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "cargo" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/spot-development.yml b/.github/workflows/spot-development.yml new file mode 100644 index 0000000..9a25fc1 --- /dev/null +++ b/.github/workflows/spot-development.yml @@ -0,0 +1,22 @@ +name: spot-development + +on: + pull_request: + branches: [development] + workflow_dispatch: + +jobs: + flatpak-builder: + name: "Flatpak Builder" + runs-on: ubuntu-latest + container: + image: bilelmoussaoui/flatpak-github-actions:gnome-nightly + options: --privileged + steps: + - uses: actions/checkout@v2 + - uses: bilelmoussaoui/flatpak-github-actions/flatpak-builder@v4 + with: + bundle: "spot.flatpak" + manifest-path: "dev.alextren.Spot.development.json" + cache-key: flatpak-builder-${{ github.sha }} + run-tests: true diff --git a/.github/workflows/spot-quality.yml b/.github/workflows/spot-quality.yml new file mode 100644 index 0000000..984b43f --- /dev/null +++ b/.github/workflows/spot-quality.yml @@ -0,0 +1,36 @@ +name: spot-quality + +on: + push: + pull_request: + +jobs: + ci-check: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + default: true + components: rustfmt + + - name: Add empty config.rs + run: | + echo >> src/config.rs + + - name: Format + uses: actions-rs/cargo@v1 + with: + command: fmt + args: --all -- --check + + shellcheck: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + - run: | + sudo apt-get -y update && sudo apt-get -y install shellcheck + find $GITHUB_WORKSPACE -type f -and \( -name "*.sh" \) | xargs shellcheck diff --git a/.github/workflows/spot-snapshots.yml b/.github/workflows/spot-snapshots.yml new file mode 100644 index 0000000..d3cc56d --- /dev/null +++ b/.github/workflows/spot-snapshots.yml @@ -0,0 +1,24 @@ +name: spot-snapshots + +on: + push: + branches: [master] + pull_request: + branches: [master] + workflow_dispatch: + +jobs: + flatpak-builder: + name: "Flatpak Builder" + runs-on: ubuntu-latest + container: + image: bilelmoussaoui/flatpak-github-actions:gnome-42 + options: --privileged + steps: + - uses: actions/checkout@v2 + - uses: bilelmoussaoui/flatpak-github-actions/flatpak-builder@v4 + with: + bundle: "spot.flatpak" + manifest-path: "dev.alextren.Spot.snapshots.json" + cache-key: flatpak-builder-${{ github.sha }} + run-tests: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a15d90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target/ +/doc/target +/cargo/ +/build/ +/.flatpak-builder/ +/src/config.rs +/subprojects/blueprint-compiler diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..49d86c6 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,51 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "options": { + "env": { + "RUST_BACKTRACE": "1", + "RUST_LOG": "spot=debug", + "LANG": "C", + //"https_proxy": "localhost:8080" + } + }, + "tasks": [ + { + "label": "meson", + "type": "shell", + "command": "meson setup target -Dbuildtype=debug -Doffline=false --prefix=\"$HOME/.local\"" + }, + { + "label": "build", + "type": "shell", + "command": "ninja install -C target", + "problemMatcher": [], + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "clear": true + } + }, + { + "label": "run", + "type": "shell", + "command": "$HOME/.local/bin/spot", + "presentation": { + "reveal": "always", + "clear": true + } + }, + { + "label": "test", + "type": "shell", + "command": "meson test -C target --verbose", + "group": { + "kind": "test", + "isDefault": true + } + } + ] +} \ No newline at end of file diff --git a/ARTISTS b/ARTISTS new file mode 100644 index 0000000..d2b8884 --- /dev/null +++ b/ARTISTS @@ -0,0 +1,2 @@ +Tobias Bernard +Noëlle diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..f7dad66 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,22 @@ +Alexandre Trendel +Noëlle +Armin Begert +Douile +Diego Augusto +TotalDarkness-NRF +Seioo Inoue +xRMG412 +realJavabot +Gabriele Musco +Alistair Francis +Daniel Peukert +Nils Tonnätt +Niklas Sauter +Nicolas Fella +Fridolin Weisser +Jan Przebor +Warren Hu +bbb651 +Julius Rüberg +janbrummer +Alisson Lauffer diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ccf3967 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5651 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "aes" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" +dependencies = [ + "cfg-if", + "cipher 0.3.0", + "cpufeatures", + "opaque-debug", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.6.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23a1e53f0f5d86382dafe1cf314783b2044280f406e7e1506368220ad11b1338" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8365de52b16c035ff4fcafe0092ba9390540e3e352870ac09933bebcaa2c8c56" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +dependencies = [ + "anstyle", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c042108f3ed77fd83760a5fd79b53be043192bb3b9dba91d8c574c0ada7850c8" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-broadcast" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c48ccdbf6ca6b121e0f586cbc0e73ae440e56c67c30fa0873b4e110d9c26d2b" +dependencies = [ + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-executor" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0c4a4f319e45986f347ee47fef8bf5e81c9abc3f6f58dc2391439f30df65f0" +dependencies = [ + "async-lock", + "async-task", + "concurrent-queue", + "fastrand 2.0.1", + "futures-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "279cf904654eeebfa37ac9bb1598880884924aab82e290aa65c9e77a0e142e06" +dependencies = [ + "async-lock", + "autocfg", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-global-executor" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1b6f5d7df27bd294849f8eec66ecfc63d11814df7a4f5d74168a2394467b776" +dependencies = [ + "async-channel", + "async-executor", + "async-io 1.13.0", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" +dependencies = [ + "async-lock", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-lite", + "log", + "parking", + "polling 2.8.0", + "rustix 0.37.27", + "slab", + "socket2 0.4.10", + "waker-fn", +] + +[[package]] +name = "async-io" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da8f3146014722c89e7859e1d7bb97873125d7346d10ca642ffab794355828" +dependencies = [ + "async-lock", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling 3.3.0", + "rustix 0.38.21", + "slab", + "tracing", + "waker-fn", + "windows-sys 0.48.0", +] + +[[package]] +name = "async-lock" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" +dependencies = [ + "event-listener 2.5.3", +] + +[[package]] +name = "async-process" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6438ba0a08d81529c69b36700fa2f95837bfe3e776ab39cde9c14d9149da88" +dependencies = [ + "async-io 1.13.0", + "async-lock", + "async-signal", + "blocking", + "cfg-if", + "event-listener 3.0.1", + "futures-lite", + "rustix 0.38.21", + "windows-sys 0.48.0", +] + +[[package]] +name = "async-recursion" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd55a5ba1179988837d24ab4c7cc8ed6efdeff578ede0416b4225a5fca35bd0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "async-signal" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e47d90f65a225c4527103a8d747001fc56e375203592b25ad103e1ca13124c5" +dependencies = [ + "async-io 2.1.0", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 0.38.21", + "signal-hook-registry", + "slab", + "windows-sys 0.48.0", +] + +[[package]] +name = "async-std" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62565bb4402e926b29953c785397c6dc0391b7b446e45008b0049eb43cec6f5d" +dependencies = [ + "async-channel", + "async-global-executor", + "async-io 1.13.0", + "async-lock", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-task" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4eb2cdb97421e01129ccb49169d8279ed21e829929144f4a22a6e54ac549ca1" + +[[package]] +name = "async-trait" +version = "0.1.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atomic_refcell" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e67cd8309bbd06cd603a9e693a784ac2e5d1e955f11286e355089fcab3047c" + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "aws-lc-rs" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd82dba44d209fddb11c190e0a94b78651f95299598e472215667417a03ff1d" +dependencies = [ + "aws-lc-sys", + "mirai-annotations", + "paste", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7a4168111d7eb622a31b214057b8509c0a7e1794f44c546d742330dc793972" +dependencies = [ + "bindgen 0.69.5", + "cc", + "cmake", + "dunce", + "fs_extra", + "libc", + "paste", +] + +[[package]] +name = "backtrace" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bindgen" +version = "0.68.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726e4313eb6ec35d2730258ad4e15b547ee75d6afaa1361a922e78e59b7d8078" +dependencies = [ + "bitflags 2.6.0", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.85", +] + +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags 2.6.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.85", + "which", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-modes" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cb03d1bed155d89dce0f845b7899b18a9a163e148fd004e1c28421a783e2d8e" +dependencies = [ + "block-padding", + "cipher 0.3.0", +] + +[[package]] +name = "block-padding" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d696c370c750c948ada61c69a0ee2cbbb9c50b1019ddb86d9317157a99c2cae" + +[[package]] +name = "blocking" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c36a4d0d48574b3dd360b4b7d95cc651d2b6557b6402848a27d4b228a473e2a" +dependencies = [ + "async-channel", + "async-lock", + "async-task", + "fastrand 2.0.1", + "futures-io", + "futures-lite", + "piper", + "tracing", +] + +[[package]] +name = "bumpalo" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" + +[[package]] +name = "bytemuck" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8334215b81e418a0a7bdb8ef0849474f40bb10c8b71f1c4ed315cff49f32494d" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" + +[[package]] +name = "cairo-rs" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3603c4028a5e368d09b51c8b624b9a46edcd7c3778284077a6125af73c9f0a" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "glib 0.17.10", + "libc", + "once_cell", + "thiserror", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "691d0c66b1fb4881be80a760cb8fe76ea97218312f9dfe2c9cc0f496ca279cb1" +dependencies = [ + "glib-sys 0.17.10", + "libc", + "system-deps 6.2.0", +] + +[[package]] +name = "castaway" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6" + +[[package]] +name = "cc" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e8aabfac534be767c909e0690571677d49f41bd8465ae876fe043d52ba5292" +dependencies = [ + "jobserver", + "libc", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-expr" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03915af431787e6ffdcc74c645077518c6b6e01f80b761e0fbbfa288536311b3" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-expr" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0890061c4d3223e7267f3bad2ec40b997d64faac1c2815a4a9d95018e2b9e9c" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets 0.48.5", +] + +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c688fc74432808e3eb684cae8830a86be1d66a2bd58e1f248ed0960a590baf6f" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e43aa7fd152b1f968787f7dbcdeb306d1867ff373c69955211876c053f91a" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "combine" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f057a694a54f12365049b0958a1685bb52d567f5593b355fbf685838e873d400" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8478e5bdad14dce236b9898ea002eabfa87cbe14f0aa538dbe3b6a4bec4332d" +dependencies = [ + "bindgen 0.68.1", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + +[[package]] +name = "cpufeatures" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce420fe07aecd3e67c5f910618fe65e94158f6dcc0adf44e00d69ce2bdfe0fd0" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "curl" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509bd11746c7ac09ebd19f0b17782eae80aadee26237658a6b4808afb5c11a22" +dependencies = [ + "curl-sys", + "libc", + "openssl-probe", + "openssl-sys", + "schannel", + "socket2 0.4.10", + "winapi", +] + +[[package]] +name = "curl-sys" +version = "0.4.68+curl-8.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a0d18d88360e374b16b2273c832b5e57258ffc1d4aa4f96b108e0738d5752f" +dependencies = [ + "cc", + "libc", + "libnghttp2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", + "windows-sys 0.48.0", +] + +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.85", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" + +[[package]] +name = "der" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.85", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "encoding_rs" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enumflags2" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5998b4f30320c9d93aed72f63af821bfdac50465b75428fce77b48ec482c3939" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f95e2801cd355d4a1a3e3953ce6ee5ae9603a5c833455343a8bfe3f44d418246" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "env_filter" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +dependencies = [ + "log", +] + +[[package]] +name = "env_logger" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "env_logger" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3e13f66a2f95e32a39eaa81f6b95d42878ca0e1db0c7543723dfe12557e860" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01cec0252c2afff729ee6f00e903d479fba81784c8e2bd77447673471fdfaea1" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "fastrand" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset 0.9.0", + "rustc_version", +] + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0290714b38af9b4a7b094b8a37086d1b4e61f2df9122c3cad2577669145335" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f4fb8693db0cf099eadcca0efe2a5a22e4550f98ed16aba6c48700da29597bc" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695d6bc846438c5708b07007537b9274d883373dd30858ca881d7d71b5540717" +dependencies = [ + "bitflags 1.3.2", + "gdk-pixbuf-sys", + "gio", + "glib 0.17.10", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9285ec3c113c66d7d0ab5676599176f1f42f4944ca1b581852215bf5694870cb" +dependencies = [ + "gio-sys 0.17.10", + "glib-sys 0.17.10", + "gobject-sys 0.17.10", + "libc", + "system-deps 6.2.0", +] + +[[package]] +name = "gdk4" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3abf96408a26e3eddf881a7f893a1e111767137136e347745e8ea6ed12731ff" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib 0.17.10", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc92aa1608c089c49393d014c38ac0390d01e4841e1fedaa75dbcef77aaed64" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys 0.17.10", + "glib-sys 0.17.10", + "gobject-sys 0.17.10", + "libc", + "pango-sys", + "pkg-config", + "system-deps 6.2.0", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "gettext-rs" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49ea8a8fad198aaa1f9655a2524b64b70eb06b2f3ff37da407566c93054f364" +dependencies = [ + "gettext-sys", + "locale_config", +] + +[[package]] +name = "gettext-sys" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c63ce2e00f56a206778276704bbe38564c8695249fdc8f354b4ef71c57c3839d" +dependencies = [ + "cc", + "temp-dir", +] + +[[package]] +name = "gimli" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" + +[[package]] +name = "gio" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6973e92937cf98689b6a054a9e56c657ed4ff76de925e36fc331a15f0c5d30a" +dependencies = [ + "bitflags 1.3.2", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys 0.17.10", + "glib 0.17.10", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror", +] + +[[package]] +name = "gio-sys" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ccf87c30a12c469b6d958950f6a9c09f2be20b7773f7e70d20b867fdf2628c3" +dependencies = [ + "glib-sys 0.17.10", + "gobject-sys 0.17.10", + "libc", + "system-deps 6.2.0", + "winapi", +] + +[[package]] +name = "gio-sys" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f7efc368de04755344f0084104835b6bb71df2c1d41e37d863947392a894779" +dependencies = [ + "glib-sys 0.20.4", + "gobject-sys 0.20.4", + "libc", + "system-deps 7.0.3", + "windows-sys 0.52.0", +] + +[[package]] +name = "glib" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fad45ba8d4d2cea612b432717e834f48031cd8853c8aaf43b2c79fec8d144b" +dependencies = [ + "bitflags 1.3.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys 0.17.10", + "glib-macros 0.17.10", + "glib-sys 0.17.10", + "gobject-sys 0.17.10", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror", +] + +[[package]] +name = "glib" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcf1ec6d3650bf9fdbc6cee242d4fcebc6f6bfd9bea5b929b6a8b7344eb85ff" +dependencies = [ + "bitflags 2.6.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys 0.20.4", + "glib-macros 0.20.4", + "glib-sys 0.20.4", + "gobject-sys 0.20.4", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca5c79337338391f1ab8058d6698125034ce8ef31b72a442437fa6c8580de26" +dependencies = [ + "anyhow", + "heck 0.4.1", + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "glib-macros" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6bf88f70cd5720a6197639dcabcb378dd528d0cb68cb1f45e3b358bcb841cd7" +dependencies = [ + "heck 0.5.0", + "proc-macro-crate 3.2.0", + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "glib-sys" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d80aa6ea7bba0baac79222204aa786a6293078c210abe69ef1336911d4bdc4f0" +dependencies = [ + "libc", + "system-deps 6.2.0", +] + +[[package]] +name = "glib-sys" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f9eca5d88cfa6a453b00d203287c34a2b7cac3a7831779aa2bb0b3c7233752b" +dependencies = [ + "libc", + "system-deps 7.0.3", +] + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "gloo-timers" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gobject-sys" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd34c3317740a6358ec04572c1bcfd3ac0b5b6529275fae255b237b314bb8062" +dependencies = [ + "glib-sys 0.17.10", + "libc", + "system-deps 6.2.0", +] + +[[package]] +name = "gobject-sys" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4c674d2ff8478cf0ec29d2be730ed779fef54415a2fb4b565c52def62696462" +dependencies = [ + "glib-sys 0.20.4", + "libc", + "system-deps 7.0.3", +] + +[[package]] +name = "governor" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b" +dependencies = [ + "cfg-if", + "futures", + "futures-timer", + "no-std-compat", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "rand", + "smallvec", + "spinning_top", +] + +[[package]] +name = "graphene-rs" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def4bb01265b59ed548b05455040d272d989b3012c42d4c1bbd39083cb9b40d9" +dependencies = [ + "glib 0.17.10", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1856fc817e6a6675e36cea0bd9a3afe296f5d9709d1e2d3182803ac77f0ab21d" +dependencies = [ + "glib-sys 0.17.10", + "libc", + "pkg-config", + "system-deps 6.2.0", +] + +[[package]] +name = "gsk4" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f01ef44fa7cac15e2da9978529383e6bee03e570ba5bf7036b4c10a15cc3a3c" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk4", + "glib 0.17.10", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07a84fb4dcf1323d29435aa85e2f5f58bef564342bef06775ec7bd0da1f01b0" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys 0.17.10", + "gobject-sys 0.17.10", + "graphene-sys", + "libc", + "pango-sys", + "system-deps 6.2.0", +] + +[[package]] +name = "gstreamer" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecf3bcfc2ceb82ce02437f53ff2fcaee5e7d45ae697ab64a018408749779b9" +dependencies = [ + "cfg-if", + "futures-channel", + "futures-core", + "futures-util", + "glib 0.20.4", + "gstreamer-sys", + "itertools 0.13.0", + "libc", + "muldiv", + "num-integer", + "num-rational", + "once_cell", + "option-operations", + "paste", + "pin-project-lite", + "smallvec", + "thiserror", +] + +[[package]] +name = "gstreamer-app" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a4ec9f0d2037349c82f589c1cbfe788a62f4941851924bb7c3929a6a790007" +dependencies = [ + "futures-core", + "futures-sink", + "glib 0.20.4", + "gstreamer", + "gstreamer-app-sys", + "gstreamer-base", + "libc", +] + +[[package]] +name = "gstreamer-app-sys" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d5cac633c1ab7030c777c8c58c682a0c763bbc4127bccc370dabe39c01a12d" +dependencies = [ + "glib-sys 0.20.4", + "gstreamer-base-sys", + "gstreamer-sys", + "libc", + "system-deps 7.0.3", +] + +[[package]] +name = "gstreamer-audio" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d39b07213f83055fc705a384fa32ad581776b8e5b04c86f3a419ec5dfc0f81" +dependencies = [ + "cfg-if", + "glib 0.20.4", + "gstreamer", + "gstreamer-audio-sys", + "gstreamer-base", + "libc", + "once_cell", + "smallvec", +] + +[[package]] +name = "gstreamer-audio-sys" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d84744e7ac8f8bc0cf76b7be40f2d5be12e6cf197e4c6ca9d3438109c21e2f51" +dependencies = [ + "glib-sys 0.20.4", + "gobject-sys 0.20.4", + "gstreamer-base-sys", + "gstreamer-sys", + "libc", + "system-deps 7.0.3", +] + +[[package]] +name = "gstreamer-base" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46ce7330d2995138a77192ea20961422ddee1578e1a47480acb820c43ceb0e2d" +dependencies = [ + "atomic_refcell", + "cfg-if", + "glib 0.20.4", + "gstreamer", + "gstreamer-base-sys", + "libc", +] + +[[package]] +name = "gstreamer-base-sys" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7796e694c21c215447811c9cff694dce1fc6e02b0bbafb75cd8583b6aefe9e5f" +dependencies = [ + "glib-sys 0.20.4", + "gobject-sys 0.20.4", + "gstreamer-sys", + "libc", + "system-deps 7.0.3", +] + +[[package]] +name = "gstreamer-sys" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb3859929db32f26a35818d0d9ed82f0887c9221ca402ddefaea2bb99833d535" +dependencies = [ + "glib-sys 0.20.4", + "gobject-sys 0.20.4", + "libc", + "system-deps 7.0.3", +] + +[[package]] +name = "gtk4" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b28a32a04cd75cef14a0983f8b0c669e0fe152a0a7725accdeb594e2c764c88b" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib 0.17.10", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "once_cell", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a4d6b61570f76d3ee542d984da443b1cd69b6105264c61afec3abed08c2500f" +dependencies = [ + "anyhow", + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "gtk4-sys" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f8283f707b07e019e76c7f2934bdd4180c277e08aa93f4c0d8dd07b7a34e22f" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys 0.17.10", + "glib-sys 0.17.10", + "gobject-sys 0.17.10", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps 6.2.0", +] + +[[package]] +name = "h2" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.9", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.1.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" + +[[package]] +name = "headers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9" +dependencies = [ + "base64 0.21.5", + "bytes", + "headers-core", + "http 1.1.0", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.1.0", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "hostname" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba" +dependencies = [ + "cfg-if", + "libc", + "windows 0.52.0", +] + +[[package]] +name = "http" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +dependencies = [ + "bytes", + "http 0.2.9", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.1.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "hyper" +version = "0.14.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.26", + "http 0.2.9", + "http-body 0.4.5", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.4.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbff0a806a4728c99295b254c8838933b5b082d75e3cb70c8dab21fdfbcfa9a" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2 0.4.6", + "http 1.1.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-proxy2" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9043b7b23fb0bc4a1c7014c27b50a4fc42cc76206f71d34fc0dfe5b28ddc3faf" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http 1.1.0", + "hyper 1.5.0", + "hyper-rustls 0.26.0", + "hyper-util", + "pin-project-lite", + "rustls-native-certs 0.7.3", + "tokio", + "tokio-rustls 0.25.0", + "tower-service", + "webpki", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.9", + "hyper 0.14.27", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" +dependencies = [ + "futures-util", + "http 1.1.0", + "hyper 1.5.0", + "hyper-util", + "log", + "rustls 0.22.4", + "rustls-native-certs 0.7.3", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.25.0", + "tower-service", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" +dependencies = [ + "futures-util", + "http 1.1.0", + "hyper 1.5.0", + "hyper-util", + "log", + "rustls 0.23.15", + "rustls-native-certs 0.8.0", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.0", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.1.0", + "http-body 1.0.1", + "hyper 1.5.0", + "pin-project-lite", + "socket2 0.5.5", + "tokio", + "tower", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core 0.51.1", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "if-addrs" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2a33e9c38988ecbda730c85b0fd9ddcdf83c0305ac7fd21c8bb9f57f2f0cc8" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "indexmap" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "ipnet" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-terminal" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" +dependencies = [ + "hermit-abi", + "rustix 0.38.21", + "windows-sys 0.48.0", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "isahc" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9" +dependencies = [ + "async-channel", + "castaway", + "crossbeam-utils", + "curl", + "curl-sys", + "encoding_rs", + "event-listener 2.5.3", + "futures-lite", + "http 0.2.9", + "log", + "mime", + "once_cell", + "polling 2.8.0", + "serde", + "serde_json", + "slab", + "sluice", + "tracing", + "tracing-futures", + "url", + "waker-fn", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54c0c35952f67de54bb584e9fd912b3023117cbafc0a77d8f3dee1fb5f572fe8" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin 0.5.2", +] + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libadwaita" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ab9c0843f9f23ff25634df2743690c3a1faffe0a190e60c490878517eb81abf" +dependencies = [ + "bitflags 1.3.2", + "gdk-pixbuf", + "gdk4", + "gio", + "glib 0.17.10", + "gtk4", + "libadwaita-sys", + "libc", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4231cb2499a9f0c4cdfa4885414b33e39901ddcac61150bc0bb4ff8a57ede404" +dependencies = [ + "gdk4-sys", + "gio-sys 0.17.10", + "glib-sys 0.17.10", + "gobject-sys 0.17.10", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps 6.2.0", +] + +[[package]] +name = "libc" +version = "0.2.161" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + +[[package]] +name = "libmdns" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48854699e11b111433431b69cee2365fcab0b29b06993f48c257dfbaf6395862" +dependencies = [ + "byteorder", + "futures-util", + "hostname", + "if-addrs", + "log", + "multimap", + "rand", + "socket2 0.5.5", + "thiserror", + "tokio", +] + +[[package]] +name = "libnghttp2-sys" +version = "0.1.8+1.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fae956c192dadcdb5dace96db71fa0b827333cce7c7b38dc71446f024d8a340" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "libpulse-binding" +version = "2.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3557a2dfc380c8f061189a01c6ae7348354e0c9886038dc6c171219c08eaff" +dependencies = [ + "bitflags 1.3.2", + "libc", + "libpulse-sys", + "num-derive 0.3.3", + "num-traits", + "winapi", +] + +[[package]] +name = "libpulse-simple-binding" +version = "2.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05fd6b68f33f6a251265e6ed1212dc3107caad7c5c6fdcd847b2e65ef58c308d" +dependencies = [ + "libpulse-binding", + "libpulse-simple-sys", + "libpulse-sys", +] + +[[package]] +name = "libpulse-simple-sys" +version = "1.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6613b4199d8b9f0edcfb623e020cb17bbd0bee8dd21f3c7cc938de561c4152" +dependencies = [ + "libpulse-sys", + "pkg-config", +] + +[[package]] +name = "libpulse-sys" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc19e110fbf42c17260d30f6d3dc545f58491c7830d38ecb9aaca96e26067a9b" +dependencies = [ + "libc", + "num-derive 0.3.3", + "num-traits", + "pkg-config", + "winapi", +] + +[[package]] +name = "librespot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea500bb5673fbf4cc9dcbb7afdc228bf0cdb434cc3a0be79afa6bb385a8b5f8" +dependencies = [ + "data-encoding", + "env_logger 0.11.5", + "futures-util", + "getopts", + "librespot-audio", + "librespot-connect", + "librespot-core", + "librespot-discovery", + "librespot-metadata", + "librespot-oauth", + "librespot-playback", + "librespot-protocol", + "log", + "sha1", + "sysinfo", + "thiserror", + "tokio", + "url", +] + +[[package]] +name = "librespot-audio" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fbda070a5598b32718e497f585f46891f7113e64aff20a13c0f2ba8fe7ccad9" +dependencies = [ + "aes 0.8.4", + "bytes", + "ctr", + "futures-util", + "http-body-util", + "hyper 1.5.0", + "hyper-util", + "librespot-core", + "log", + "parking_lot", + "tempfile", + "thiserror", + "tokio", +] + +[[package]] +name = "librespot-connect" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699745eaeff6f53b67e93fe973eb72c818cba58d10cfdbdd4c1bf7893f9b705b" +dependencies = [ + "form_urlencoded", + "futures-util", + "librespot-core", + "librespot-playback", + "librespot-protocol", + "log", + "protobuf", + "rand", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", +] + +[[package]] +name = "librespot-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "505a5ddd966231755994b60435607a1e8ae1d41c7f1169b078e0511bfb82d931" +dependencies = [ + "aes 0.8.4", + "base64 0.22.1", + "byteorder", + "bytes", + "data-encoding", + "form_urlencoded", + "futures-core", + "futures-util", + "governor", + "hmac", + "http 1.1.0", + "http-body-util", + "httparse", + "hyper 1.5.0", + "hyper-proxy2", + "hyper-rustls 0.27.3", + "hyper-util", + "librespot-oauth", + "librespot-protocol", + "log", + "nonzero_ext", + "num-bigint", + "num-derive 0.4.2", + "num-integer", + "num-traits", + "once_cell", + "parking_lot", + "pbkdf2", + "pin-project-lite", + "priority-queue", + "protobuf", + "quick-xml", + "rand", + "rsa", + "serde", + "serde_json", + "sha1", + "shannon", + "sysinfo", + "thiserror", + "time", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tokio-util", + "url", + "uuid", + "vergen-gitcl", +] + +[[package]] +name = "librespot-discovery" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abca1d9420179c0875189cee50b1cd75ad91c6180f38d079eae3a62a40b4745f" +dependencies = [ + "aes 0.8.4", + "base64 0.22.1", + "bytes", + "ctr", + "form_urlencoded", + "futures-core", + "futures-util", + "hmac", + "http-body-util", + "hyper 1.5.0", + "hyper-util", + "libmdns", + "librespot-core", + "log", + "rand", + "serde_json", + "sha1", + "thiserror", + "tokio", +] + +[[package]] +name = "librespot-metadata" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a10ab5a390f65281e763cd09c617b173f0e665994eae3d242526924625fdc66" +dependencies = [ + "async-trait", + "bytes", + "librespot-core", + "librespot-protocol", + "log", + "protobuf", + "serde", + "serde_json", + "thiserror", + "uuid", +] + +[[package]] +name = "librespot-oauth" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57bda94233b358fb41c04ed15507c61136c80efe876c6e05a10ddb9a182b144e" +dependencies = [ + "log", + "oauth2", + "thiserror", + "url", +] + +[[package]] +name = "librespot-playback" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b1bcfe1d72c5ac14c798c7e3e1c20e1fb6af2b9c254794545cfcb1f2a4627e2" +dependencies = [ + "alsa", + "cpal", + "futures-util", + "gstreamer", + "gstreamer-app", + "gstreamer-audio", + "libpulse-binding", + "libpulse-simple-binding", + "librespot-audio", + "librespot-core", + "librespot-metadata", + "log", + "parking_lot", + "rand", + "rand_distr", + "rodio", + "shell-words", + "symphonia", + "thiserror", + "tokio", + "zerocopy", +] + +[[package]] +name = "librespot-protocol" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6f343f573e0469d3ff8a02b99bbd9789faa01e2ff167332542ac840a8b31e7" +dependencies = [ + "protobuf", + "protobuf-codegen", +] + +[[package]] +name = "libz-sys" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97137b25e321a73eef1418d1d5d2eda4d77e12813f8e6dead84bc52c5870a7b" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + +[[package]] +name = "linux-raw-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da2479e8c062e40bf0066ffa0bc823de0a9368974af99c9f6df941d2c231e03f" + +[[package]] +name = "locale_config" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d2c35b16f4483f6c26f0e4e9550717a2f6575bcd6f12a53ff0c490a94a6934" +dependencies = [ + "lazy_static", + "objc", + "objc-foundation", + "regex", + "winapi", +] + +[[package]] +name = "lock_api" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +dependencies = [ + "value-bag", +] + +[[package]] +name = "mach2" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709" +dependencies = [ + "libc", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +dependencies = [ + "hermit-abi", + "libc", + "wasi", + "windows-sys 0.52.0", +] + +[[package]] +name = "mirai-annotations" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9be0862c1b3f26a88803c4a49de6889c10e608b3ee9344e6ef5b45fb37ad3d1" + +[[package]] +name = "muldiv" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956787520e75e9bd233246045d19f42fb73242759cc57fba9611d940ae96d4b0" + +[[package]] +name = "multimap" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" +dependencies = [ + "serde", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.6.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "thiserror", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", +] + +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + +[[package]] +name = "num" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05180d69e3da0e530ba2a1dae5110317e49e3b7f3d41be227dc5f92e49ee7af" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", + "rand", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ba157ca0885411de85d6ca030ba7e2a83a28636056c7c699b07c8b6f7383214" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" +dependencies = [ + "autocfg", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +dependencies = [ + "proc-macro-crate 3.2.0", + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "oauth2" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" +dependencies = [ + "base64 0.13.1", + "chrono", + "getrandom", + "http 0.2.9", + "rand", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror", + "url", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "object" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +dependencies = [ + "memchr", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk", + "ndk-context", + "num-derive 0.4.2", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" + +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + +[[package]] +name = "open" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61a877bf6abd716642a53ef1b89fb498923a4afca5c754f9050b4d081c05c4b3" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f55da20b29f956fb01f0add8683eb26ee13ebe3ebd935e49898717c6b4b2830" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-operations" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c26d27bb1aeab65138e4bf7666045169d1717febcc9ff870166be8348b223d0" +dependencies = [ + "paste", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35be456fc620e61f62dff7ff70fbd54dcbaf0a4b920c0f16de1107c47d921d48" +dependencies = [ + "bitflags 1.3.2", + "gio", + "glib 0.17.10", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3da69f9f3850b0d8990d462f8c709561975e95f689c1cdf0fecdebde78b35195" +dependencies = [ + "glib-sys 0.17.10", + "gobject-sys 0.17.10", + "libc", + "system-deps 6.2.0", +] + +[[package]] +name = "parking" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +dependencies = [ + "backtrace", + "cfg-if", + "libc", + "petgraph", + "redox_syscall", + "smallvec", + "thread-id", + "windows-targets 0.48.5", +] + +[[package]] +name = "paste" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" + +[[package]] +name = "pathdiff" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d61c5ce1153ab5b689d0c074c4e7fc613e942dfb7dd9eea5ab202d2ad91fe361" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" + +[[package]] +name = "petgraph" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d3afd2628e69da2be385eb6f2fd57c8ac7977ceeff6dc166ff1657b0e386a9" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda4ed1c6c173e3fc7a83629421152e01d7b1f9b7f65fb301e490e8cfc656422" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668d31b1c4eba19242f2088b2bf3316b82ca31082a8335764db4e083db7485d4" +dependencies = [ + "atomic-waker", + "fastrand 2.0.1", + "futures-io", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" + +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + +[[package]] +name = "polling" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e53b6af1f60f36f8c2ac2aad5459d75a5a9b4be1e8cdd40264f315d78193e531" +dependencies = [ + "cfg-if", + "concurrent-queue", + "pin-project-lite", + "rustix 0.38.21", + "tracing", + "windows-sys 0.48.0", +] + +[[package]] +name = "portable-atomic" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "prettyplease" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" +dependencies = [ + "proc-macro2", + "syn 2.0.85", +] + +[[package]] +name = "priority-queue" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714c75db297bc88a63783ffc6ab9f830698a6705aa0201416931759ef4c8183d" +dependencies = [ + "autocfg", + "equivalent", + "indexmap", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +dependencies = [ + "toml_edit 0.22.22", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "protobuf" +version = "3.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a7c64d9bf75b1b8d981124c14c179074e8caa7dfe7b6a12e6222ddcd0c8f72" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror", +] + +[[package]] +name = "protobuf-codegen" +version = "3.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e26b833f144769a30e04b1db0146b2aaa53fd2fd83acf10a6b5f996606c18144" +dependencies = [ + "anyhow", + "once_cell", + "protobuf", + "protobuf-parse", + "regex", + "tempfile", + "thiserror", +] + +[[package]] +name = "protobuf-parse" +version = "3.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322330e133eab455718444b4e033ebfac7c6528972c784fcde28d2cc783c6257" +dependencies = [ + "anyhow", + "indexmap", + "log", + "protobuf", + "protobuf-support", + "tempfile", + "thiserror", + "which", +] + +[[package]] +name = "protobuf-support" +version = "3.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b088fd20b938a875ea00843b6faf48579462630015c3788d397ad6a786663252" +dependencies = [ + "thiserror", +] + +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "ref_filter_map" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b5ceb840e4009da4841ed22a15eb49f64fdd00a2138945c5beacf506b2fb5ed" + +[[package]] +name = "regex" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.5", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.26", + "http 0.2.9", + "http-body 0.4.5", + "hyper 0.14.27", + "hyper-rustls 0.24.2", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-rustls 0.24.1", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin 0.9.8", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rodio" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb" +dependencies = [ + "cpal", + "thiserror", +] + +[[package]] +name = "rsa" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.37.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea8ca367a3a01fe35e6943c400addf443c0f57670e6ec51196f71a4b8762dd2" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys 0.3.8", + "windows-sys 0.48.0", +] + +[[package]] +name = "rustix" +version = "0.38.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b426b0506e5d50a7d8dafcf2e81471400deb602392c7dd110815afb4eaf02a3" +dependencies = [ + "bitflags 2.6.0", + "errno", + "libc", + "linux-raw-sys 0.4.10", + "windows-sys 0.48.0", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fbb44d7acc4e873d613422379f69f237a1b141928c02f6bc6ccfddddc2d7993" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile 2.2.0", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcaf18a4f2be7326cd874a5fa579fae794320a0f388d365dca7e480e55f83f8a" +dependencies = [ + "openssl-probe", + "rustls-pemfile 2.2.0", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.5", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" + +[[package]] +name = "ryu" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88" +dependencies = [ + "windows-sys 0.48.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "secret-service" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da1a5ad4d28c03536f82f77d9f36603f5e37d8869ac98f0a750d5b5686d8d95" +dependencies = [ + "aes 0.7.5", + "block-modes", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "770452e37cad93e0a50d5abc3990d2bc351c36d0328f86cefec2f2fb206eaef6" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317936bbbd05227752583946b9e66d7ce3b489f84e11a94a510b4437fef407d7" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090" + +[[package]] +name = "serde" +version = "1.0.213" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ea7893ff5e2466df8d720bb615088341b295f849602c6956047f8f80f0e9bc1" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.213" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e85ad2009c50b58e87caa8cd6dac16bdf511bbfb7af6c33df902396aa480fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "serde_json" +version = "1.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3081f5ffbb02284dda55132aa26daecedd7372a42417bbbab6f14ab7d6bb9145" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "serde_spanned" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12022b835073e5b11e90a14f86838ceb1c8fb0325b72416845c487ac0fa95e80" +dependencies = [ + "serde", +] + +[[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 = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shannon" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ea5b41c9427b56caa7b808cb548a04fb50bb5b9e98590b53f28064ff4174561" +dependencies = [ + "byteorder", +] + +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + +[[package]] +name = "shlex" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cee0529a6d40f580e7a5e6c495c8fbfe21b7b52795ed4bb5e62cdf92bc6380" + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "sluice" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5" +dependencies = [ + "async-channel", + "futures-core", + "futures-io", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "spot" +version = "0.5.0" +dependencies = [ + "async-std", + "env_logger 0.10.0", + "form_urlencoded", + "futures", + "gdk-pixbuf", + "gdk4", + "gettext-rs", + "gio", + "glib 0.17.10", + "gtk4", + "isahc", + "lazy_static", + "libadwaita", + "librespot", + "log", + "oauth2", + "open", + "percent-encoding", + "rand", + "ref_filter_map", + "regex", + "secret-service", + "serde", + "serde_json", + "thiserror", + "tokio", + "url", + "zbus", + "zvariant", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symphonia" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9" +dependencies = [ + "lazy_static", + "symphonia-bundle-mp3", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-ogg", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c01c2aae70f0f1fb096b6f0ff112a930b1fb3626178fba3ae68b09dce71706d4" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a98765fb46a0a6732b007f7e2870c2129b6f78d87db7987e6533c8f164a9f30" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "798306779e3dc7d5231bd5691f5a813496dc79d3f56bf82e25789f2094e022c3" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada3505789516bcf00fc1157c67729eded428b455c27ca370e41f4d785bfa931" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc622b9841a10089c5b18e99eb904f4341615d5aa55bbf4eedde1be721a4023c" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484472580fa49991afda5f6550ece662237b00c6f562c7d9638d1b086ed010fe" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sysinfo" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "windows 0.57.0", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2d580ff6a20c55dfb86be5f9c238f67835d0e81cbdea8bf5680e0897320331" +dependencies = [ + "cfg-expr 0.15.5", + "heck 0.4.1", + "pkg-config", + "toml", + "version-compare 0.1.1", +] + +[[package]] +name = "system-deps" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d23aaf9f331227789a99e8de4c91bf46703add012bdfd45fdecdfb2975a005" +dependencies = [ + "cfg-expr 0.17.0", + "heck 0.5.0", + "pkg-config", + "toml", + "version-compare 0.2.0", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "temp-dir" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af547b166dd1ea4b472165569fc456cfb6818116f854690b0ff205e636523dab" + +[[package]] +name = "tempfile" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef1adac450ad7f4b3c28589471ade84f25f731a7a0fe30d71dfa9f60fd808e5" +dependencies = [ + "cfg-if", + "fastrand 2.0.1", + "redox_syscall", + "rustix 0.38.21", + "windows-sys 0.48.0", +] + +[[package]] +name = "termcolor" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6093bad37da69aab9d123a8091e4be0aa4a03e4d601ec641c327398315f62b64" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "thread-id" +version = "4.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0ec81c46e9eb50deaa257be2f148adf052d1fb7701cfd55ccfab2525280b70b" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145f3413504347a2be84393cc8a7d2fb4d863b375909ea59f2158261aa258bbb" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.5.5", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls 0.23.15", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls 0.23.15", + "rustls-native-certs 0.8.0", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.0", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "toml" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ff9e3abce27ee2c9a37f9ad37238c1bdd4e789c84ba37df76aa4d528f5072cc" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.20.7", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow 0.5.18", +] + +[[package]] +name = "toml_edit" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow 0.5.18", +] + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow 0.6.20", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + +[[package]] +name = "try-lock" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.1.0", + "httparse", + "log", + "rand", + "rustls 0.23.15", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "uds_windows" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce65604324d3cce9b966701489fbd0cf318cb1f7bd9dd07ac9a4ee6fb791930d" +dependencies = [ + "tempfile", + "winapi", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-width" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ad59a7560b41a70d191093a945f0b87bc1deeda46fb237479708a1d6b6cdfc" +dependencies = [ + "getrandom", + "rand", +] + +[[package]] +name = "value-bag" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ef4c4aa54d5d05a279399bfa921ec387b7aba77caf7a682ae8d86785b8fdad2" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vergen" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349ed9e45296a581f455bc18039878f409992999bc1d5da12a6800eb18c8752f" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3a7f91caabecefc3c249fd864b11d4abe315c166fbdb568964421bccfd2b7a" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229eaddb0050920816cf051e619affaf18caa3dd512de8de5839ccbc8e53abb0" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + +[[package]] +name = "version-compare" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "waker-fn" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c4517f54858c779bbcbf228f4fca63d121bf85fbecb2dc578cdf4a39395690" + +[[package]] +name = "walkdir" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.85", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afec9963e3d0994cac82455b2b3502b81a7f40f9a0d32181f7528d9f4b43e02" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" + +[[package]] +name = "web-sys" +version = "0.3.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db499c5f66323272151db0e666cd34f78617522fb0c1604d31a27c50c206a85" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.21", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "176b6138793677221d420fd2f0aeeced263f197688b36484660da767bca2fa32" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "xdg-home" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2769203cd13a0c6015d515be729c526d041e9cf2c0cc478d57faee85f40c6dcd" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "zbus" +version = "3.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31de390a2d872e4cd04edd71b425e29853f786dc99317ed72d73d6fcf5ebb948" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io 1.13.0", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "byteorder", + "derivative", + "enumflags2", + "event-listener 2.5.3", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "once_cell", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "winapi", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "3.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d1794a946878c0e807f55a397187c11fc7a038ba5d868e7db4f3bd7760bc9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb80bb776dbda6e23d705cf0123c3b95df99c4ebeaec6c2599d4a5419902b4a9" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zvariant" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44b291bee0d960c53170780af148dca5fa260a63cdd24f1962fa82e03e53338c" +dependencies = [ + "byteorder", + "enumflags2", + "libc", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "934d7a7dfc310d6ee06c87ffe88ef4eca7d3e37bb251dece2ef93da8f17d8ecd" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 1.0.109", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7234f0d811589db492d16893e3f21e8e2fd282e6d01b0cddee310322062cc200" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7640cd3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,78 @@ +[package] +name = "spot" +version = "0.5.0" +edition = "2018" +license = "MIT" + +[dependencies.gtk] +version = "^0.6.6" +package = "gtk4" +features = ["gnome_44", "blueprint"] + +[dependencies.libadwaita] +version = "^0.4.1" +features = ["v1_2"] + +[dependencies.gdk] +version = "^0.6.3" +package = "gdk4" + +[dependencies.gio] +version = "^0.17.9" +features = ["v2_60"] + +[dependencies.glib] +version = "^0.17.9" +features = ["v2_60"] + +[dependencies.librespot] +version = "0.5.0" +features = ["alsa-backend", "pulseaudio-backend", "gstreamer-backend"] + +[dependencies.tokio] +version = "1" +features = ["rt", "macros", "sync"] + +[dependencies.futures] +package = "futures" +version = "0.3.18" + +[dependencies.serde] +version = "^1.0.136" +features = ["derive"] + +[dependencies.serde_json] +version = "^1.0.96" + +[dependencies.isahc] +version = "^1.7.2" +features = ["json"] + +[dependencies.rand] +version = "^0.8.5" +features = ["small_rng"] + +[dependencies.gettext-rs] +version = "0.7.0" +features = ["gettext-system"] + +[dependencies.secret-service] +version = "3.0.1" +features = ["rt-async-io-crypto-rust"] + +[dependencies] +gdk-pixbuf = "0.17.0" +ref_filter_map = "1.0.1" +regex = "1.8.3" +async-std = "1.12.0" +form_urlencoded = "1.0.1" +zbus = "3.13" +zvariant = "3.14" +thiserror = "1.0.40" +lazy_static = "1.4.0" +log = "0.4.17" +env_logger = "0.10.0" +percent-encoding = "2.2.0" +oauth2 = "4.4" +url = "2.4.1" +open = "5.3.0" \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a7c3b62 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Alexandre Trendel + +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/README.md b/README.md new file mode 100644 index 0000000..c9ee930 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# Spot [![spot-snapshots](https://github.com/xou816/spot/actions/workflows/spot-snapshots.yml/badge.svg?branch=master)](https://github.com/xou816/spot/actions/workflows/spot-snapshots.yml) + +Gtk/Rust native Spotify client for the GNOME desktop. **Only works with premium accounts!** + +Based on [librespot](https://github.com/librespot-org/librespot/). + +Join the discussion on [Matrix](https://matrix.to/#/#spot-devel:matrix.org). + +![Spot screenshot](./data/appstream/2.png) + +## Installing + +Download on Flathub + +## Usage notes + +### Credentials + +It is recommended to install a libsecret compliant keyring application, such as [GNOME Keyring](https://wiki.gnome.org/action/show/Projects/GnomeKeyring) (aka seahorse). This will allow saving your password securely between launches. + +In GNOME, things should work out of the box. It might be a bit trickier to get it working in other DEs: see this [ArchWiki entry](https://wiki.archlinux.org/index.php/GNOME/Keyring) for detailed explanations on how to automatically start the daemon with your session. + +Bear special attention to the fact that to enable automatic login, you might have to use the same password for your user account and for the keyring, and that the keyring might need to be [set as default](https://wiki.archlinux.org/index.php/GNOME/Keyring#Passwords_are_not_remembered). + +See [this comment](https://github.com/xou816/spot/issues/92#issuecomment-801852593) for more details! + +### Login in with Facebook + +...is not supported. However, you can update your account in order to be able to log in with a username and password [as explained in this issue](https://github.com/xou816/spot/issues/373). + + +### Settings + +Spot can also be configured via `gsettings` if you want to change the audio backend, the song bitrate, etc. + +### Seek bar warping +It is possible to click on the seek bar to navigate to that position in a song. If you are having issues with this not working you may have [gtk-primary-button-warps-slider](https://docs.gtk.org/gtk3/property.Settings.gtk-primary-button-warps-slider.html) set to false. +In order to fix this issue set the value to true in your gtk configuration. + +### Scrobbling + +Scrobbling is not supported directly by Spot. However, you can use a tool such a [rescrobbled](https://github.com/InputUsername/rescrobbled) ([see #85](https://github.com/xou816/spot/issues/85)). + +### Lyrics + +Similarly, Spot does not display lyrics for songs, but you can use [osdlyrics](https://github.com/osdlyrics/osdlyrics) ([see #226](https://github.com/xou816/spot/issues/226)). + +### Gtk theme + +Spot uses the dark theme variant by default; this can be changed in the settings. + +If you are using the flatpak version, don't forget to install your theme with flatpak as well. See [this comment](https://github.com/xou816/spot/issues/209#issuecomment-860180537) for details. + +Similarly, snap also requires that you install the corresponding snap for your theme. See [this comment](https://github.com/xou816/spot/issues/338#issuecomment-975543476) for details. + +## Features + +**Only works with premium accounts!** + +- playback control (play/pause, prev/next, seeking, shuffle, repeat (none, all, song)) +- selection mode: easily browse and select mutliple tracks to queue them +- browse your saved albums and playlists +- search albums and artists +- view an artist's releases +- view users' playlists +- view album info +- credentials management with Secret Service +- MPRIS integration +- playlist management (creation and edition) +- liked tracks + +### Planned + +- GNOME search provider? +- improved search? (track results) +- recommendations? + +## Contributing + +Contributions are welcome! If you wish, add yourself to the `AUTHORS` files when submitting your contribution. + +For any large feature/change, **please** open an issue first to discuss implementation and design decisions. + +### Translating + +Translations are managed using `gettext` and are available in the `po/` subdirectory. + +**Please use [POEditor](https://poeditor.com/join/project?hash=xfVrpQfRBM) to submit translations.** + +If you feel like it, you are welcome to open a PR to be added to the `TRANSLATORS` file! + +## Building + +### With GNOME Builder and flatpak + +Pre-requisite: install the `org.freedesktop.Sdk.Extension.rust-stable` SDK extension with flatpak. Builder might do this for you automatically, but it will install an older version; make sure the version installed matches the version of the Freedesktop SDK GNOME uses. + +Open the project in GNOME Builder and make the `dev.alextren.Spot.development.json` configuration active. Then build :) + +### Manually + +Requires Rust (stable), **GTK4**, and a couple other things. Also requires **libadwaita** and **blueprint-compiler**: they are not packaged on all distros at the moment, you might have to build them yourself! + +With meson: + +``` +meson target -Dbuildtype=debug -Doffline=false --prefix="$HOME/.local" +ninja install -C target +# to run test/linter/etc +meson test -C target --verbose +``` + +This will install a `.desktop` file among other things, and the spot executable will be put in `.local/bin` (you might want to add it to your path). + +To build an optimized release build, use `-Dbuildtype=release` instead. + +### Regenerating potfiles + +When adding new `msgids`, don't forget to regenerate/update the potfiles. + +``` +ninja spot-pot -C target +ninja spot-update-po -C target +``` + +### Pulling updated strings from POEditor + +We are now using POEditor and the wonderful [`poeditor-sync`](https://github.com/mick88/poeditor-sync) tool. + +``` +poeditor pull +``` + +### Regenerating sources for flatpak + +Using [flatpak-cargo-generator.py](https://github.com/flatpak/flatpak-builder-tools/tree/master/cargo): + +``` +ninja cargo-sources.json -C target +``` + +### Debugging + +Set the `RUST_LOG` env variable to the appropriate level. + +Debug builds (flatpak) are available from the master branch on Github (see the `spot-snaphots` action). + +Spot caches images and HTTP responses in `~/.cache/spot`. + +Spot uses [isahc](https://github.com/sagebind/isahc), which uses libcurl, therefore you can set the `https_proxy` env variable to help with debugging. In debug mode, Spot skips SSL certificate verification. diff --git a/TRANSLATORS b/TRANSLATORS new file mode 100644 index 0000000..d816210 --- /dev/null +++ b/TRANSLATORS @@ -0,0 +1,25 @@ +Heimen Stoffels +Philipp Kiemle +kleinHeiti +Ondřej Sluka +Ícar N. S. +Jonasz Potoniec +José Miguel Sarasola +Hugo Meireles Gonçalves +Paul Bragin +Yusuf Çınar Özmen +Kukuh Syafaat +Lucas Araujo +Igor Dyatlov +Jiri Grönroos +Guilherme +Lars Martinsen +Sergio +SoftInterlingua +Tine Jozelj +Ana Pika Šubic +Amor Ali +Seioo Inoue +Dmitry +Julius Rüberg +Francesco Babetto diff --git a/build-aux/flatpak-cargo-generator.py b/build-aux/flatpak-cargo-generator.py new file mode 100644 index 0000000..4181e51 --- /dev/null +++ b/build-aux/flatpak-cargo-generator.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python3 + +__license__ = 'MIT' +import json +from urllib.parse import urlparse, ParseResult, parse_qs +import os +import contextlib +import copy +import glob +import subprocess +import argparse +import logging +import hashlib +import asyncio +from typing import Any, Dict, List, NamedTuple, Optional, Tuple, TypedDict + +import aiohttp +import toml + +CRATES_IO = 'https://static.crates.io/crates' +CARGO_HOME = 'cargo' +CARGO_CRATES = f'{CARGO_HOME}/vendor' +VENDORED_SOURCES = 'vendored-sources' +GIT_CACHE = 'flatpak-cargo/git' +COMMIT_LEN = 7 + + +@contextlib.contextmanager +def workdir(path: str): + oldpath = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(oldpath) + + +def canonical_url(url: str) -> ParseResult: + 'Converts a string to a Cargo Canonical URL, as per https://github.com/rust-lang/cargo/blob/35c55a93200c84a4de4627f1770f76a8ad268a39/src/cargo/util/canonical_url.rs#L19' + # Hrm. The upstream cargo does not replace those URLs, but if we don't then it doesn't work too well :( + url = url.replace('git+https://', 'https://') + u = urlparse(url) + # It seems cargo drops query and fragment + u = ParseResult(u.scheme, u.netloc, u.path, '', '', '') + u = u._replace(path = u.path.rstrip('/')) + + if u.netloc == 'github.com': + u = u._replace(scheme = 'https') + u = u._replace(path = u.path.lower()) + + if u.path.endswith('.git'): + u = u._replace(path = u.path[:-len('.git')]) + + return u + + +def get_git_tarball(repo_url: str, commit: str) -> str: + url = canonical_url(repo_url) + path = url.path.split('/')[1:] + + assert len(path) == 2 + owner = path[0] + if path[1].endswith('.git'): + repo = path[1].replace('.git', '') + else: + repo = path[1] + if url.hostname == 'github.com': + return f'https://codeload.{url.hostname}/{owner}/{repo}/tar.gz/{commit}' + elif url.hostname.split('.')[0] == 'gitlab': # type: ignore + return f'https://{url.hostname}/{owner}/{repo}/-/archive/{commit}/{repo}-{commit}.tar.gz' + elif url.hostname == 'bitbucket.org': + return f'https://{url.hostname}/{owner}/{repo}/get/{commit}.tar.gz' + else: + raise ValueError(f'Don\'t know how to get tarball for {repo_url}') + + +async def get_remote_sha256(url: str) -> str: + logging.info(f"started sha256({url})") + sha256 = hashlib.sha256() + async with aiohttp.ClientSession(raise_for_status=True) as http_session: + async with http_session.get(url) as response: + while True: + data = await response.content.read(4096) + if not data: + break + sha256.update(data) + logging.info(f"done sha256({url})") + return sha256.hexdigest() + + +_TomlType = Dict[str, Any] + + +def load_toml(tomlfile: str = 'Cargo.lock') -> _TomlType: + with open(tomlfile, 'r') as f: + toml_data = toml.load(f) + return toml_data + + +def git_repo_name(git_url: str, commit: str) -> str: + name = canonical_url(git_url).path.split('/')[-1] + return f'{name}-{commit[:COMMIT_LEN]}' + + +def fetch_git_repo(git_url: str, commit: str) -> str: + repo_dir = git_url.replace('://', '_').replace('/', '_') + cache_dir = os.environ.get('XDG_CACHE_HOME', os.path.expanduser('~/.cache')) + clone_dir = os.path.join(cache_dir, 'flatpak-cargo', repo_dir) + if not os.path.isdir(os.path.join(clone_dir, '.git')): + subprocess.run(['git', 'clone', '--depth=1', git_url, clone_dir], check=True) + rev_parse_proc = subprocess.run(['git', 'rev-parse', 'HEAD'], cwd=clone_dir, check=True, + stdout=subprocess.PIPE) + head = rev_parse_proc.stdout.decode().strip() + if head[:COMMIT_LEN] != commit[:COMMIT_LEN]: + subprocess.run(['git', 'fetch', 'origin', commit], cwd=clone_dir, check=True) + subprocess.run(['git', 'checkout', commit], cwd=clone_dir, check=True) + return clone_dir + + +class _GitPackage(NamedTuple): + path: str + package: _TomlType + workspace: Optional[_TomlType] + + @property + def normalized(self) -> _TomlType: + package = copy.deepcopy(self.package) + if self.workspace is None: + return package + for section_key, section in package.items(): + # XXX We ignore top-level lists here; maybe we should iterate over list items, too + if not isinstance(section, dict): + continue + for key, value in section.items(): + if not isinstance(value, dict): + continue + if not value.get('workspace'): + continue + package[section_key][key] = self.workspace[section_key][key] + return package + + +_GitPackagesType = Dict[str, _GitPackage] + + +async def get_git_repo_packages(git_url: str, commit: str) -> _GitPackagesType: + logging.info('Loading packages from %s', git_url) + git_repo_dir = fetch_git_repo(git_url, commit) + packages: _GitPackagesType = {} + + with workdir(git_repo_dir): + if os.path.isfile('Cargo.toml'): + packages.update(await get_cargo_toml_packages(load_toml('Cargo.toml'), '.')) + else: + for toml_path in glob.glob('*/Cargo.toml'): + packages.update(await get_cargo_toml_packages(load_toml(toml_path), + os.path.dirname(toml_path))) + + assert packages, f"No packages found in {git_repo_dir}" + logging.debug( + 'Packages in %s:\n%s', + git_url, + json.dumps( + {k: v.path for k, v in packages.items()}, + indent=4, + ), + ) + return packages + + +async def get_cargo_toml_packages(root_toml: _TomlType, root_dir: str) -> _GitPackagesType: + assert not os.path.isabs(root_dir) and os.path.isdir(root_dir) + assert 'package' in root_toml or 'workspace' in root_toml + packages: _GitPackagesType = {} + + async def get_dep_packages( + entry: _TomlType, + toml_dir: str, + workspace: Optional[_TomlType] = None, + ): + assert not os.path.isabs(toml_dir) + # https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html + if 'dependencies' in entry: + for dep_name, dep in entry['dependencies'].items(): + if 'package' in dep: + dep_name = dep['package'] + if 'path' not in dep: + continue + if dep_name in packages: + continue + dep_dir = os.path.normpath(os.path.join(toml_dir, dep['path'])) + logging.debug("Loading dependency %s from %s", dep_name, dep_dir) + dep_toml = load_toml(os.path.join(dep_dir, 'Cargo.toml')) + assert dep_toml['package']['name'] == dep_name, toml_dir + await get_dep_packages(dep_toml, dep_dir, workspace) + packages[dep_name] = _GitPackage( + path=dep_dir, + package=dep, + workspace=workspace, + ) + if 'target' in entry: + for _, target in entry['target'].items(): + await get_dep_packages(target, toml_dir) + + if 'package' in root_toml: + await get_dep_packages(root_toml, root_dir) + packages[root_toml['package']['name']] = _GitPackage( + path=root_dir, + package=root_toml, + workspace=None, + ) + + if 'workspace' in root_toml: + for member in root_toml['workspace'].get('members', []): + for subpkg_toml in glob.glob(os.path.join(root_dir, member, 'Cargo.toml')): + subpkg = os.path.normpath(os.path.dirname(subpkg_toml)) + logging.debug( + "Loading workspace member %s in %s", + subpkg_toml, + os.path.abspath(root_dir), + ) + pkg_toml = load_toml(subpkg_toml) + await get_dep_packages(pkg_toml, subpkg, root_toml['workspace']) + packages[pkg_toml['package']['name']] = _GitPackage( + path=subpkg, + package=pkg_toml, + workspace=root_toml['workspace'], + ) + + return packages + + +_FlatpakSourceType = Dict[str, Any] + + +async def get_git_repo_sources( + url: str, + commit: str, + tarball: bool = False, +) -> List[_FlatpakSourceType]: + name = git_repo_name(url, commit) + if tarball: + tarball_url = get_git_tarball(url, commit) + git_repo_sources = [{ + 'type': 'archive', + 'archive-type': 'tar-gzip', + 'url': tarball_url, + 'sha256': await get_remote_sha256(tarball_url), + 'dest': f'{GIT_CACHE}/{name}', + }] + else: + git_repo_sources = [{ + 'type': 'git', + 'url': url, + 'commit': commit, + 'dest': f'{GIT_CACHE}/{name}', + }] + return git_repo_sources + + +_GitRepo = TypedDict('_GitRepo', {'lock': asyncio.Lock, 'commits': Dict[str, _GitPackagesType]}) +_GitReposType = Dict[str, _GitRepo] +_VendorEntryType = Dict[str, Dict[str, str]] + + +async def get_git_package_sources( + package: _TomlType, + git_repos: _GitReposType, +) -> Tuple[List[_FlatpakSourceType], _VendorEntryType]: + name = package['name'] + source = package['source'] + commit = urlparse(source).fragment + assert commit, 'The commit needs to be indicated in the fragement part' + canonical = canonical_url(source) + repo_url = canonical.geturl() + + git_repo = git_repos.setdefault(repo_url, { + 'commits': {}, + 'lock': asyncio.Lock(), + }) + async with git_repo['lock']: + if commit not in git_repo['commits']: + git_repo['commits'][commit] = await get_git_repo_packages(repo_url, commit) + + cargo_vendored_entry: _VendorEntryType = { + repo_url: { + 'git': repo_url, + 'replace-with': VENDORED_SOURCES, + } + } + rev = parse_qs(urlparse(source).query).get('rev') + tag = parse_qs(urlparse(source).query).get('tag') + branch = parse_qs(urlparse(source).query).get('branch') + if rev: + assert len(rev) == 1 + cargo_vendored_entry[repo_url]['rev'] = rev[0] + elif tag: + assert len(tag) == 1 + cargo_vendored_entry[repo_url]['tag'] = tag[0] + elif branch: + assert len(branch) == 1 + cargo_vendored_entry[repo_url]['branch'] = branch[0] + + logging.info("Adding package %s from %s", name, repo_url) + git_pkg = git_repo['commits'][commit][name] + pkg_repo_dir = os.path.join(GIT_CACHE, git_repo_name(repo_url, commit), git_pkg.path) + git_sources: List[_FlatpakSourceType] = [ + { + 'type': 'shell', + 'commands': [ + f'cp -r --reflink=auto "{pkg_repo_dir}" "{CARGO_CRATES}/{name}"' + ], + }, + { + 'type': 'inline', + 'contents': toml.dumps(git_pkg.normalized), + 'dest': f'{CARGO_CRATES}/{name}', #-{version}', + 'dest-filename': 'Cargo.toml', + }, + { + 'type': 'inline', + 'contents': json.dumps({'package': None, 'files': {}}), + 'dest': f'{CARGO_CRATES}/{name}', #-{version}', + 'dest-filename': '.cargo-checksum.json', + } + ] + + return (git_sources, cargo_vendored_entry) + + +async def get_package_sources( + package: _TomlType, + cargo_lock: _TomlType, + git_repos: _GitReposType, +) -> Optional[Tuple[List[_FlatpakSourceType], _VendorEntryType]]: + metadata = cargo_lock.get('metadata') + name = package['name'] + version = package['version'] + + if 'source' not in package: + logging.debug('%s has no source', name) + return None + source = package['source'] + + if source.startswith('git+'): + return await get_git_package_sources(package, git_repos) + + key = f'checksum {name} {version} ({source})' + if metadata is not None and key in metadata: + checksum = metadata[key] + elif 'checksum' in package: + checksum = package['checksum'] + else: + logging.warning(f'{name} doesn\'t have checksum') + return None + crate_sources = [ + { + 'type': 'archive', + 'archive-type': 'tar-gzip', + 'url': f'{CRATES_IO}/{name}/{name}-{version}.crate', + 'sha256': checksum, + 'dest': f'{CARGO_CRATES}/{name}-{version}', + }, + { + 'type': 'inline', + 'contents': json.dumps({'package': checksum, 'files': {}}), + 'dest': f'{CARGO_CRATES}/{name}-{version}', + 'dest-filename': '.cargo-checksum.json', + }, + ] + return (crate_sources, {'crates-io': {'replace-with': VENDORED_SOURCES}}) + + +async def generate_sources( + cargo_lock: _TomlType, + git_tarballs: bool = False, +) -> List[_FlatpakSourceType]: + # { + # "git-repo-url": { + # "lock": asyncio.Lock(), + # "commits": { + # "commit-hash": { + # "package-name": "./relative/package/path" + # } + # } + # } + # } + git_repos: _GitReposType = {} + sources: List[_FlatpakSourceType] = [] + package_sources = [] + cargo_vendored_sources = { + VENDORED_SOURCES: {'directory': f'{CARGO_CRATES}'}, + } + + pkg_coros = [get_package_sources(p, cargo_lock, git_repos) for p in cargo_lock['package']] + for pkg in await asyncio.gather(*pkg_coros): + if pkg is None: + continue + else: + pkg_sources, cargo_vendored_entry = pkg + package_sources.extend(pkg_sources) + cargo_vendored_sources.update(cargo_vendored_entry) + + logging.debug('Adding collected git repos:\n%s', json.dumps(list(git_repos), indent=4)) + git_repo_coros = [] + for git_url, git_repo in git_repos.items(): + for git_commit in git_repo['commits']: + git_repo_coros.append(get_git_repo_sources(git_url, git_commit, git_tarballs)) + sources.extend(sum(await asyncio.gather(*git_repo_coros), [])) + + sources.extend(package_sources) + + logging.debug('Vendored sources:\n%s', json.dumps(cargo_vendored_sources, indent=4)) + sources.append({ + 'type': 'inline', + 'contents': toml.dumps({ + 'source': cargo_vendored_sources, + }), + 'dest': CARGO_HOME, + 'dest-filename': 'config' + }) + return sources + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('cargo_lock', help='Path to the Cargo.lock file') + parser.add_argument('-o', '--output', required=False, help='Where to write generated sources') + parser.add_argument('-t', '--git-tarballs', action='store_true', help='Download git repos as tarballs') + parser.add_argument('-d', '--debug', action='store_true') + args = parser.parse_args() + if args.output is not None: + outfile = args.output + else: + outfile = 'generated-sources.json' + if args.debug: + loglevel = logging.DEBUG + else: + loglevel = logging.INFO + logging.basicConfig(level=loglevel) + + generated_sources = asyncio.run(generate_sources(load_toml(args.cargo_lock), + git_tarballs=args.git_tarballs)) + with open(outfile, 'w') as out: + json.dump(generated_sources, out, indent=4, sort_keys=False) + + +if __name__ == '__main__': + main() + diff --git a/cargo-sources.json b/cargo-sources.json new file mode 100644 index 0000000..f307901 --- /dev/null +++ b/cargo-sources.json @@ -0,0 +1,6898 @@ +[ + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/addr2line/addr2line-0.21.0.crate", + "sha256": "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb", + "dest": "cargo/vendor/addr2line-0.21.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb\", \"files\": {}}", + "dest": "cargo/vendor/addr2line-0.21.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/adler/adler-1.0.2.crate", + "sha256": "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe", + "dest": "cargo/vendor/adler-1.0.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe\", \"files\": {}}", + "dest": "cargo/vendor/adler-1.0.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/aes/aes-0.7.5.crate", + "sha256": "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8", + "dest": "cargo/vendor/aes-0.7.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8\", \"files\": {}}", + "dest": "cargo/vendor/aes-0.7.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/aes/aes-0.8.4.crate", + "sha256": "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0", + "dest": "cargo/vendor/aes-0.8.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0\", \"files\": {}}", + "dest": "cargo/vendor/aes-0.8.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/aho-corasick/aho-corasick-1.1.2.crate", + "sha256": "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0", + "dest": "cargo/vendor/aho-corasick-1.1.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0\", \"files\": {}}", + "dest": "cargo/vendor/aho-corasick-1.1.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/alsa/alsa-0.9.1.crate", + "sha256": "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43", + "dest": "cargo/vendor/alsa-0.9.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43\", \"files\": {}}", + "dest": "cargo/vendor/alsa-0.9.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/alsa-sys/alsa-sys-0.3.1.crate", + "sha256": "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527", + "dest": "cargo/vendor/alsa-sys-0.3.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527\", \"files\": {}}", + "dest": "cargo/vendor/alsa-sys-0.3.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/android-tzdata/android-tzdata-0.1.1.crate", + "sha256": "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0", + "dest": "cargo/vendor/android-tzdata-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0\", \"files\": {}}", + "dest": "cargo/vendor/android-tzdata-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/android_system_properties/android_system_properties-0.1.5.crate", + "sha256": "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311", + "dest": "cargo/vendor/android_system_properties-0.1.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311\", \"files\": {}}", + "dest": "cargo/vendor/android_system_properties-0.1.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/anstream/anstream-0.6.17.crate", + "sha256": "23a1e53f0f5d86382dafe1cf314783b2044280f406e7e1506368220ad11b1338", + "dest": "cargo/vendor/anstream-0.6.17" + }, + { + "type": "inline", + "contents": "{\"package\": \"23a1e53f0f5d86382dafe1cf314783b2044280f406e7e1506368220ad11b1338\", \"files\": {}}", + "dest": "cargo/vendor/anstream-0.6.17", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/anstyle/anstyle-1.0.9.crate", + "sha256": "8365de52b16c035ff4fcafe0092ba9390540e3e352870ac09933bebcaa2c8c56", + "dest": "cargo/vendor/anstyle-1.0.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"8365de52b16c035ff4fcafe0092ba9390540e3e352870ac09933bebcaa2c8c56\", \"files\": {}}", + "dest": "cargo/vendor/anstyle-1.0.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/anstyle-parse/anstyle-parse-0.2.6.crate", + "sha256": "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9", + "dest": "cargo/vendor/anstyle-parse-0.2.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9\", \"files\": {}}", + "dest": "cargo/vendor/anstyle-parse-0.2.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/anstyle-query/anstyle-query-1.1.2.crate", + "sha256": "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c", + "dest": "cargo/vendor/anstyle-query-1.1.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c\", \"files\": {}}", + "dest": "cargo/vendor/anstyle-query-1.1.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/anstyle-wincon/anstyle-wincon-3.0.6.crate", + "sha256": "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125", + "dest": "cargo/vendor/anstyle-wincon-3.0.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125\", \"files\": {}}", + "dest": "cargo/vendor/anstyle-wincon-3.0.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/anyhow/anyhow-1.0.91.crate", + "sha256": "c042108f3ed77fd83760a5fd79b53be043192bb3b9dba91d8c574c0ada7850c8", + "dest": "cargo/vendor/anyhow-1.0.91" + }, + { + "type": "inline", + "contents": "{\"package\": \"c042108f3ed77fd83760a5fd79b53be043192bb3b9dba91d8c574c0ada7850c8\", \"files\": {}}", + "dest": "cargo/vendor/anyhow-1.0.91", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/arrayvec/arrayvec-0.7.6.crate", + "sha256": "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50", + "dest": "cargo/vendor/arrayvec-0.7.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50\", \"files\": {}}", + "dest": "cargo/vendor/arrayvec-0.7.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-broadcast/async-broadcast-0.5.1.crate", + "sha256": "7c48ccdbf6ca6b121e0f586cbc0e73ae440e56c67c30fa0873b4e110d9c26d2b", + "dest": "cargo/vendor/async-broadcast-0.5.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"7c48ccdbf6ca6b121e0f586cbc0e73ae440e56c67c30fa0873b4e110d9c26d2b\", \"files\": {}}", + "dest": "cargo/vendor/async-broadcast-0.5.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-channel/async-channel-1.9.0.crate", + "sha256": "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35", + "dest": "cargo/vendor/async-channel-1.9.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35\", \"files\": {}}", + "dest": "cargo/vendor/async-channel-1.9.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-executor/async-executor-1.6.0.crate", + "sha256": "4b0c4a4f319e45986f347ee47fef8bf5e81c9abc3f6f58dc2391439f30df65f0", + "dest": "cargo/vendor/async-executor-1.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"4b0c4a4f319e45986f347ee47fef8bf5e81c9abc3f6f58dc2391439f30df65f0\", \"files\": {}}", + "dest": "cargo/vendor/async-executor-1.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-fs/async-fs-1.6.0.crate", + "sha256": "279cf904654eeebfa37ac9bb1598880884924aab82e290aa65c9e77a0e142e06", + "dest": "cargo/vendor/async-fs-1.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"279cf904654eeebfa37ac9bb1598880884924aab82e290aa65c9e77a0e142e06\", \"files\": {}}", + "dest": "cargo/vendor/async-fs-1.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-global-executor/async-global-executor-2.3.1.crate", + "sha256": "f1b6f5d7df27bd294849f8eec66ecfc63d11814df7a4f5d74168a2394467b776", + "dest": "cargo/vendor/async-global-executor-2.3.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"f1b6f5d7df27bd294849f8eec66ecfc63d11814df7a4f5d74168a2394467b776\", \"files\": {}}", + "dest": "cargo/vendor/async-global-executor-2.3.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-io/async-io-1.13.0.crate", + "sha256": "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af", + "dest": "cargo/vendor/async-io-1.13.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af\", \"files\": {}}", + "dest": "cargo/vendor/async-io-1.13.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-io/async-io-2.1.0.crate", + "sha256": "10da8f3146014722c89e7859e1d7bb97873125d7346d10ca642ffab794355828", + "dest": "cargo/vendor/async-io-2.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"10da8f3146014722c89e7859e1d7bb97873125d7346d10ca642ffab794355828\", \"files\": {}}", + "dest": "cargo/vendor/async-io-2.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-lock/async-lock-2.8.0.crate", + "sha256": "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b", + "dest": "cargo/vendor/async-lock-2.8.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b\", \"files\": {}}", + "dest": "cargo/vendor/async-lock-2.8.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-process/async-process-1.8.1.crate", + "sha256": "ea6438ba0a08d81529c69b36700fa2f95837bfe3e776ab39cde9c14d9149da88", + "dest": "cargo/vendor/async-process-1.8.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ea6438ba0a08d81529c69b36700fa2f95837bfe3e776ab39cde9c14d9149da88\", \"files\": {}}", + "dest": "cargo/vendor/async-process-1.8.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-recursion/async-recursion-1.0.5.crate", + "sha256": "5fd55a5ba1179988837d24ab4c7cc8ed6efdeff578ede0416b4225a5fca35bd0", + "dest": "cargo/vendor/async-recursion-1.0.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"5fd55a5ba1179988837d24ab4c7cc8ed6efdeff578ede0416b4225a5fca35bd0\", \"files\": {}}", + "dest": "cargo/vendor/async-recursion-1.0.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-signal/async-signal-0.2.5.crate", + "sha256": "9e47d90f65a225c4527103a8d747001fc56e375203592b25ad103e1ca13124c5", + "dest": "cargo/vendor/async-signal-0.2.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"9e47d90f65a225c4527103a8d747001fc56e375203592b25ad103e1ca13124c5\", \"files\": {}}", + "dest": "cargo/vendor/async-signal-0.2.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-std/async-std-1.12.0.crate", + "sha256": "62565bb4402e926b29953c785397c6dc0391b7b446e45008b0049eb43cec6f5d", + "dest": "cargo/vendor/async-std-1.12.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"62565bb4402e926b29953c785397c6dc0391b7b446e45008b0049eb43cec6f5d\", \"files\": {}}", + "dest": "cargo/vendor/async-std-1.12.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-task/async-task-4.5.0.crate", + "sha256": "b4eb2cdb97421e01129ccb49169d8279ed21e829929144f4a22a6e54ac549ca1", + "dest": "cargo/vendor/async-task-4.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"b4eb2cdb97421e01129ccb49169d8279ed21e829929144f4a22a6e54ac549ca1\", \"files\": {}}", + "dest": "cargo/vendor/async-task-4.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/async-trait/async-trait-0.1.74.crate", + "sha256": "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9", + "dest": "cargo/vendor/async-trait-0.1.74" + }, + { + "type": "inline", + "contents": "{\"package\": \"a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9\", \"files\": {}}", + "dest": "cargo/vendor/async-trait-0.1.74", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/atomic-waker/atomic-waker-1.1.2.crate", + "sha256": "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0", + "dest": "cargo/vendor/atomic-waker-1.1.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0\", \"files\": {}}", + "dest": "cargo/vendor/atomic-waker-1.1.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/atomic_refcell/atomic_refcell-0.1.13.crate", + "sha256": "41e67cd8309bbd06cd603a9e693a784ac2e5d1e955f11286e355089fcab3047c", + "dest": "cargo/vendor/atomic_refcell-0.1.13" + }, + { + "type": "inline", + "contents": "{\"package\": \"41e67cd8309bbd06cd603a9e693a784ac2e5d1e955f11286e355089fcab3047c\", \"files\": {}}", + "dest": "cargo/vendor/atomic_refcell-0.1.13", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/autocfg/autocfg-1.1.0.crate", + "sha256": "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa", + "dest": "cargo/vendor/autocfg-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa\", \"files\": {}}", + "dest": "cargo/vendor/autocfg-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/aws-lc-rs/aws-lc-rs-1.10.0.crate", + "sha256": "cdd82dba44d209fddb11c190e0a94b78651f95299598e472215667417a03ff1d", + "dest": "cargo/vendor/aws-lc-rs-1.10.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"cdd82dba44d209fddb11c190e0a94b78651f95299598e472215667417a03ff1d\", \"files\": {}}", + "dest": "cargo/vendor/aws-lc-rs-1.10.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/aws-lc-sys/aws-lc-sys-0.22.0.crate", + "sha256": "df7a4168111d7eb622a31b214057b8509c0a7e1794f44c546d742330dc793972", + "dest": "cargo/vendor/aws-lc-sys-0.22.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"df7a4168111d7eb622a31b214057b8509c0a7e1794f44c546d742330dc793972\", \"files\": {}}", + "dest": "cargo/vendor/aws-lc-sys-0.22.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/backtrace/backtrace-0.3.69.crate", + "sha256": "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837", + "dest": "cargo/vendor/backtrace-0.3.69" + }, + { + "type": "inline", + "contents": "{\"package\": \"2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837\", \"files\": {}}", + "dest": "cargo/vendor/backtrace-0.3.69", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/base64/base64-0.13.1.crate", + "sha256": "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8", + "dest": "cargo/vendor/base64-0.13.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8\", \"files\": {}}", + "dest": "cargo/vendor/base64-0.13.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/base64/base64-0.21.5.crate", + "sha256": "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9", + "dest": "cargo/vendor/base64-0.21.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9\", \"files\": {}}", + "dest": "cargo/vendor/base64-0.21.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/base64/base64-0.22.1.crate", + "sha256": "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6", + "dest": "cargo/vendor/base64-0.22.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6\", \"files\": {}}", + "dest": "cargo/vendor/base64-0.22.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/base64ct/base64ct-1.6.0.crate", + "sha256": "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b", + "dest": "cargo/vendor/base64ct-1.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b\", \"files\": {}}", + "dest": "cargo/vendor/base64ct-1.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bindgen/bindgen-0.68.1.crate", + "sha256": "726e4313eb6ec35d2730258ad4e15b547ee75d6afaa1361a922e78e59b7d8078", + "dest": "cargo/vendor/bindgen-0.68.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"726e4313eb6ec35d2730258ad4e15b547ee75d6afaa1361a922e78e59b7d8078\", \"files\": {}}", + "dest": "cargo/vendor/bindgen-0.68.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bindgen/bindgen-0.69.5.crate", + "sha256": "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088", + "dest": "cargo/vendor/bindgen-0.69.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088\", \"files\": {}}", + "dest": "cargo/vendor/bindgen-0.69.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bitflags/bitflags-1.3.2.crate", + "sha256": "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a", + "dest": "cargo/vendor/bitflags-1.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a\", \"files\": {}}", + "dest": "cargo/vendor/bitflags-1.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bitflags/bitflags-2.6.0.crate", + "sha256": "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de", + "dest": "cargo/vendor/bitflags-2.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de\", \"files\": {}}", + "dest": "cargo/vendor/bitflags-2.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/block/block-0.1.6.crate", + "sha256": "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a", + "dest": "cargo/vendor/block-0.1.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a\", \"files\": {}}", + "dest": "cargo/vendor/block-0.1.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/block-buffer/block-buffer-0.10.4.crate", + "sha256": "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71", + "dest": "cargo/vendor/block-buffer-0.10.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71\", \"files\": {}}", + "dest": "cargo/vendor/block-buffer-0.10.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/block-modes/block-modes-0.8.1.crate", + "sha256": "2cb03d1bed155d89dce0f845b7899b18a9a163e148fd004e1c28421a783e2d8e", + "dest": "cargo/vendor/block-modes-0.8.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"2cb03d1bed155d89dce0f845b7899b18a9a163e148fd004e1c28421a783e2d8e\", \"files\": {}}", + "dest": "cargo/vendor/block-modes-0.8.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/block-padding/block-padding-0.2.1.crate", + "sha256": "8d696c370c750c948ada61c69a0ee2cbbb9c50b1019ddb86d9317157a99c2cae", + "dest": "cargo/vendor/block-padding-0.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"8d696c370c750c948ada61c69a0ee2cbbb9c50b1019ddb86d9317157a99c2cae\", \"files\": {}}", + "dest": "cargo/vendor/block-padding-0.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/blocking/blocking-1.4.1.crate", + "sha256": "8c36a4d0d48574b3dd360b4b7d95cc651d2b6557b6402848a27d4b228a473e2a", + "dest": "cargo/vendor/blocking-1.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"8c36a4d0d48574b3dd360b4b7d95cc651d2b6557b6402848a27d4b228a473e2a\", \"files\": {}}", + "dest": "cargo/vendor/blocking-1.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bumpalo/bumpalo-3.14.0.crate", + "sha256": "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec", + "dest": "cargo/vendor/bumpalo-3.14.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec\", \"files\": {}}", + "dest": "cargo/vendor/bumpalo-3.14.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bytemuck/bytemuck-1.19.0.crate", + "sha256": "8334215b81e418a0a7bdb8ef0849474f40bb10c8b71f1c4ed315cff49f32494d", + "dest": "cargo/vendor/bytemuck-1.19.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"8334215b81e418a0a7bdb8ef0849474f40bb10c8b71f1c4ed315cff49f32494d\", \"files\": {}}", + "dest": "cargo/vendor/bytemuck-1.19.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/byteorder/byteorder-1.5.0.crate", + "sha256": "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b", + "dest": "cargo/vendor/byteorder-1.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b\", \"files\": {}}", + "dest": "cargo/vendor/byteorder-1.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/bytes/bytes-1.5.0.crate", + "sha256": "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223", + "dest": "cargo/vendor/bytes-1.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223\", \"files\": {}}", + "dest": "cargo/vendor/bytes-1.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cairo-rs/cairo-rs-0.17.10.crate", + "sha256": "ab3603c4028a5e368d09b51c8b624b9a46edcd7c3778284077a6125af73c9f0a", + "dest": "cargo/vendor/cairo-rs-0.17.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"ab3603c4028a5e368d09b51c8b624b9a46edcd7c3778284077a6125af73c9f0a\", \"files\": {}}", + "dest": "cargo/vendor/cairo-rs-0.17.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cairo-sys-rs/cairo-sys-rs-0.17.10.crate", + "sha256": "691d0c66b1fb4881be80a760cb8fe76ea97218312f9dfe2c9cc0f496ca279cb1", + "dest": "cargo/vendor/cairo-sys-rs-0.17.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"691d0c66b1fb4881be80a760cb8fe76ea97218312f9dfe2c9cc0f496ca279cb1\", \"files\": {}}", + "dest": "cargo/vendor/cairo-sys-rs-0.17.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/castaway/castaway-0.1.2.crate", + "sha256": "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6", + "dest": "cargo/vendor/castaway-0.1.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6\", \"files\": {}}", + "dest": "cargo/vendor/castaway-0.1.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cc/cc-1.1.10.crate", + "sha256": "e9e8aabfac534be767c909e0690571677d49f41bd8465ae876fe043d52ba5292", + "dest": "cargo/vendor/cc-1.1.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"e9e8aabfac534be767c909e0690571677d49f41bd8465ae876fe043d52ba5292\", \"files\": {}}", + "dest": "cargo/vendor/cc-1.1.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cesu8/cesu8-1.1.0.crate", + "sha256": "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c", + "dest": "cargo/vendor/cesu8-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c\", \"files\": {}}", + "dest": "cargo/vendor/cesu8-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cexpr/cexpr-0.6.0.crate", + "sha256": "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766", + "dest": "cargo/vendor/cexpr-0.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766\", \"files\": {}}", + "dest": "cargo/vendor/cexpr-0.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cfg-expr/cfg-expr-0.15.5.crate", + "sha256": "03915af431787e6ffdcc74c645077518c6b6e01f80b761e0fbbfa288536311b3", + "dest": "cargo/vendor/cfg-expr-0.15.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"03915af431787e6ffdcc74c645077518c6b6e01f80b761e0fbbfa288536311b3\", \"files\": {}}", + "dest": "cargo/vendor/cfg-expr-0.15.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cfg-expr/cfg-expr-0.17.0.crate", + "sha256": "d0890061c4d3223e7267f3bad2ec40b997d64faac1c2815a4a9d95018e2b9e9c", + "dest": "cargo/vendor/cfg-expr-0.17.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"d0890061c4d3223e7267f3bad2ec40b997d64faac1c2815a4a9d95018e2b9e9c\", \"files\": {}}", + "dest": "cargo/vendor/cfg-expr-0.17.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cfg-if/cfg-if-1.0.0.crate", + "sha256": "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd", + "dest": "cargo/vendor/cfg-if-1.0.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd\", \"files\": {}}", + "dest": "cargo/vendor/cfg-if-1.0.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/chrono/chrono-0.4.31.crate", + "sha256": "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38", + "dest": "cargo/vendor/chrono-0.4.31" + }, + { + "type": "inline", + "contents": "{\"package\": \"7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38\", \"files\": {}}", + "dest": "cargo/vendor/chrono-0.4.31", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cipher/cipher-0.3.0.crate", + "sha256": "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7", + "dest": "cargo/vendor/cipher-0.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7\", \"files\": {}}", + "dest": "cargo/vendor/cipher-0.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cipher/cipher-0.4.4.crate", + "sha256": "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad", + "dest": "cargo/vendor/cipher-0.4.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad\", \"files\": {}}", + "dest": "cargo/vendor/cipher-0.4.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/clang-sys/clang-sys-1.6.1.crate", + "sha256": "c688fc74432808e3eb684cae8830a86be1d66a2bd58e1f248ed0960a590baf6f", + "dest": "cargo/vendor/clang-sys-1.6.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"c688fc74432808e3eb684cae8830a86be1d66a2bd58e1f248ed0960a590baf6f\", \"files\": {}}", + "dest": "cargo/vendor/clang-sys-1.6.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cmake/cmake-0.1.51.crate", + "sha256": "fb1e43aa7fd152b1f968787f7dbcdeb306d1867ff373c69955211876c053f91a", + "dest": "cargo/vendor/cmake-0.1.51" + }, + { + "type": "inline", + "contents": "{\"package\": \"fb1e43aa7fd152b1f968787f7dbcdeb306d1867ff373c69955211876c053f91a\", \"files\": {}}", + "dest": "cargo/vendor/cmake-0.1.51", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/colorchoice/colorchoice-1.0.3.crate", + "sha256": "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990", + "dest": "cargo/vendor/colorchoice-1.0.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990\", \"files\": {}}", + "dest": "cargo/vendor/colorchoice-1.0.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/combine/combine-4.6.6.crate", + "sha256": "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4", + "dest": "cargo/vendor/combine-4.6.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4\", \"files\": {}}", + "dest": "cargo/vendor/combine-4.6.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/concurrent-queue/concurrent-queue-2.3.0.crate", + "sha256": "f057a694a54f12365049b0958a1685bb52d567f5593b355fbf685838e873d400", + "dest": "cargo/vendor/concurrent-queue-2.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"f057a694a54f12365049b0958a1685bb52d567f5593b355fbf685838e873d400\", \"files\": {}}", + "dest": "cargo/vendor/concurrent-queue-2.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/const-oid/const-oid-0.9.6.crate", + "sha256": "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8", + "dest": "cargo/vendor/const-oid-0.9.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8\", \"files\": {}}", + "dest": "cargo/vendor/const-oid-0.9.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/core-foundation/core-foundation-0.9.3.crate", + "sha256": "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146", + "dest": "cargo/vendor/core-foundation-0.9.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146\", \"files\": {}}", + "dest": "cargo/vendor/core-foundation-0.9.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/core-foundation-sys/core-foundation-sys-0.8.4.crate", + "sha256": "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa", + "dest": "cargo/vendor/core-foundation-sys-0.8.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa\", \"files\": {}}", + "dest": "cargo/vendor/core-foundation-sys-0.8.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/coreaudio-rs/coreaudio-rs-0.11.3.crate", + "sha256": "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace", + "dest": "cargo/vendor/coreaudio-rs-0.11.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace\", \"files\": {}}", + "dest": "cargo/vendor/coreaudio-rs-0.11.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/coreaudio-sys/coreaudio-sys-0.2.13.crate", + "sha256": "d8478e5bdad14dce236b9898ea002eabfa87cbe14f0aa538dbe3b6a4bec4332d", + "dest": "cargo/vendor/coreaudio-sys-0.2.13" + }, + { + "type": "inline", + "contents": "{\"package\": \"d8478e5bdad14dce236b9898ea002eabfa87cbe14f0aa538dbe3b6a4bec4332d\", \"files\": {}}", + "dest": "cargo/vendor/coreaudio-sys-0.2.13", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cpal/cpal-0.15.3.crate", + "sha256": "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779", + "dest": "cargo/vendor/cpal-0.15.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779\", \"files\": {}}", + "dest": "cargo/vendor/cpal-0.15.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cpufeatures/cpufeatures-0.2.11.crate", + "sha256": "ce420fe07aecd3e67c5f910618fe65e94158f6dcc0adf44e00d69ce2bdfe0fd0", + "dest": "cargo/vendor/cpufeatures-0.2.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"ce420fe07aecd3e67c5f910618fe65e94158f6dcc0adf44e00d69ce2bdfe0fd0\", \"files\": {}}", + "dest": "cargo/vendor/cpufeatures-0.2.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/crossbeam-utils/crossbeam-utils-0.8.16.crate", + "sha256": "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294", + "dest": "cargo/vendor/crossbeam-utils-0.8.16" + }, + { + "type": "inline", + "contents": "{\"package\": \"5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294\", \"files\": {}}", + "dest": "cargo/vendor/crossbeam-utils-0.8.16", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/crypto-common/crypto-common-0.1.6.crate", + "sha256": "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3", + "dest": "cargo/vendor/crypto-common-0.1.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3\", \"files\": {}}", + "dest": "cargo/vendor/crypto-common-0.1.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ctr/ctr-0.9.2.crate", + "sha256": "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835", + "dest": "cargo/vendor/ctr-0.9.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835\", \"files\": {}}", + "dest": "cargo/vendor/ctr-0.9.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/curl/curl-0.4.44.crate", + "sha256": "509bd11746c7ac09ebd19f0b17782eae80aadee26237658a6b4808afb5c11a22", + "dest": "cargo/vendor/curl-0.4.44" + }, + { + "type": "inline", + "contents": "{\"package\": \"509bd11746c7ac09ebd19f0b17782eae80aadee26237658a6b4808afb5c11a22\", \"files\": {}}", + "dest": "cargo/vendor/curl-0.4.44", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/curl-sys/curl-sys-0.4.68+curl-8.4.0.crate", + "sha256": "b4a0d18d88360e374b16b2273c832b5e57258ffc1d4aa4f96b108e0738d5752f", + "dest": "cargo/vendor/curl-sys-0.4.68+curl-8.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"b4a0d18d88360e374b16b2273c832b5e57258ffc1d4aa4f96b108e0738d5752f\", \"files\": {}}", + "dest": "cargo/vendor/curl-sys-0.4.68+curl-8.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/darling/darling-0.20.10.crate", + "sha256": "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989", + "dest": "cargo/vendor/darling-0.20.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989\", \"files\": {}}", + "dest": "cargo/vendor/darling-0.20.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/darling_core/darling_core-0.20.10.crate", + "sha256": "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5", + "dest": "cargo/vendor/darling_core-0.20.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5\", \"files\": {}}", + "dest": "cargo/vendor/darling_core-0.20.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/darling_macro/darling_macro-0.20.10.crate", + "sha256": "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806", + "dest": "cargo/vendor/darling_macro-0.20.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806\", \"files\": {}}", + "dest": "cargo/vendor/darling_macro-0.20.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/dasp_sample/dasp_sample-0.11.0.crate", + "sha256": "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f", + "dest": "cargo/vendor/dasp_sample-0.11.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f\", \"files\": {}}", + "dest": "cargo/vendor/dasp_sample-0.11.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/data-encoding/data-encoding-2.6.0.crate", + "sha256": "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2", + "dest": "cargo/vendor/data-encoding-2.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2\", \"files\": {}}", + "dest": "cargo/vendor/data-encoding-2.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/der/der-0.7.9.crate", + "sha256": "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0", + "dest": "cargo/vendor/der-0.7.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0\", \"files\": {}}", + "dest": "cargo/vendor/der-0.7.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/deranged/deranged-0.3.11.crate", + "sha256": "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4", + "dest": "cargo/vendor/deranged-0.3.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4\", \"files\": {}}", + "dest": "cargo/vendor/deranged-0.3.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/derivative/derivative-2.2.0.crate", + "sha256": "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b", + "dest": "cargo/vendor/derivative-2.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b\", \"files\": {}}", + "dest": "cargo/vendor/derivative-2.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/derive_builder/derive_builder-0.20.2.crate", + "sha256": "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947", + "dest": "cargo/vendor/derive_builder-0.20.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947\", \"files\": {}}", + "dest": "cargo/vendor/derive_builder-0.20.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/derive_builder_core/derive_builder_core-0.20.2.crate", + "sha256": "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8", + "dest": "cargo/vendor/derive_builder_core-0.20.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8\", \"files\": {}}", + "dest": "cargo/vendor/derive_builder_core-0.20.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/derive_builder_macro/derive_builder_macro-0.20.2.crate", + "sha256": "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c", + "dest": "cargo/vendor/derive_builder_macro-0.20.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c\", \"files\": {}}", + "dest": "cargo/vendor/derive_builder_macro-0.20.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/digest/digest-0.10.7.crate", + "sha256": "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292", + "dest": "cargo/vendor/digest-0.10.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292\", \"files\": {}}", + "dest": "cargo/vendor/digest-0.10.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/dunce/dunce-1.0.5.crate", + "sha256": "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813", + "dest": "cargo/vendor/dunce-1.0.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813\", \"files\": {}}", + "dest": "cargo/vendor/dunce-1.0.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/either/either-1.13.0.crate", + "sha256": "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0", + "dest": "cargo/vendor/either-1.13.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0\", \"files\": {}}", + "dest": "cargo/vendor/either-1.13.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/encoding_rs/encoding_rs-0.8.33.crate", + "sha256": "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1", + "dest": "cargo/vendor/encoding_rs-0.8.33" + }, + { + "type": "inline", + "contents": "{\"package\": \"7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1\", \"files\": {}}", + "dest": "cargo/vendor/encoding_rs-0.8.33", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/enumflags2/enumflags2-0.7.8.crate", + "sha256": "5998b4f30320c9d93aed72f63af821bfdac50465b75428fce77b48ec482c3939", + "dest": "cargo/vendor/enumflags2-0.7.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"5998b4f30320c9d93aed72f63af821bfdac50465b75428fce77b48ec482c3939\", \"files\": {}}", + "dest": "cargo/vendor/enumflags2-0.7.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/enumflags2_derive/enumflags2_derive-0.7.8.crate", + "sha256": "f95e2801cd355d4a1a3e3953ce6ee5ae9603a5c833455343a8bfe3f44d418246", + "dest": "cargo/vendor/enumflags2_derive-0.7.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"f95e2801cd355d4a1a3e3953ce6ee5ae9603a5c833455343a8bfe3f44d418246\", \"files\": {}}", + "dest": "cargo/vendor/enumflags2_derive-0.7.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/env_filter/env_filter-0.1.2.crate", + "sha256": "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab", + "dest": "cargo/vendor/env_filter-0.1.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab\", \"files\": {}}", + "dest": "cargo/vendor/env_filter-0.1.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/env_logger/env_logger-0.10.0.crate", + "sha256": "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0", + "dest": "cargo/vendor/env_logger-0.10.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0\", \"files\": {}}", + "dest": "cargo/vendor/env_logger-0.10.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/env_logger/env_logger-0.11.5.crate", + "sha256": "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d", + "dest": "cargo/vendor/env_logger-0.11.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d\", \"files\": {}}", + "dest": "cargo/vendor/env_logger-0.11.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/equivalent/equivalent-1.0.1.crate", + "sha256": "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5", + "dest": "cargo/vendor/equivalent-1.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5\", \"files\": {}}", + "dest": "cargo/vendor/equivalent-1.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/errno/errno-0.3.5.crate", + "sha256": "ac3e13f66a2f95e32a39eaa81f6b95d42878ca0e1db0c7543723dfe12557e860", + "dest": "cargo/vendor/errno-0.3.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"ac3e13f66a2f95e32a39eaa81f6b95d42878ca0e1db0c7543723dfe12557e860\", \"files\": {}}", + "dest": "cargo/vendor/errno-0.3.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/event-listener/event-listener-2.5.3.crate", + "sha256": "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0", + "dest": "cargo/vendor/event-listener-2.5.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0\", \"files\": {}}", + "dest": "cargo/vendor/event-listener-2.5.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/event-listener/event-listener-3.0.1.crate", + "sha256": "01cec0252c2afff729ee6f00e903d479fba81784c8e2bd77447673471fdfaea1", + "dest": "cargo/vendor/event-listener-3.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"01cec0252c2afff729ee6f00e903d479fba81784c8e2bd77447673471fdfaea1\", \"files\": {}}", + "dest": "cargo/vendor/event-listener-3.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fastrand/fastrand-1.9.0.crate", + "sha256": "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be", + "dest": "cargo/vendor/fastrand-1.9.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be\", \"files\": {}}", + "dest": "cargo/vendor/fastrand-1.9.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fastrand/fastrand-2.0.1.crate", + "sha256": "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5", + "dest": "cargo/vendor/fastrand-2.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5\", \"files\": {}}", + "dest": "cargo/vendor/fastrand-2.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/field-offset/field-offset-0.3.6.crate", + "sha256": "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f", + "dest": "cargo/vendor/field-offset-0.3.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f\", \"files\": {}}", + "dest": "cargo/vendor/field-offset-0.3.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fixedbitset/fixedbitset-0.4.2.crate", + "sha256": "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80", + "dest": "cargo/vendor/fixedbitset-0.4.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80\", \"files\": {}}", + "dest": "cargo/vendor/fixedbitset-0.4.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fnv/fnv-1.0.7.crate", + "sha256": "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1", + "dest": "cargo/vendor/fnv-1.0.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1\", \"files\": {}}", + "dest": "cargo/vendor/fnv-1.0.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/form_urlencoded/form_urlencoded-1.2.0.crate", + "sha256": "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652", + "dest": "cargo/vendor/form_urlencoded-1.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652\", \"files\": {}}", + "dest": "cargo/vendor/form_urlencoded-1.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/fs_extra/fs_extra-1.3.0.crate", + "sha256": "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c", + "dest": "cargo/vendor/fs_extra-1.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c\", \"files\": {}}", + "dest": "cargo/vendor/fs_extra-1.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures/futures-0.3.29.crate", + "sha256": "da0290714b38af9b4a7b094b8a37086d1b4e61f2df9122c3cad2577669145335", + "dest": "cargo/vendor/futures-0.3.29" + }, + { + "type": "inline", + "contents": "{\"package\": \"da0290714b38af9b4a7b094b8a37086d1b4e61f2df9122c3cad2577669145335\", \"files\": {}}", + "dest": "cargo/vendor/futures-0.3.29", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-channel/futures-channel-0.3.31.crate", + "sha256": "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10", + "dest": "cargo/vendor/futures-channel-0.3.31" + }, + { + "type": "inline", + "contents": "{\"package\": \"2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10\", \"files\": {}}", + "dest": "cargo/vendor/futures-channel-0.3.31", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-core/futures-core-0.3.31.crate", + "sha256": "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e", + "dest": "cargo/vendor/futures-core-0.3.31" + }, + { + "type": "inline", + "contents": "{\"package\": \"05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e\", \"files\": {}}", + "dest": "cargo/vendor/futures-core-0.3.31", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-executor/futures-executor-0.3.29.crate", + "sha256": "0f4fb8693db0cf099eadcca0efe2a5a22e4550f98ed16aba6c48700da29597bc", + "dest": "cargo/vendor/futures-executor-0.3.29" + }, + { + "type": "inline", + "contents": "{\"package\": \"0f4fb8693db0cf099eadcca0efe2a5a22e4550f98ed16aba6c48700da29597bc\", \"files\": {}}", + "dest": "cargo/vendor/futures-executor-0.3.29", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-io/futures-io-0.3.31.crate", + "sha256": "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6", + "dest": "cargo/vendor/futures-io-0.3.31" + }, + { + "type": "inline", + "contents": "{\"package\": \"9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6\", \"files\": {}}", + "dest": "cargo/vendor/futures-io-0.3.31", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-lite/futures-lite-1.13.0.crate", + "sha256": "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce", + "dest": "cargo/vendor/futures-lite-1.13.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce\", \"files\": {}}", + "dest": "cargo/vendor/futures-lite-1.13.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-macro/futures-macro-0.3.31.crate", + "sha256": "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650", + "dest": "cargo/vendor/futures-macro-0.3.31" + }, + { + "type": "inline", + "contents": "{\"package\": \"162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650\", \"files\": {}}", + "dest": "cargo/vendor/futures-macro-0.3.31", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-sink/futures-sink-0.3.31.crate", + "sha256": "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7", + "dest": "cargo/vendor/futures-sink-0.3.31" + }, + { + "type": "inline", + "contents": "{\"package\": \"e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7\", \"files\": {}}", + "dest": "cargo/vendor/futures-sink-0.3.31", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-task/futures-task-0.3.31.crate", + "sha256": "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988", + "dest": "cargo/vendor/futures-task-0.3.31" + }, + { + "type": "inline", + "contents": "{\"package\": \"f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988\", \"files\": {}}", + "dest": "cargo/vendor/futures-task-0.3.31", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-timer/futures-timer-3.0.3.crate", + "sha256": "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24", + "dest": "cargo/vendor/futures-timer-3.0.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24\", \"files\": {}}", + "dest": "cargo/vendor/futures-timer-3.0.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/futures-util/futures-util-0.3.31.crate", + "sha256": "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81", + "dest": "cargo/vendor/futures-util-0.3.31" + }, + { + "type": "inline", + "contents": "{\"package\": \"9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81\", \"files\": {}}", + "dest": "cargo/vendor/futures-util-0.3.31", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gdk-pixbuf/gdk-pixbuf-0.17.10.crate", + "sha256": "695d6bc846438c5708b07007537b9274d883373dd30858ca881d7d71b5540717", + "dest": "cargo/vendor/gdk-pixbuf-0.17.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"695d6bc846438c5708b07007537b9274d883373dd30858ca881d7d71b5540717\", \"files\": {}}", + "dest": "cargo/vendor/gdk-pixbuf-0.17.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gdk-pixbuf-sys/gdk-pixbuf-sys-0.17.10.crate", + "sha256": "9285ec3c113c66d7d0ab5676599176f1f42f4944ca1b581852215bf5694870cb", + "dest": "cargo/vendor/gdk-pixbuf-sys-0.17.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"9285ec3c113c66d7d0ab5676599176f1f42f4944ca1b581852215bf5694870cb\", \"files\": {}}", + "dest": "cargo/vendor/gdk-pixbuf-sys-0.17.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gdk4/gdk4-0.6.3.crate", + "sha256": "c3abf96408a26e3eddf881a7f893a1e111767137136e347745e8ea6ed12731ff", + "dest": "cargo/vendor/gdk4-0.6.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"c3abf96408a26e3eddf881a7f893a1e111767137136e347745e8ea6ed12731ff\", \"files\": {}}", + "dest": "cargo/vendor/gdk4-0.6.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gdk4-sys/gdk4-sys-0.6.3.crate", + "sha256": "1bc92aa1608c089c49393d014c38ac0390d01e4841e1fedaa75dbcef77aaed64", + "dest": "cargo/vendor/gdk4-sys-0.6.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"1bc92aa1608c089c49393d014c38ac0390d01e4841e1fedaa75dbcef77aaed64\", \"files\": {}}", + "dest": "cargo/vendor/gdk4-sys-0.6.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/generic-array/generic-array-0.14.7.crate", + "sha256": "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a", + "dest": "cargo/vendor/generic-array-0.14.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a\", \"files\": {}}", + "dest": "cargo/vendor/generic-array-0.14.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/getopts/getopts-0.2.21.crate", + "sha256": "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5", + "dest": "cargo/vendor/getopts-0.2.21" + }, + { + "type": "inline", + "contents": "{\"package\": \"14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5\", \"files\": {}}", + "dest": "cargo/vendor/getopts-0.2.21", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/getrandom/getrandom-0.2.10.crate", + "sha256": "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427", + "dest": "cargo/vendor/getrandom-0.2.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427\", \"files\": {}}", + "dest": "cargo/vendor/getrandom-0.2.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gettext-rs/gettext-rs-0.7.0.crate", + "sha256": "e49ea8a8fad198aaa1f9655a2524b64b70eb06b2f3ff37da407566c93054f364", + "dest": "cargo/vendor/gettext-rs-0.7.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"e49ea8a8fad198aaa1f9655a2524b64b70eb06b2f3ff37da407566c93054f364\", \"files\": {}}", + "dest": "cargo/vendor/gettext-rs-0.7.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gettext-sys/gettext-sys-0.21.3.crate", + "sha256": "c63ce2e00f56a206778276704bbe38564c8695249fdc8f354b4ef71c57c3839d", + "dest": "cargo/vendor/gettext-sys-0.21.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"c63ce2e00f56a206778276704bbe38564c8695249fdc8f354b4ef71c57c3839d\", \"files\": {}}", + "dest": "cargo/vendor/gettext-sys-0.21.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gimli/gimli-0.28.0.crate", + "sha256": "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0", + "dest": "cargo/vendor/gimli-0.28.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0\", \"files\": {}}", + "dest": "cargo/vendor/gimli-0.28.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gio/gio-0.17.10.crate", + "sha256": "a6973e92937cf98689b6a054a9e56c657ed4ff76de925e36fc331a15f0c5d30a", + "dest": "cargo/vendor/gio-0.17.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"a6973e92937cf98689b6a054a9e56c657ed4ff76de925e36fc331a15f0c5d30a\", \"files\": {}}", + "dest": "cargo/vendor/gio-0.17.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gio-sys/gio-sys-0.17.10.crate", + "sha256": "0ccf87c30a12c469b6d958950f6a9c09f2be20b7773f7e70d20b867fdf2628c3", + "dest": "cargo/vendor/gio-sys-0.17.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"0ccf87c30a12c469b6d958950f6a9c09f2be20b7773f7e70d20b867fdf2628c3\", \"files\": {}}", + "dest": "cargo/vendor/gio-sys-0.17.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gio-sys/gio-sys-0.20.4.crate", + "sha256": "4f7efc368de04755344f0084104835b6bb71df2c1d41e37d863947392a894779", + "dest": "cargo/vendor/gio-sys-0.20.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"4f7efc368de04755344f0084104835b6bb71df2c1d41e37d863947392a894779\", \"files\": {}}", + "dest": "cargo/vendor/gio-sys-0.20.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/glib/glib-0.17.10.crate", + "sha256": "d3fad45ba8d4d2cea612b432717e834f48031cd8853c8aaf43b2c79fec8d144b", + "dest": "cargo/vendor/glib-0.17.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"d3fad45ba8d4d2cea612b432717e834f48031cd8853c8aaf43b2c79fec8d144b\", \"files\": {}}", + "dest": "cargo/vendor/glib-0.17.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/glib/glib-0.20.4.crate", + "sha256": "adcf1ec6d3650bf9fdbc6cee242d4fcebc6f6bfd9bea5b929b6a8b7344eb85ff", + "dest": "cargo/vendor/glib-0.20.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"adcf1ec6d3650bf9fdbc6cee242d4fcebc6f6bfd9bea5b929b6a8b7344eb85ff\", \"files\": {}}", + "dest": "cargo/vendor/glib-0.20.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/glib-macros/glib-macros-0.17.10.crate", + "sha256": "eca5c79337338391f1ab8058d6698125034ce8ef31b72a442437fa6c8580de26", + "dest": "cargo/vendor/glib-macros-0.17.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"eca5c79337338391f1ab8058d6698125034ce8ef31b72a442437fa6c8580de26\", \"files\": {}}", + "dest": "cargo/vendor/glib-macros-0.17.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/glib-macros/glib-macros-0.20.4.crate", + "sha256": "a6bf88f70cd5720a6197639dcabcb378dd528d0cb68cb1f45e3b358bcb841cd7", + "dest": "cargo/vendor/glib-macros-0.20.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"a6bf88f70cd5720a6197639dcabcb378dd528d0cb68cb1f45e3b358bcb841cd7\", \"files\": {}}", + "dest": "cargo/vendor/glib-macros-0.20.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/glib-sys/glib-sys-0.17.10.crate", + "sha256": "d80aa6ea7bba0baac79222204aa786a6293078c210abe69ef1336911d4bdc4f0", + "dest": "cargo/vendor/glib-sys-0.17.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"d80aa6ea7bba0baac79222204aa786a6293078c210abe69ef1336911d4bdc4f0\", \"files\": {}}", + "dest": "cargo/vendor/glib-sys-0.17.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/glib-sys/glib-sys-0.20.4.crate", + "sha256": "5f9eca5d88cfa6a453b00d203287c34a2b7cac3a7831779aa2bb0b3c7233752b", + "dest": "cargo/vendor/glib-sys-0.20.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"5f9eca5d88cfa6a453b00d203287c34a2b7cac3a7831779aa2bb0b3c7233752b\", \"files\": {}}", + "dest": "cargo/vendor/glib-sys-0.20.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/glob/glob-0.3.1.crate", + "sha256": "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b", + "dest": "cargo/vendor/glob-0.3.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b\", \"files\": {}}", + "dest": "cargo/vendor/glob-0.3.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gloo-timers/gloo-timers-0.2.6.crate", + "sha256": "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c", + "dest": "cargo/vendor/gloo-timers-0.2.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c\", \"files\": {}}", + "dest": "cargo/vendor/gloo-timers-0.2.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gobject-sys/gobject-sys-0.17.10.crate", + "sha256": "cd34c3317740a6358ec04572c1bcfd3ac0b5b6529275fae255b237b314bb8062", + "dest": "cargo/vendor/gobject-sys-0.17.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"cd34c3317740a6358ec04572c1bcfd3ac0b5b6529275fae255b237b314bb8062\", \"files\": {}}", + "dest": "cargo/vendor/gobject-sys-0.17.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gobject-sys/gobject-sys-0.20.4.crate", + "sha256": "a4c674d2ff8478cf0ec29d2be730ed779fef54415a2fb4b565c52def62696462", + "dest": "cargo/vendor/gobject-sys-0.20.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"a4c674d2ff8478cf0ec29d2be730ed779fef54415a2fb4b565c52def62696462\", \"files\": {}}", + "dest": "cargo/vendor/gobject-sys-0.20.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/governor/governor-0.6.3.crate", + "sha256": "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b", + "dest": "cargo/vendor/governor-0.6.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b\", \"files\": {}}", + "dest": "cargo/vendor/governor-0.6.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/graphene-rs/graphene-rs-0.17.10.crate", + "sha256": "def4bb01265b59ed548b05455040d272d989b3012c42d4c1bbd39083cb9b40d9", + "dest": "cargo/vendor/graphene-rs-0.17.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"def4bb01265b59ed548b05455040d272d989b3012c42d4c1bbd39083cb9b40d9\", \"files\": {}}", + "dest": "cargo/vendor/graphene-rs-0.17.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/graphene-sys/graphene-sys-0.17.10.crate", + "sha256": "1856fc817e6a6675e36cea0bd9a3afe296f5d9709d1e2d3182803ac77f0ab21d", + "dest": "cargo/vendor/graphene-sys-0.17.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"1856fc817e6a6675e36cea0bd9a3afe296f5d9709d1e2d3182803ac77f0ab21d\", \"files\": {}}", + "dest": "cargo/vendor/graphene-sys-0.17.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gsk4/gsk4-0.6.3.crate", + "sha256": "6f01ef44fa7cac15e2da9978529383e6bee03e570ba5bf7036b4c10a15cc3a3c", + "dest": "cargo/vendor/gsk4-0.6.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"6f01ef44fa7cac15e2da9978529383e6bee03e570ba5bf7036b4c10a15cc3a3c\", \"files\": {}}", + "dest": "cargo/vendor/gsk4-0.6.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gsk4-sys/gsk4-sys-0.6.3.crate", + "sha256": "c07a84fb4dcf1323d29435aa85e2f5f58bef564342bef06775ec7bd0da1f01b0", + "dest": "cargo/vendor/gsk4-sys-0.6.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"c07a84fb4dcf1323d29435aa85e2f5f58bef564342bef06775ec7bd0da1f01b0\", \"files\": {}}", + "dest": "cargo/vendor/gsk4-sys-0.6.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gstreamer/gstreamer-0.23.2.crate", + "sha256": "49ecf3bcfc2ceb82ce02437f53ff2fcaee5e7d45ae697ab64a018408749779b9", + "dest": "cargo/vendor/gstreamer-0.23.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"49ecf3bcfc2ceb82ce02437f53ff2fcaee5e7d45ae697ab64a018408749779b9\", \"files\": {}}", + "dest": "cargo/vendor/gstreamer-0.23.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gstreamer-app/gstreamer-app-0.23.2.crate", + "sha256": "54a4ec9f0d2037349c82f589c1cbfe788a62f4941851924bb7c3929a6a790007", + "dest": "cargo/vendor/gstreamer-app-0.23.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"54a4ec9f0d2037349c82f589c1cbfe788a62f4941851924bb7c3929a6a790007\", \"files\": {}}", + "dest": "cargo/vendor/gstreamer-app-0.23.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gstreamer-app-sys/gstreamer-app-sys-0.23.2.crate", + "sha256": "08d5cac633c1ab7030c777c8c58c682a0c763bbc4127bccc370dabe39c01a12d", + "dest": "cargo/vendor/gstreamer-app-sys-0.23.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"08d5cac633c1ab7030c777c8c58c682a0c763bbc4127bccc370dabe39c01a12d\", \"files\": {}}", + "dest": "cargo/vendor/gstreamer-app-sys-0.23.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gstreamer-audio/gstreamer-audio-0.23.2.crate", + "sha256": "36d39b07213f83055fc705a384fa32ad581776b8e5b04c86f3a419ec5dfc0f81", + "dest": "cargo/vendor/gstreamer-audio-0.23.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"36d39b07213f83055fc705a384fa32ad581776b8e5b04c86f3a419ec5dfc0f81\", \"files\": {}}", + "dest": "cargo/vendor/gstreamer-audio-0.23.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gstreamer-audio-sys/gstreamer-audio-sys-0.23.2.crate", + "sha256": "d84744e7ac8f8bc0cf76b7be40f2d5be12e6cf197e4c6ca9d3438109c21e2f51", + "dest": "cargo/vendor/gstreamer-audio-sys-0.23.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"d84744e7ac8f8bc0cf76b7be40f2d5be12e6cf197e4c6ca9d3438109c21e2f51\", \"files\": {}}", + "dest": "cargo/vendor/gstreamer-audio-sys-0.23.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gstreamer-base/gstreamer-base-0.23.2.crate", + "sha256": "46ce7330d2995138a77192ea20961422ddee1578e1a47480acb820c43ceb0e2d", + "dest": "cargo/vendor/gstreamer-base-0.23.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"46ce7330d2995138a77192ea20961422ddee1578e1a47480acb820c43ceb0e2d\", \"files\": {}}", + "dest": "cargo/vendor/gstreamer-base-0.23.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gstreamer-base-sys/gstreamer-base-sys-0.23.2.crate", + "sha256": "7796e694c21c215447811c9cff694dce1fc6e02b0bbafb75cd8583b6aefe9e5f", + "dest": "cargo/vendor/gstreamer-base-sys-0.23.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"7796e694c21c215447811c9cff694dce1fc6e02b0bbafb75cd8583b6aefe9e5f\", \"files\": {}}", + "dest": "cargo/vendor/gstreamer-base-sys-0.23.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gstreamer-sys/gstreamer-sys-0.23.2.crate", + "sha256": "cb3859929db32f26a35818d0d9ed82f0887c9221ca402ddefaea2bb99833d535", + "dest": "cargo/vendor/gstreamer-sys-0.23.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"cb3859929db32f26a35818d0d9ed82f0887c9221ca402ddefaea2bb99833d535\", \"files\": {}}", + "dest": "cargo/vendor/gstreamer-sys-0.23.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gtk4/gtk4-0.6.6.crate", + "sha256": "b28a32a04cd75cef14a0983f8b0c669e0fe152a0a7725accdeb594e2c764c88b", + "dest": "cargo/vendor/gtk4-0.6.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"b28a32a04cd75cef14a0983f8b0c669e0fe152a0a7725accdeb594e2c764c88b\", \"files\": {}}", + "dest": "cargo/vendor/gtk4-0.6.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gtk4-macros/gtk4-macros-0.6.6.crate", + "sha256": "6a4d6b61570f76d3ee542d984da443b1cd69b6105264c61afec3abed08c2500f", + "dest": "cargo/vendor/gtk4-macros-0.6.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"6a4d6b61570f76d3ee542d984da443b1cd69b6105264c61afec3abed08c2500f\", \"files\": {}}", + "dest": "cargo/vendor/gtk4-macros-0.6.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/gtk4-sys/gtk4-sys-0.6.3.crate", + "sha256": "5f8283f707b07e019e76c7f2934bdd4180c277e08aa93f4c0d8dd07b7a34e22f", + "dest": "cargo/vendor/gtk4-sys-0.6.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"5f8283f707b07e019e76c7f2934bdd4180c277e08aa93f4c0d8dd07b7a34e22f\", \"files\": {}}", + "dest": "cargo/vendor/gtk4-sys-0.6.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/h2/h2-0.3.26.crate", + "sha256": "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8", + "dest": "cargo/vendor/h2-0.3.26" + }, + { + "type": "inline", + "contents": "{\"package\": \"81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8\", \"files\": {}}", + "dest": "cargo/vendor/h2-0.3.26", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/h2/h2-0.4.6.crate", + "sha256": "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205", + "dest": "cargo/vendor/h2-0.4.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205\", \"files\": {}}", + "dest": "cargo/vendor/h2-0.4.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hashbrown/hashbrown-0.15.0.crate", + "sha256": "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb", + "dest": "cargo/vendor/hashbrown-0.15.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb\", \"files\": {}}", + "dest": "cargo/vendor/hashbrown-0.15.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/headers/headers-0.4.0.crate", + "sha256": "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9", + "dest": "cargo/vendor/headers-0.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9\", \"files\": {}}", + "dest": "cargo/vendor/headers-0.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/headers-core/headers-core-0.3.0.crate", + "sha256": "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4", + "dest": "cargo/vendor/headers-core-0.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4\", \"files\": {}}", + "dest": "cargo/vendor/headers-core-0.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/heck/heck-0.4.1.crate", + "sha256": "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8", + "dest": "cargo/vendor/heck-0.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8\", \"files\": {}}", + "dest": "cargo/vendor/heck-0.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/heck/heck-0.5.0.crate", + "sha256": "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea", + "dest": "cargo/vendor/heck-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea\", \"files\": {}}", + "dest": "cargo/vendor/heck-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hermit-abi/hermit-abi-0.3.9.crate", + "sha256": "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024", + "dest": "cargo/vendor/hermit-abi-0.3.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024\", \"files\": {}}", + "dest": "cargo/vendor/hermit-abi-0.3.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hex/hex-0.4.3.crate", + "sha256": "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70", + "dest": "cargo/vendor/hex-0.4.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70\", \"files\": {}}", + "dest": "cargo/vendor/hex-0.4.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hkdf/hkdf-0.12.3.crate", + "sha256": "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437", + "dest": "cargo/vendor/hkdf-0.12.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437\", \"files\": {}}", + "dest": "cargo/vendor/hkdf-0.12.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hmac/hmac-0.12.1.crate", + "sha256": "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e", + "dest": "cargo/vendor/hmac-0.12.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e\", \"files\": {}}", + "dest": "cargo/vendor/hmac-0.12.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/home/home-0.5.9.crate", + "sha256": "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5", + "dest": "cargo/vendor/home-0.5.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5\", \"files\": {}}", + "dest": "cargo/vendor/home-0.5.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hostname/hostname-0.4.0.crate", + "sha256": "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba", + "dest": "cargo/vendor/hostname-0.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba\", \"files\": {}}", + "dest": "cargo/vendor/hostname-0.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/http/http-0.2.9.crate", + "sha256": "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482", + "dest": "cargo/vendor/http-0.2.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482\", \"files\": {}}", + "dest": "cargo/vendor/http-0.2.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/http/http-1.1.0.crate", + "sha256": "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258", + "dest": "cargo/vendor/http-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258\", \"files\": {}}", + "dest": "cargo/vendor/http-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/http-body/http-body-0.4.5.crate", + "sha256": "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1", + "dest": "cargo/vendor/http-body-0.4.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1\", \"files\": {}}", + "dest": "cargo/vendor/http-body-0.4.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/http-body/http-body-1.0.1.crate", + "sha256": "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184", + "dest": "cargo/vendor/http-body-1.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184\", \"files\": {}}", + "dest": "cargo/vendor/http-body-1.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/http-body-util/http-body-util-0.1.2.crate", + "sha256": "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f", + "dest": "cargo/vendor/http-body-util-0.1.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f\", \"files\": {}}", + "dest": "cargo/vendor/http-body-util-0.1.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/httparse/httparse-1.8.0.crate", + "sha256": "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904", + "dest": "cargo/vendor/httparse-1.8.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904\", \"files\": {}}", + "dest": "cargo/vendor/httparse-1.8.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/httpdate/httpdate-1.0.3.crate", + "sha256": "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9", + "dest": "cargo/vendor/httpdate-1.0.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9\", \"files\": {}}", + "dest": "cargo/vendor/httpdate-1.0.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/humantime/humantime-2.1.0.crate", + "sha256": "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4", + "dest": "cargo/vendor/humantime-2.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4\", \"files\": {}}", + "dest": "cargo/vendor/humantime-2.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hyper/hyper-0.14.27.crate", + "sha256": "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468", + "dest": "cargo/vendor/hyper-0.14.27" + }, + { + "type": "inline", + "contents": "{\"package\": \"ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468\", \"files\": {}}", + "dest": "cargo/vendor/hyper-0.14.27", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hyper/hyper-1.5.0.crate", + "sha256": "bbbff0a806a4728c99295b254c8838933b5b082d75e3cb70c8dab21fdfbcfa9a", + "dest": "cargo/vendor/hyper-1.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"bbbff0a806a4728c99295b254c8838933b5b082d75e3cb70c8dab21fdfbcfa9a\", \"files\": {}}", + "dest": "cargo/vendor/hyper-1.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hyper-proxy2/hyper-proxy2-0.1.0.crate", + "sha256": "9043b7b23fb0bc4a1c7014c27b50a4fc42cc76206f71d34fc0dfe5b28ddc3faf", + "dest": "cargo/vendor/hyper-proxy2-0.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"9043b7b23fb0bc4a1c7014c27b50a4fc42cc76206f71d34fc0dfe5b28ddc3faf\", \"files\": {}}", + "dest": "cargo/vendor/hyper-proxy2-0.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hyper-rustls/hyper-rustls-0.24.2.crate", + "sha256": "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590", + "dest": "cargo/vendor/hyper-rustls-0.24.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590\", \"files\": {}}", + "dest": "cargo/vendor/hyper-rustls-0.24.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hyper-rustls/hyper-rustls-0.26.0.crate", + "sha256": "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c", + "dest": "cargo/vendor/hyper-rustls-0.26.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c\", \"files\": {}}", + "dest": "cargo/vendor/hyper-rustls-0.26.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hyper-rustls/hyper-rustls-0.27.3.crate", + "sha256": "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333", + "dest": "cargo/vendor/hyper-rustls-0.27.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333\", \"files\": {}}", + "dest": "cargo/vendor/hyper-rustls-0.27.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hyper-util/hyper-util-0.1.7.crate", + "sha256": "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9", + "dest": "cargo/vendor/hyper-util-0.1.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9\", \"files\": {}}", + "dest": "cargo/vendor/hyper-util-0.1.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/iana-time-zone/iana-time-zone-0.1.58.crate", + "sha256": "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20", + "dest": "cargo/vendor/iana-time-zone-0.1.58" + }, + { + "type": "inline", + "contents": "{\"package\": \"8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20\", \"files\": {}}", + "dest": "cargo/vendor/iana-time-zone-0.1.58", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/iana-time-zone-haiku/iana-time-zone-haiku-0.1.2.crate", + "sha256": "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f", + "dest": "cargo/vendor/iana-time-zone-haiku-0.1.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f\", \"files\": {}}", + "dest": "cargo/vendor/iana-time-zone-haiku-0.1.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ident_case/ident_case-1.0.1.crate", + "sha256": "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39", + "dest": "cargo/vendor/ident_case-1.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39\", \"files\": {}}", + "dest": "cargo/vendor/ident_case-1.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/idna/idna-0.4.0.crate", + "sha256": "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c", + "dest": "cargo/vendor/idna-0.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c\", \"files\": {}}", + "dest": "cargo/vendor/idna-0.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/if-addrs/if-addrs-0.12.0.crate", + "sha256": "bb2a33e9c38988ecbda730c85b0fd9ddcdf83c0305ac7fd21c8bb9f57f2f0cc8", + "dest": "cargo/vendor/if-addrs-0.12.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"bb2a33e9c38988ecbda730c85b0fd9ddcdf83c0305ac7fd21c8bb9f57f2f0cc8\", \"files\": {}}", + "dest": "cargo/vendor/if-addrs-0.12.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/indexmap/indexmap-2.6.0.crate", + "sha256": "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da", + "dest": "cargo/vendor/indexmap-2.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da\", \"files\": {}}", + "dest": "cargo/vendor/indexmap-2.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/inout/inout-0.1.3.crate", + "sha256": "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5", + "dest": "cargo/vendor/inout-0.1.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5\", \"files\": {}}", + "dest": "cargo/vendor/inout-0.1.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/instant/instant-0.1.12.crate", + "sha256": "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c", + "dest": "cargo/vendor/instant-0.1.12" + }, + { + "type": "inline", + "contents": "{\"package\": \"7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c\", \"files\": {}}", + "dest": "cargo/vendor/instant-0.1.12", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/io-lifetimes/io-lifetimes-1.0.11.crate", + "sha256": "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2", + "dest": "cargo/vendor/io-lifetimes-1.0.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2\", \"files\": {}}", + "dest": "cargo/vendor/io-lifetimes-1.0.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ipnet/ipnet-2.10.1.crate", + "sha256": "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708", + "dest": "cargo/vendor/ipnet-2.10.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708\", \"files\": {}}", + "dest": "cargo/vendor/ipnet-2.10.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/is-docker/is-docker-0.2.0.crate", + "sha256": "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3", + "dest": "cargo/vendor/is-docker-0.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3\", \"files\": {}}", + "dest": "cargo/vendor/is-docker-0.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/is-terminal/is-terminal-0.4.9.crate", + "sha256": "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b", + "dest": "cargo/vendor/is-terminal-0.4.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b\", \"files\": {}}", + "dest": "cargo/vendor/is-terminal-0.4.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/is-wsl/is-wsl-0.4.0.crate", + "sha256": "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5", + "dest": "cargo/vendor/is-wsl-0.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5\", \"files\": {}}", + "dest": "cargo/vendor/is-wsl-0.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/is_terminal_polyfill/is_terminal_polyfill-1.70.1.crate", + "sha256": "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf", + "dest": "cargo/vendor/is_terminal_polyfill-1.70.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf\", \"files\": {}}", + "dest": "cargo/vendor/is_terminal_polyfill-1.70.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/isahc/isahc-1.7.2.crate", + "sha256": "334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9", + "dest": "cargo/vendor/isahc-1.7.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9\", \"files\": {}}", + "dest": "cargo/vendor/isahc-1.7.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/itertools/itertools-0.12.1.crate", + "sha256": "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569", + "dest": "cargo/vendor/itertools-0.12.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569\", \"files\": {}}", + "dest": "cargo/vendor/itertools-0.12.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/itertools/itertools-0.13.0.crate", + "sha256": "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186", + "dest": "cargo/vendor/itertools-0.13.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186\", \"files\": {}}", + "dest": "cargo/vendor/itertools-0.13.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/itoa/itoa-1.0.9.crate", + "sha256": "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38", + "dest": "cargo/vendor/itoa-1.0.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38\", \"files\": {}}", + "dest": "cargo/vendor/itoa-1.0.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/jni/jni-0.21.1.crate", + "sha256": "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97", + "dest": "cargo/vendor/jni-0.21.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97\", \"files\": {}}", + "dest": "cargo/vendor/jni-0.21.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/jni-sys/jni-sys-0.3.0.crate", + "sha256": "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130", + "dest": "cargo/vendor/jni-sys-0.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130\", \"files\": {}}", + "dest": "cargo/vendor/jni-sys-0.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/jobserver/jobserver-0.1.32.crate", + "sha256": "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0", + "dest": "cargo/vendor/jobserver-0.1.32" + }, + { + "type": "inline", + "contents": "{\"package\": \"48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0\", \"files\": {}}", + "dest": "cargo/vendor/jobserver-0.1.32", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/js-sys/js-sys-0.3.65.crate", + "sha256": "54c0c35952f67de54bb584e9fd912b3023117cbafc0a77d8f3dee1fb5f572fe8", + "dest": "cargo/vendor/js-sys-0.3.65" + }, + { + "type": "inline", + "contents": "{\"package\": \"54c0c35952f67de54bb584e9fd912b3023117cbafc0a77d8f3dee1fb5f572fe8\", \"files\": {}}", + "dest": "cargo/vendor/js-sys-0.3.65", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/kv-log-macro/kv-log-macro-1.0.7.crate", + "sha256": "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f", + "dest": "cargo/vendor/kv-log-macro-1.0.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f\", \"files\": {}}", + "dest": "cargo/vendor/kv-log-macro-1.0.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/lazy_static/lazy_static-1.4.0.crate", + "sha256": "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646", + "dest": "cargo/vendor/lazy_static-1.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646\", \"files\": {}}", + "dest": "cargo/vendor/lazy_static-1.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/lazycell/lazycell-1.3.0.crate", + "sha256": "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55", + "dest": "cargo/vendor/lazycell-1.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55\", \"files\": {}}", + "dest": "cargo/vendor/lazycell-1.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libadwaita/libadwaita-0.4.4.crate", + "sha256": "1ab9c0843f9f23ff25634df2743690c3a1faffe0a190e60c490878517eb81abf", + "dest": "cargo/vendor/libadwaita-0.4.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"1ab9c0843f9f23ff25634df2743690c3a1faffe0a190e60c490878517eb81abf\", \"files\": {}}", + "dest": "cargo/vendor/libadwaita-0.4.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libadwaita-sys/libadwaita-sys-0.4.4.crate", + "sha256": "4231cb2499a9f0c4cdfa4885414b33e39901ddcac61150bc0bb4ff8a57ede404", + "dest": "cargo/vendor/libadwaita-sys-0.4.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"4231cb2499a9f0c4cdfa4885414b33e39901ddcac61150bc0bb4ff8a57ede404\", \"files\": {}}", + "dest": "cargo/vendor/libadwaita-sys-0.4.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libc/libc-0.2.161.crate", + "sha256": "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1", + "dest": "cargo/vendor/libc-0.2.161" + }, + { + "type": "inline", + "contents": "{\"package\": \"8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1\", \"files\": {}}", + "dest": "cargo/vendor/libc-0.2.161", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libloading/libloading-0.7.4.crate", + "sha256": "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f", + "dest": "cargo/vendor/libloading-0.7.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f\", \"files\": {}}", + "dest": "cargo/vendor/libloading-0.7.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libm/libm-0.2.8.crate", + "sha256": "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058", + "dest": "cargo/vendor/libm-0.2.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058\", \"files\": {}}", + "dest": "cargo/vendor/libm-0.2.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libmdns/libmdns-0.9.1.crate", + "sha256": "48854699e11b111433431b69cee2365fcab0b29b06993f48c257dfbaf6395862", + "dest": "cargo/vendor/libmdns-0.9.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"48854699e11b111433431b69cee2365fcab0b29b06993f48c257dfbaf6395862\", \"files\": {}}", + "dest": "cargo/vendor/libmdns-0.9.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libnghttp2-sys/libnghttp2-sys-0.1.8+1.55.1.crate", + "sha256": "4fae956c192dadcdb5dace96db71fa0b827333cce7c7b38dc71446f024d8a340", + "dest": "cargo/vendor/libnghttp2-sys-0.1.8+1.55.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"4fae956c192dadcdb5dace96db71fa0b827333cce7c7b38dc71446f024d8a340\", \"files\": {}}", + "dest": "cargo/vendor/libnghttp2-sys-0.1.8+1.55.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libpulse-binding/libpulse-binding-2.28.1.crate", + "sha256": "ed3557a2dfc380c8f061189a01c6ae7348354e0c9886038dc6c171219c08eaff", + "dest": "cargo/vendor/libpulse-binding-2.28.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ed3557a2dfc380c8f061189a01c6ae7348354e0c9886038dc6c171219c08eaff\", \"files\": {}}", + "dest": "cargo/vendor/libpulse-binding-2.28.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libpulse-simple-binding/libpulse-simple-binding-2.28.1.crate", + "sha256": "05fd6b68f33f6a251265e6ed1212dc3107caad7c5c6fdcd847b2e65ef58c308d", + "dest": "cargo/vendor/libpulse-simple-binding-2.28.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"05fd6b68f33f6a251265e6ed1212dc3107caad7c5c6fdcd847b2e65ef58c308d\", \"files\": {}}", + "dest": "cargo/vendor/libpulse-simple-binding-2.28.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libpulse-simple-sys/libpulse-simple-sys-1.21.1.crate", + "sha256": "ea6613b4199d8b9f0edcfb623e020cb17bbd0bee8dd21f3c7cc938de561c4152", + "dest": "cargo/vendor/libpulse-simple-sys-1.21.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ea6613b4199d8b9f0edcfb623e020cb17bbd0bee8dd21f3c7cc938de561c4152\", \"files\": {}}", + "dest": "cargo/vendor/libpulse-simple-sys-1.21.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libpulse-sys/libpulse-sys-1.21.0.crate", + "sha256": "bc19e110fbf42c17260d30f6d3dc545f58491c7830d38ecb9aaca96e26067a9b", + "dest": "cargo/vendor/libpulse-sys-1.21.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"bc19e110fbf42c17260d30f6d3dc545f58491c7830d38ecb9aaca96e26067a9b\", \"files\": {}}", + "dest": "cargo/vendor/libpulse-sys-1.21.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/librespot/librespot-0.5.0.crate", + "sha256": "4ea500bb5673fbf4cc9dcbb7afdc228bf0cdb434cc3a0be79afa6bb385a8b5f8", + "dest": "cargo/vendor/librespot-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"4ea500bb5673fbf4cc9dcbb7afdc228bf0cdb434cc3a0be79afa6bb385a8b5f8\", \"files\": {}}", + "dest": "cargo/vendor/librespot-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/librespot-audio/librespot-audio-0.5.0.crate", + "sha256": "5fbda070a5598b32718e497f585f46891f7113e64aff20a13c0f2ba8fe7ccad9", + "dest": "cargo/vendor/librespot-audio-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"5fbda070a5598b32718e497f585f46891f7113e64aff20a13c0f2ba8fe7ccad9\", \"files\": {}}", + "dest": "cargo/vendor/librespot-audio-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/librespot-connect/librespot-connect-0.5.0.crate", + "sha256": "699745eaeff6f53b67e93fe973eb72c818cba58d10cfdbdd4c1bf7893f9b705b", + "dest": "cargo/vendor/librespot-connect-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"699745eaeff6f53b67e93fe973eb72c818cba58d10cfdbdd4c1bf7893f9b705b\", \"files\": {}}", + "dest": "cargo/vendor/librespot-connect-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/librespot-core/librespot-core-0.5.0.crate", + "sha256": "505a5ddd966231755994b60435607a1e8ae1d41c7f1169b078e0511bfb82d931", + "dest": "cargo/vendor/librespot-core-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"505a5ddd966231755994b60435607a1e8ae1d41c7f1169b078e0511bfb82d931\", \"files\": {}}", + "dest": "cargo/vendor/librespot-core-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/librespot-discovery/librespot-discovery-0.5.0.crate", + "sha256": "abca1d9420179c0875189cee50b1cd75ad91c6180f38d079eae3a62a40b4745f", + "dest": "cargo/vendor/librespot-discovery-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"abca1d9420179c0875189cee50b1cd75ad91c6180f38d079eae3a62a40b4745f\", \"files\": {}}", + "dest": "cargo/vendor/librespot-discovery-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/librespot-metadata/librespot-metadata-0.5.0.crate", + "sha256": "6a10ab5a390f65281e763cd09c617b173f0e665994eae3d242526924625fdc66", + "dest": "cargo/vendor/librespot-metadata-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"6a10ab5a390f65281e763cd09c617b173f0e665994eae3d242526924625fdc66\", \"files\": {}}", + "dest": "cargo/vendor/librespot-metadata-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/librespot-oauth/librespot-oauth-0.5.0.crate", + "sha256": "57bda94233b358fb41c04ed15507c61136c80efe876c6e05a10ddb9a182b144e", + "dest": "cargo/vendor/librespot-oauth-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"57bda94233b358fb41c04ed15507c61136c80efe876c6e05a10ddb9a182b144e\", \"files\": {}}", + "dest": "cargo/vendor/librespot-oauth-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/librespot-playback/librespot-playback-0.5.0.crate", + "sha256": "5b1bcfe1d72c5ac14c798c7e3e1c20e1fb6af2b9c254794545cfcb1f2a4627e2", + "dest": "cargo/vendor/librespot-playback-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"5b1bcfe1d72c5ac14c798c7e3e1c20e1fb6af2b9c254794545cfcb1f2a4627e2\", \"files\": {}}", + "dest": "cargo/vendor/librespot-playback-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/librespot-protocol/librespot-protocol-0.5.0.crate", + "sha256": "0d6f343f573e0469d3ff8a02b99bbd9789faa01e2ff167332542ac840a8b31e7", + "dest": "cargo/vendor/librespot-protocol-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"0d6f343f573e0469d3ff8a02b99bbd9789faa01e2ff167332542ac840a8b31e7\", \"files\": {}}", + "dest": "cargo/vendor/librespot-protocol-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/libz-sys/libz-sys-1.1.12.crate", + "sha256": "d97137b25e321a73eef1418d1d5d2eda4d77e12813f8e6dead84bc52c5870a7b", + "dest": "cargo/vendor/libz-sys-1.1.12" + }, + { + "type": "inline", + "contents": "{\"package\": \"d97137b25e321a73eef1418d1d5d2eda4d77e12813f8e6dead84bc52c5870a7b\", \"files\": {}}", + "dest": "cargo/vendor/libz-sys-1.1.12", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/linux-raw-sys/linux-raw-sys-0.3.8.crate", + "sha256": "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519", + "dest": "cargo/vendor/linux-raw-sys-0.3.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519\", \"files\": {}}", + "dest": "cargo/vendor/linux-raw-sys-0.3.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/linux-raw-sys/linux-raw-sys-0.4.10.crate", + "sha256": "da2479e8c062e40bf0066ffa0bc823de0a9368974af99c9f6df941d2c231e03f", + "dest": "cargo/vendor/linux-raw-sys-0.4.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"da2479e8c062e40bf0066ffa0bc823de0a9368974af99c9f6df941d2c231e03f\", \"files\": {}}", + "dest": "cargo/vendor/linux-raw-sys-0.4.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/locale_config/locale_config-0.3.0.crate", + "sha256": "08d2c35b16f4483f6c26f0e4e9550717a2f6575bcd6f12a53ff0c490a94a6934", + "dest": "cargo/vendor/locale_config-0.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"08d2c35b16f4483f6c26f0e4e9550717a2f6575bcd6f12a53ff0c490a94a6934\", \"files\": {}}", + "dest": "cargo/vendor/locale_config-0.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/lock_api/lock_api-0.4.11.crate", + "sha256": "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45", + "dest": "cargo/vendor/lock_api-0.4.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45\", \"files\": {}}", + "dest": "cargo/vendor/lock_api-0.4.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/log/log-0.4.22.crate", + "sha256": "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24", + "dest": "cargo/vendor/log-0.4.22" + }, + { + "type": "inline", + "contents": "{\"package\": \"a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24\", \"files\": {}}", + "dest": "cargo/vendor/log-0.4.22", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/mach2/mach2-0.4.2.crate", + "sha256": "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709", + "dest": "cargo/vendor/mach2-0.4.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709\", \"files\": {}}", + "dest": "cargo/vendor/mach2-0.4.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/malloc_buf/malloc_buf-0.0.6.crate", + "sha256": "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb", + "dest": "cargo/vendor/malloc_buf-0.0.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb\", \"files\": {}}", + "dest": "cargo/vendor/malloc_buf-0.0.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/memchr/memchr-2.7.4.crate", + "sha256": "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3", + "dest": "cargo/vendor/memchr-2.7.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3\", \"files\": {}}", + "dest": "cargo/vendor/memchr-2.7.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/memoffset/memoffset-0.7.1.crate", + "sha256": "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4", + "dest": "cargo/vendor/memoffset-0.7.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4\", \"files\": {}}", + "dest": "cargo/vendor/memoffset-0.7.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/memoffset/memoffset-0.9.0.crate", + "sha256": "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c", + "dest": "cargo/vendor/memoffset-0.9.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c\", \"files\": {}}", + "dest": "cargo/vendor/memoffset-0.9.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/mime/mime-0.3.17.crate", + "sha256": "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a", + "dest": "cargo/vendor/mime-0.3.17" + }, + { + "type": "inline", + "contents": "{\"package\": \"6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a\", \"files\": {}}", + "dest": "cargo/vendor/mime-0.3.17", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/minimal-lexical/minimal-lexical-0.2.1.crate", + "sha256": "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a", + "dest": "cargo/vendor/minimal-lexical-0.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a\", \"files\": {}}", + "dest": "cargo/vendor/minimal-lexical-0.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/miniz_oxide/miniz_oxide-0.7.1.crate", + "sha256": "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7", + "dest": "cargo/vendor/miniz_oxide-0.7.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7\", \"files\": {}}", + "dest": "cargo/vendor/miniz_oxide-0.7.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/mio/mio-1.0.2.crate", + "sha256": "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec", + "dest": "cargo/vendor/mio-1.0.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec\", \"files\": {}}", + "dest": "cargo/vendor/mio-1.0.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/mirai-annotations/mirai-annotations-1.12.0.crate", + "sha256": "c9be0862c1b3f26a88803c4a49de6889c10e608b3ee9344e6ef5b45fb37ad3d1", + "dest": "cargo/vendor/mirai-annotations-1.12.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"c9be0862c1b3f26a88803c4a49de6889c10e608b3ee9344e6ef5b45fb37ad3d1\", \"files\": {}}", + "dest": "cargo/vendor/mirai-annotations-1.12.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/muldiv/muldiv-1.0.1.crate", + "sha256": "956787520e75e9bd233246045d19f42fb73242759cc57fba9611d940ae96d4b0", + "dest": "cargo/vendor/muldiv-1.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"956787520e75e9bd233246045d19f42fb73242759cc57fba9611d940ae96d4b0\", \"files\": {}}", + "dest": "cargo/vendor/muldiv-1.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/multimap/multimap-0.10.0.crate", + "sha256": "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03", + "dest": "cargo/vendor/multimap-0.10.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03\", \"files\": {}}", + "dest": "cargo/vendor/multimap-0.10.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ndk/ndk-0.8.0.crate", + "sha256": "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7", + "dest": "cargo/vendor/ndk-0.8.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7\", \"files\": {}}", + "dest": "cargo/vendor/ndk-0.8.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ndk-context/ndk-context-0.1.1.crate", + "sha256": "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b", + "dest": "cargo/vendor/ndk-context-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b\", \"files\": {}}", + "dest": "cargo/vendor/ndk-context-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ndk-sys/ndk-sys-0.5.0+25.2.9519653.crate", + "sha256": "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691", + "dest": "cargo/vendor/ndk-sys-0.5.0+25.2.9519653" + }, + { + "type": "inline", + "contents": "{\"package\": \"8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691\", \"files\": {}}", + "dest": "cargo/vendor/ndk-sys-0.5.0+25.2.9519653", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/nix/nix-0.26.4.crate", + "sha256": "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b", + "dest": "cargo/vendor/nix-0.26.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b\", \"files\": {}}", + "dest": "cargo/vendor/nix-0.26.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/no-std-compat/no-std-compat-0.4.1.crate", + "sha256": "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c", + "dest": "cargo/vendor/no-std-compat-0.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c\", \"files\": {}}", + "dest": "cargo/vendor/no-std-compat-0.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/nom/nom-7.1.3.crate", + "sha256": "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a", + "dest": "cargo/vendor/nom-7.1.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a\", \"files\": {}}", + "dest": "cargo/vendor/nom-7.1.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/nonzero_ext/nonzero_ext-0.3.0.crate", + "sha256": "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21", + "dest": "cargo/vendor/nonzero_ext-0.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21\", \"files\": {}}", + "dest": "cargo/vendor/nonzero_ext-0.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ntapi/ntapi-0.4.1.crate", + "sha256": "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4", + "dest": "cargo/vendor/ntapi-0.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4\", \"files\": {}}", + "dest": "cargo/vendor/ntapi-0.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num/num-0.4.1.crate", + "sha256": "b05180d69e3da0e530ba2a1dae5110317e49e3b7f3d41be227dc5f92e49ee7af", + "dest": "cargo/vendor/num-0.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"b05180d69e3da0e530ba2a1dae5110317e49e3b7f3d41be227dc5f92e49ee7af\", \"files\": {}}", + "dest": "cargo/vendor/num-0.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num-bigint/num-bigint-0.4.4.crate", + "sha256": "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0", + "dest": "cargo/vendor/num-bigint-0.4.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0\", \"files\": {}}", + "dest": "cargo/vendor/num-bigint-0.4.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num-bigint-dig/num-bigint-dig-0.8.4.crate", + "sha256": "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151", + "dest": "cargo/vendor/num-bigint-dig-0.8.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151\", \"files\": {}}", + "dest": "cargo/vendor/num-bigint-dig-0.8.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num-complex/num-complex-0.4.4.crate", + "sha256": "1ba157ca0885411de85d6ca030ba7e2a83a28636056c7c699b07c8b6f7383214", + "dest": "cargo/vendor/num-complex-0.4.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"1ba157ca0885411de85d6ca030ba7e2a83a28636056c7c699b07c8b6f7383214\", \"files\": {}}", + "dest": "cargo/vendor/num-complex-0.4.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num-conv/num-conv-0.1.0.crate", + "sha256": "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9", + "dest": "cargo/vendor/num-conv-0.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9\", \"files\": {}}", + "dest": "cargo/vendor/num-conv-0.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num-derive/num-derive-0.3.3.crate", + "sha256": "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d", + "dest": "cargo/vendor/num-derive-0.3.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d\", \"files\": {}}", + "dest": "cargo/vendor/num-derive-0.3.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num-derive/num-derive-0.4.2.crate", + "sha256": "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202", + "dest": "cargo/vendor/num-derive-0.4.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202\", \"files\": {}}", + "dest": "cargo/vendor/num-derive-0.4.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num-integer/num-integer-0.1.45.crate", + "sha256": "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9", + "dest": "cargo/vendor/num-integer-0.1.45" + }, + { + "type": "inline", + "contents": "{\"package\": \"225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9\", \"files\": {}}", + "dest": "cargo/vendor/num-integer-0.1.45", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num-iter/num-iter-0.1.43.crate", + "sha256": "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252", + "dest": "cargo/vendor/num-iter-0.1.43" + }, + { + "type": "inline", + "contents": "{\"package\": \"7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252\", \"files\": {}}", + "dest": "cargo/vendor/num-iter-0.1.43", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num-rational/num-rational-0.4.1.crate", + "sha256": "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0", + "dest": "cargo/vendor/num-rational-0.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0\", \"files\": {}}", + "dest": "cargo/vendor/num-rational-0.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num-traits/num-traits-0.2.17.crate", + "sha256": "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c", + "dest": "cargo/vendor/num-traits-0.2.17" + }, + { + "type": "inline", + "contents": "{\"package\": \"39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c\", \"files\": {}}", + "dest": "cargo/vendor/num-traits-0.2.17", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num_enum/num_enum-0.7.3.crate", + "sha256": "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179", + "dest": "cargo/vendor/num_enum-0.7.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179\", \"files\": {}}", + "dest": "cargo/vendor/num_enum-0.7.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num_enum_derive/num_enum_derive-0.7.3.crate", + "sha256": "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56", + "dest": "cargo/vendor/num_enum_derive-0.7.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56\", \"files\": {}}", + "dest": "cargo/vendor/num_enum_derive-0.7.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/num_threads/num_threads-0.1.7.crate", + "sha256": "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9", + "dest": "cargo/vendor/num_threads-0.1.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9\", \"files\": {}}", + "dest": "cargo/vendor/num_threads-0.1.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/oauth2/oauth2-4.4.2.crate", + "sha256": "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f", + "dest": "cargo/vendor/oauth2-4.4.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f\", \"files\": {}}", + "dest": "cargo/vendor/oauth2-4.4.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc/objc-0.2.7.crate", + "sha256": "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1", + "dest": "cargo/vendor/objc-0.2.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1\", \"files\": {}}", + "dest": "cargo/vendor/objc-0.2.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc-foundation/objc-foundation-0.1.1.crate", + "sha256": "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9", + "dest": "cargo/vendor/objc-foundation-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9\", \"files\": {}}", + "dest": "cargo/vendor/objc-foundation-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/objc_id/objc_id-0.1.1.crate", + "sha256": "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b", + "dest": "cargo/vendor/objc_id-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b\", \"files\": {}}", + "dest": "cargo/vendor/objc_id-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/object/object-0.32.1.crate", + "sha256": "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0", + "dest": "cargo/vendor/object-0.32.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0\", \"files\": {}}", + "dest": "cargo/vendor/object-0.32.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/oboe/oboe-0.6.1.crate", + "sha256": "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb", + "dest": "cargo/vendor/oboe-0.6.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb\", \"files\": {}}", + "dest": "cargo/vendor/oboe-0.6.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/oboe-sys/oboe-sys-0.6.1.crate", + "sha256": "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d", + "dest": "cargo/vendor/oboe-sys-0.6.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d\", \"files\": {}}", + "dest": "cargo/vendor/oboe-sys-0.6.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/once_cell/once_cell-1.18.0.crate", + "sha256": "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d", + "dest": "cargo/vendor/once_cell-1.18.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d\", \"files\": {}}", + "dest": "cargo/vendor/once_cell-1.18.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/opaque-debug/opaque-debug-0.3.0.crate", + "sha256": "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5", + "dest": "cargo/vendor/opaque-debug-0.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5\", \"files\": {}}", + "dest": "cargo/vendor/opaque-debug-0.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/open/open-5.3.0.crate", + "sha256": "61a877bf6abd716642a53ef1b89fb498923a4afca5c754f9050b4d081c05c4b3", + "dest": "cargo/vendor/open-5.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"61a877bf6abd716642a53ef1b89fb498923a4afca5c754f9050b4d081c05c4b3\", \"files\": {}}", + "dest": "cargo/vendor/open-5.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/openssl-probe/openssl-probe-0.1.5.crate", + "sha256": "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf", + "dest": "cargo/vendor/openssl-probe-0.1.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf\", \"files\": {}}", + "dest": "cargo/vendor/openssl-probe-0.1.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.94.crate", + "sha256": "2f55da20b29f956fb01f0add8683eb26ee13ebe3ebd935e49898717c6b4b2830", + "dest": "cargo/vendor/openssl-sys-0.9.94" + }, + { + "type": "inline", + "contents": "{\"package\": \"2f55da20b29f956fb01f0add8683eb26ee13ebe3ebd935e49898717c6b4b2830\", \"files\": {}}", + "dest": "cargo/vendor/openssl-sys-0.9.94", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/option-operations/option-operations-0.5.0.crate", + "sha256": "7c26d27bb1aeab65138e4bf7666045169d1717febcc9ff870166be8348b223d0", + "dest": "cargo/vendor/option-operations-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"7c26d27bb1aeab65138e4bf7666045169d1717febcc9ff870166be8348b223d0\", \"files\": {}}", + "dest": "cargo/vendor/option-operations-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ordered-stream/ordered-stream-0.2.0.crate", + "sha256": "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50", + "dest": "cargo/vendor/ordered-stream-0.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50\", \"files\": {}}", + "dest": "cargo/vendor/ordered-stream-0.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pango/pango-0.17.10.crate", + "sha256": "35be456fc620e61f62dff7ff70fbd54dcbaf0a4b920c0f16de1107c47d921d48", + "dest": "cargo/vendor/pango-0.17.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"35be456fc620e61f62dff7ff70fbd54dcbaf0a4b920c0f16de1107c47d921d48\", \"files\": {}}", + "dest": "cargo/vendor/pango-0.17.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pango-sys/pango-sys-0.17.10.crate", + "sha256": "3da69f9f3850b0d8990d462f8c709561975e95f689c1cdf0fecdebde78b35195", + "dest": "cargo/vendor/pango-sys-0.17.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"3da69f9f3850b0d8990d462f8c709561975e95f689c1cdf0fecdebde78b35195\", \"files\": {}}", + "dest": "cargo/vendor/pango-sys-0.17.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/parking/parking-2.2.0.crate", + "sha256": "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae", + "dest": "cargo/vendor/parking-2.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae\", \"files\": {}}", + "dest": "cargo/vendor/parking-2.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/parking_lot/parking_lot-0.12.1.crate", + "sha256": "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f", + "dest": "cargo/vendor/parking_lot-0.12.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f\", \"files\": {}}", + "dest": "cargo/vendor/parking_lot-0.12.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/parking_lot_core/parking_lot_core-0.9.9.crate", + "sha256": "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e", + "dest": "cargo/vendor/parking_lot_core-0.9.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e\", \"files\": {}}", + "dest": "cargo/vendor/parking_lot_core-0.9.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/paste/paste-1.0.14.crate", + "sha256": "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c", + "dest": "cargo/vendor/paste-1.0.14" + }, + { + "type": "inline", + "contents": "{\"package\": \"de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c\", \"files\": {}}", + "dest": "cargo/vendor/paste-1.0.14", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pathdiff/pathdiff-0.2.2.crate", + "sha256": "d61c5ce1153ab5b689d0c074c4e7fc613e942dfb7dd9eea5ab202d2ad91fe361", + "dest": "cargo/vendor/pathdiff-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"d61c5ce1153ab5b689d0c074c4e7fc613e942dfb7dd9eea5ab202d2ad91fe361\", \"files\": {}}", + "dest": "cargo/vendor/pathdiff-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pbkdf2/pbkdf2-0.12.2.crate", + "sha256": "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2", + "dest": "cargo/vendor/pbkdf2-0.12.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2\", \"files\": {}}", + "dest": "cargo/vendor/pbkdf2-0.12.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/peeking_take_while/peeking_take_while-0.1.2.crate", + "sha256": "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099", + "dest": "cargo/vendor/peeking_take_while-0.1.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099\", \"files\": {}}", + "dest": "cargo/vendor/peeking_take_while-0.1.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pem-rfc7468/pem-rfc7468-0.7.0.crate", + "sha256": "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412", + "dest": "cargo/vendor/pem-rfc7468-0.7.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412\", \"files\": {}}", + "dest": "cargo/vendor/pem-rfc7468-0.7.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/percent-encoding/percent-encoding-2.3.0.crate", + "sha256": "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94", + "dest": "cargo/vendor/percent-encoding-2.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94\", \"files\": {}}", + "dest": "cargo/vendor/percent-encoding-2.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/petgraph/petgraph-0.6.4.crate", + "sha256": "e1d3afd2628e69da2be385eb6f2fd57c8ac7977ceeff6dc166ff1657b0e386a9", + "dest": "cargo/vendor/petgraph-0.6.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"e1d3afd2628e69da2be385eb6f2fd57c8ac7977ceeff6dc166ff1657b0e386a9\", \"files\": {}}", + "dest": "cargo/vendor/petgraph-0.6.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pin-project/pin-project-1.1.3.crate", + "sha256": "fda4ed1c6c173e3fc7a83629421152e01d7b1f9b7f65fb301e490e8cfc656422", + "dest": "cargo/vendor/pin-project-1.1.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"fda4ed1c6c173e3fc7a83629421152e01d7b1f9b7f65fb301e490e8cfc656422\", \"files\": {}}", + "dest": "cargo/vendor/pin-project-1.1.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pin-project-internal/pin-project-internal-1.1.3.crate", + "sha256": "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405", + "dest": "cargo/vendor/pin-project-internal-1.1.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405\", \"files\": {}}", + "dest": "cargo/vendor/pin-project-internal-1.1.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pin-project-lite/pin-project-lite-0.2.13.crate", + "sha256": "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58", + "dest": "cargo/vendor/pin-project-lite-0.2.13" + }, + { + "type": "inline", + "contents": "{\"package\": \"8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58\", \"files\": {}}", + "dest": "cargo/vendor/pin-project-lite-0.2.13", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pin-utils/pin-utils-0.1.0.crate", + "sha256": "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184", + "dest": "cargo/vendor/pin-utils-0.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184\", \"files\": {}}", + "dest": "cargo/vendor/pin-utils-0.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/piper/piper-0.2.1.crate", + "sha256": "668d31b1c4eba19242f2088b2bf3316b82ca31082a8335764db4e083db7485d4", + "dest": "cargo/vendor/piper-0.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"668d31b1c4eba19242f2088b2bf3316b82ca31082a8335764db4e083db7485d4\", \"files\": {}}", + "dest": "cargo/vendor/piper-0.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pkcs1/pkcs1-0.7.5.crate", + "sha256": "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f", + "dest": "cargo/vendor/pkcs1-0.7.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f\", \"files\": {}}", + "dest": "cargo/vendor/pkcs1-0.7.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pkcs8/pkcs8-0.10.2.crate", + "sha256": "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7", + "dest": "cargo/vendor/pkcs8-0.10.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7\", \"files\": {}}", + "dest": "cargo/vendor/pkcs8-0.10.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pkg-config/pkg-config-0.3.27.crate", + "sha256": "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964", + "dest": "cargo/vendor/pkg-config-0.3.27" + }, + { + "type": "inline", + "contents": "{\"package\": \"26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964\", \"files\": {}}", + "dest": "cargo/vendor/pkg-config-0.3.27", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/polling/polling-2.8.0.crate", + "sha256": "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce", + "dest": "cargo/vendor/polling-2.8.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce\", \"files\": {}}", + "dest": "cargo/vendor/polling-2.8.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/polling/polling-3.3.0.crate", + "sha256": "e53b6af1f60f36f8c2ac2aad5459d75a5a9b4be1e8cdd40264f315d78193e531", + "dest": "cargo/vendor/polling-3.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"e53b6af1f60f36f8c2ac2aad5459d75a5a9b4be1e8cdd40264f315d78193e531\", \"files\": {}}", + "dest": "cargo/vendor/polling-3.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/portable-atomic/portable-atomic-1.9.0.crate", + "sha256": "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2", + "dest": "cargo/vendor/portable-atomic-1.9.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2\", \"files\": {}}", + "dest": "cargo/vendor/portable-atomic-1.9.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/powerfmt/powerfmt-0.2.0.crate", + "sha256": "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391", + "dest": "cargo/vendor/powerfmt-0.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391\", \"files\": {}}", + "dest": "cargo/vendor/powerfmt-0.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ppv-lite86/ppv-lite86-0.2.17.crate", + "sha256": "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de", + "dest": "cargo/vendor/ppv-lite86-0.2.17" + }, + { + "type": "inline", + "contents": "{\"package\": \"5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de\", \"files\": {}}", + "dest": "cargo/vendor/ppv-lite86-0.2.17", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/prettyplease/prettyplease-0.2.25.crate", + "sha256": "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033", + "dest": "cargo/vendor/prettyplease-0.2.25" + }, + { + "type": "inline", + "contents": "{\"package\": \"64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033\", \"files\": {}}", + "dest": "cargo/vendor/prettyplease-0.2.25", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/priority-queue/priority-queue-2.1.1.crate", + "sha256": "714c75db297bc88a63783ffc6ab9f830698a6705aa0201416931759ef4c8183d", + "dest": "cargo/vendor/priority-queue-2.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"714c75db297bc88a63783ffc6ab9f830698a6705aa0201416931759ef4c8183d\", \"files\": {}}", + "dest": "cargo/vendor/priority-queue-2.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/proc-macro-crate/proc-macro-crate-1.3.1.crate", + "sha256": "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919", + "dest": "cargo/vendor/proc-macro-crate-1.3.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919\", \"files\": {}}", + "dest": "cargo/vendor/proc-macro-crate-1.3.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/proc-macro-crate/proc-macro-crate-3.2.0.crate", + "sha256": "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b", + "dest": "cargo/vendor/proc-macro-crate-3.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b\", \"files\": {}}", + "dest": "cargo/vendor/proc-macro-crate-3.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/proc-macro-error/proc-macro-error-1.0.4.crate", + "sha256": "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c", + "dest": "cargo/vendor/proc-macro-error-1.0.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c\", \"files\": {}}", + "dest": "cargo/vendor/proc-macro-error-1.0.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/proc-macro-error-attr/proc-macro-error-attr-1.0.4.crate", + "sha256": "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869", + "dest": "cargo/vendor/proc-macro-error-attr-1.0.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869\", \"files\": {}}", + "dest": "cargo/vendor/proc-macro-error-attr-1.0.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/proc-macro2/proc-macro2-1.0.89.crate", + "sha256": "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e", + "dest": "cargo/vendor/proc-macro2-1.0.89" + }, + { + "type": "inline", + "contents": "{\"package\": \"f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e\", \"files\": {}}", + "dest": "cargo/vendor/proc-macro2-1.0.89", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/protobuf/protobuf-3.7.1.crate", + "sha256": "a3a7c64d9bf75b1b8d981124c14c179074e8caa7dfe7b6a12e6222ddcd0c8f72", + "dest": "cargo/vendor/protobuf-3.7.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"a3a7c64d9bf75b1b8d981124c14c179074e8caa7dfe7b6a12e6222ddcd0c8f72\", \"files\": {}}", + "dest": "cargo/vendor/protobuf-3.7.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/protobuf-codegen/protobuf-codegen-3.7.1.crate", + "sha256": "e26b833f144769a30e04b1db0146b2aaa53fd2fd83acf10a6b5f996606c18144", + "dest": "cargo/vendor/protobuf-codegen-3.7.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"e26b833f144769a30e04b1db0146b2aaa53fd2fd83acf10a6b5f996606c18144\", \"files\": {}}", + "dest": "cargo/vendor/protobuf-codegen-3.7.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/protobuf-parse/protobuf-parse-3.7.1.crate", + "sha256": "322330e133eab455718444b4e033ebfac7c6528972c784fcde28d2cc783c6257", + "dest": "cargo/vendor/protobuf-parse-3.7.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"322330e133eab455718444b4e033ebfac7c6528972c784fcde28d2cc783c6257\", \"files\": {}}", + "dest": "cargo/vendor/protobuf-parse-3.7.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/protobuf-support/protobuf-support-3.7.1.crate", + "sha256": "b088fd20b938a875ea00843b6faf48579462630015c3788d397ad6a786663252", + "dest": "cargo/vendor/protobuf-support-3.7.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"b088fd20b938a875ea00843b6faf48579462630015c3788d397ad6a786663252\", \"files\": {}}", + "dest": "cargo/vendor/protobuf-support-3.7.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/quick-xml/quick-xml-0.36.2.crate", + "sha256": "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe", + "dest": "cargo/vendor/quick-xml-0.36.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe\", \"files\": {}}", + "dest": "cargo/vendor/quick-xml-0.36.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/quote/quote-1.0.37.crate", + "sha256": "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af", + "dest": "cargo/vendor/quote-1.0.37" + }, + { + "type": "inline", + "contents": "{\"package\": \"b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af\", \"files\": {}}", + "dest": "cargo/vendor/quote-1.0.37", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rand/rand-0.8.5.crate", + "sha256": "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404", + "dest": "cargo/vendor/rand-0.8.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404\", \"files\": {}}", + "dest": "cargo/vendor/rand-0.8.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rand_chacha/rand_chacha-0.3.1.crate", + "sha256": "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88", + "dest": "cargo/vendor/rand_chacha-0.3.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88\", \"files\": {}}", + "dest": "cargo/vendor/rand_chacha-0.3.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rand_core/rand_core-0.6.4.crate", + "sha256": "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c", + "dest": "cargo/vendor/rand_core-0.6.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c\", \"files\": {}}", + "dest": "cargo/vendor/rand_core-0.6.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rand_distr/rand_distr-0.4.3.crate", + "sha256": "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31", + "dest": "cargo/vendor/rand_distr-0.4.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31\", \"files\": {}}", + "dest": "cargo/vendor/rand_distr-0.4.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/redox_syscall/redox_syscall-0.4.1.crate", + "sha256": "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa", + "dest": "cargo/vendor/redox_syscall-0.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa\", \"files\": {}}", + "dest": "cargo/vendor/redox_syscall-0.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ref_filter_map/ref_filter_map-1.0.1.crate", + "sha256": "2b5ceb840e4009da4841ed22a15eb49f64fdd00a2138945c5beacf506b2fb5ed", + "dest": "cargo/vendor/ref_filter_map-1.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"2b5ceb840e4009da4841ed22a15eb49f64fdd00a2138945c5beacf506b2fb5ed\", \"files\": {}}", + "dest": "cargo/vendor/ref_filter_map-1.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/regex/regex-1.10.2.crate", + "sha256": "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343", + "dest": "cargo/vendor/regex-1.10.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343\", \"files\": {}}", + "dest": "cargo/vendor/regex-1.10.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/regex-automata/regex-automata-0.4.3.crate", + "sha256": "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f", + "dest": "cargo/vendor/regex-automata-0.4.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f\", \"files\": {}}", + "dest": "cargo/vendor/regex-automata-0.4.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/regex-syntax/regex-syntax-0.8.2.crate", + "sha256": "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f", + "dest": "cargo/vendor/regex-syntax-0.8.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f\", \"files\": {}}", + "dest": "cargo/vendor/regex-syntax-0.8.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/reqwest/reqwest-0.11.27.crate", + "sha256": "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62", + "dest": "cargo/vendor/reqwest-0.11.27" + }, + { + "type": "inline", + "contents": "{\"package\": \"dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62\", \"files\": {}}", + "dest": "cargo/vendor/reqwest-0.11.27", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ring/ring-0.17.8.crate", + "sha256": "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d", + "dest": "cargo/vendor/ring-0.17.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d\", \"files\": {}}", + "dest": "cargo/vendor/ring-0.17.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rodio/rodio-0.19.0.crate", + "sha256": "6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb", + "dest": "cargo/vendor/rodio-0.19.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb\", \"files\": {}}", + "dest": "cargo/vendor/rodio-0.19.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rsa/rsa-0.9.6.crate", + "sha256": "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc", + "dest": "cargo/vendor/rsa-0.9.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc\", \"files\": {}}", + "dest": "cargo/vendor/rsa-0.9.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustc-demangle/rustc-demangle-0.1.23.crate", + "sha256": "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76", + "dest": "cargo/vendor/rustc-demangle-0.1.23" + }, + { + "type": "inline", + "contents": "{\"package\": \"d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76\", \"files\": {}}", + "dest": "cargo/vendor/rustc-demangle-0.1.23", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustc-hash/rustc-hash-1.1.0.crate", + "sha256": "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2", + "dest": "cargo/vendor/rustc-hash-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2\", \"files\": {}}", + "dest": "cargo/vendor/rustc-hash-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustc_version/rustc_version-0.4.0.crate", + "sha256": "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366", + "dest": "cargo/vendor/rustc_version-0.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366\", \"files\": {}}", + "dest": "cargo/vendor/rustc_version-0.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustix/rustix-0.37.27.crate", + "sha256": "fea8ca367a3a01fe35e6943c400addf443c0f57670e6ec51196f71a4b8762dd2", + "dest": "cargo/vendor/rustix-0.37.27" + }, + { + "type": "inline", + "contents": "{\"package\": \"fea8ca367a3a01fe35e6943c400addf443c0f57670e6ec51196f71a4b8762dd2\", \"files\": {}}", + "dest": "cargo/vendor/rustix-0.37.27", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustix/rustix-0.38.21.crate", + "sha256": "2b426b0506e5d50a7d8dafcf2e81471400deb602392c7dd110815afb4eaf02a3", + "dest": "cargo/vendor/rustix-0.38.21" + }, + { + "type": "inline", + "contents": "{\"package\": \"2b426b0506e5d50a7d8dafcf2e81471400deb602392c7dd110815afb4eaf02a3\", \"files\": {}}", + "dest": "cargo/vendor/rustix-0.38.21", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustls/rustls-0.21.12.crate", + "sha256": "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e", + "dest": "cargo/vendor/rustls-0.21.12" + }, + { + "type": "inline", + "contents": "{\"package\": \"3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e\", \"files\": {}}", + "dest": "cargo/vendor/rustls-0.21.12", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustls/rustls-0.22.4.crate", + "sha256": "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432", + "dest": "cargo/vendor/rustls-0.22.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432\", \"files\": {}}", + "dest": "cargo/vendor/rustls-0.22.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustls/rustls-0.23.15.crate", + "sha256": "5fbb44d7acc4e873d613422379f69f237a1b141928c02f6bc6ccfddddc2d7993", + "dest": "cargo/vendor/rustls-0.23.15" + }, + { + "type": "inline", + "contents": "{\"package\": \"5fbb44d7acc4e873d613422379f69f237a1b141928c02f6bc6ccfddddc2d7993\", \"files\": {}}", + "dest": "cargo/vendor/rustls-0.23.15", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustls-native-certs/rustls-native-certs-0.7.3.crate", + "sha256": "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5", + "dest": "cargo/vendor/rustls-native-certs-0.7.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5\", \"files\": {}}", + "dest": "cargo/vendor/rustls-native-certs-0.7.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustls-native-certs/rustls-native-certs-0.8.0.crate", + "sha256": "fcaf18a4f2be7326cd874a5fa579fae794320a0f388d365dca7e480e55f83f8a", + "dest": "cargo/vendor/rustls-native-certs-0.8.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"fcaf18a4f2be7326cd874a5fa579fae794320a0f388d365dca7e480e55f83f8a\", \"files\": {}}", + "dest": "cargo/vendor/rustls-native-certs-0.8.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustls-pemfile/rustls-pemfile-1.0.4.crate", + "sha256": "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c", + "dest": "cargo/vendor/rustls-pemfile-1.0.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c\", \"files\": {}}", + "dest": "cargo/vendor/rustls-pemfile-1.0.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustls-pemfile/rustls-pemfile-2.2.0.crate", + "sha256": "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50", + "dest": "cargo/vendor/rustls-pemfile-2.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50\", \"files\": {}}", + "dest": "cargo/vendor/rustls-pemfile-2.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustls-pki-types/rustls-pki-types-1.10.0.crate", + "sha256": "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b", + "dest": "cargo/vendor/rustls-pki-types-1.10.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b\", \"files\": {}}", + "dest": "cargo/vendor/rustls-pki-types-1.10.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustls-webpki/rustls-webpki-0.101.7.crate", + "sha256": "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765", + "dest": "cargo/vendor/rustls-webpki-0.101.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765\", \"files\": {}}", + "dest": "cargo/vendor/rustls-webpki-0.101.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustls-webpki/rustls-webpki-0.102.8.crate", + "sha256": "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9", + "dest": "cargo/vendor/rustls-webpki-0.102.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9\", \"files\": {}}", + "dest": "cargo/vendor/rustls-webpki-0.102.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rustversion/rustversion-1.0.18.crate", + "sha256": "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248", + "dest": "cargo/vendor/rustversion-1.0.18" + }, + { + "type": "inline", + "contents": "{\"package\": \"0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248\", \"files\": {}}", + "dest": "cargo/vendor/rustversion-1.0.18", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ryu/ryu-1.0.15.crate", + "sha256": "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741", + "dest": "cargo/vendor/ryu-1.0.15" + }, + { + "type": "inline", + "contents": "{\"package\": \"1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741\", \"files\": {}}", + "dest": "cargo/vendor/ryu-1.0.15", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/same-file/same-file-1.0.6.crate", + "sha256": "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502", + "dest": "cargo/vendor/same-file-1.0.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502\", \"files\": {}}", + "dest": "cargo/vendor/same-file-1.0.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/schannel/schannel-0.1.22.crate", + "sha256": "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88", + "dest": "cargo/vendor/schannel-0.1.22" + }, + { + "type": "inline", + "contents": "{\"package\": \"0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88\", \"files\": {}}", + "dest": "cargo/vendor/schannel-0.1.22", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/scopeguard/scopeguard-1.2.0.crate", + "sha256": "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49", + "dest": "cargo/vendor/scopeguard-1.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49\", \"files\": {}}", + "dest": "cargo/vendor/scopeguard-1.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/sct/sct-0.7.1.crate", + "sha256": "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414", + "dest": "cargo/vendor/sct-0.7.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414\", \"files\": {}}", + "dest": "cargo/vendor/sct-0.7.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/secret-service/secret-service-3.0.1.crate", + "sha256": "5da1a5ad4d28c03536f82f77d9f36603f5e37d8869ac98f0a750d5b5686d8d95", + "dest": "cargo/vendor/secret-service-3.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"5da1a5ad4d28c03536f82f77d9f36603f5e37d8869ac98f0a750d5b5686d8d95\", \"files\": {}}", + "dest": "cargo/vendor/secret-service-3.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/security-framework/security-framework-2.10.0.crate", + "sha256": "770452e37cad93e0a50d5abc3990d2bc351c36d0328f86cefec2f2fb206eaef6", + "dest": "cargo/vendor/security-framework-2.10.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"770452e37cad93e0a50d5abc3990d2bc351c36d0328f86cefec2f2fb206eaef6\", \"files\": {}}", + "dest": "cargo/vendor/security-framework-2.10.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/security-framework-sys/security-framework-sys-2.11.0.crate", + "sha256": "317936bbbd05227752583946b9e66d7ce3b489f84e11a94a510b4437fef407d7", + "dest": "cargo/vendor/security-framework-sys-2.11.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"317936bbbd05227752583946b9e66d7ce3b489f84e11a94a510b4437fef407d7\", \"files\": {}}", + "dest": "cargo/vendor/security-framework-sys-2.11.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/semver/semver-1.0.20.crate", + "sha256": "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090", + "dest": "cargo/vendor/semver-1.0.20" + }, + { + "type": "inline", + "contents": "{\"package\": \"836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090\", \"files\": {}}", + "dest": "cargo/vendor/semver-1.0.20", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/serde/serde-1.0.213.crate", + "sha256": "3ea7893ff5e2466df8d720bb615088341b295f849602c6956047f8f80f0e9bc1", + "dest": "cargo/vendor/serde-1.0.213" + }, + { + "type": "inline", + "contents": "{\"package\": \"3ea7893ff5e2466df8d720bb615088341b295f849602c6956047f8f80f0e9bc1\", \"files\": {}}", + "dest": "cargo/vendor/serde-1.0.213", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/serde_derive/serde_derive-1.0.213.crate", + "sha256": "7e85ad2009c50b58e87caa8cd6dac16bdf511bbfb7af6c33df902396aa480fa5", + "dest": "cargo/vendor/serde_derive-1.0.213" + }, + { + "type": "inline", + "contents": "{\"package\": \"7e85ad2009c50b58e87caa8cd6dac16bdf511bbfb7af6c33df902396aa480fa5\", \"files\": {}}", + "dest": "cargo/vendor/serde_derive-1.0.213", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/serde_json/serde_json-1.0.108.crate", + "sha256": "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b", + "dest": "cargo/vendor/serde_json-1.0.108" + }, + { + "type": "inline", + "contents": "{\"package\": \"3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b\", \"files\": {}}", + "dest": "cargo/vendor/serde_json-1.0.108", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/serde_path_to_error/serde_path_to_error-0.1.16.crate", + "sha256": "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6", + "dest": "cargo/vendor/serde_path_to_error-0.1.16" + }, + { + "type": "inline", + "contents": "{\"package\": \"af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6\", \"files\": {}}", + "dest": "cargo/vendor/serde_path_to_error-0.1.16", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/serde_repr/serde_repr-0.1.17.crate", + "sha256": "3081f5ffbb02284dda55132aa26daecedd7372a42417bbbab6f14ab7d6bb9145", + "dest": "cargo/vendor/serde_repr-0.1.17" + }, + { + "type": "inline", + "contents": "{\"package\": \"3081f5ffbb02284dda55132aa26daecedd7372a42417bbbab6f14ab7d6bb9145\", \"files\": {}}", + "dest": "cargo/vendor/serde_repr-0.1.17", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/serde_spanned/serde_spanned-0.6.4.crate", + "sha256": "12022b835073e5b11e90a14f86838ceb1c8fb0325b72416845c487ac0fa95e80", + "dest": "cargo/vendor/serde_spanned-0.6.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"12022b835073e5b11e90a14f86838ceb1c8fb0325b72416845c487ac0fa95e80\", \"files\": {}}", + "dest": "cargo/vendor/serde_spanned-0.6.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/serde_urlencoded/serde_urlencoded-0.7.1.crate", + "sha256": "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd", + "dest": "cargo/vendor/serde_urlencoded-0.7.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd\", \"files\": {}}", + "dest": "cargo/vendor/serde_urlencoded-0.7.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/sha1/sha1-0.10.6.crate", + "sha256": "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba", + "dest": "cargo/vendor/sha1-0.10.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba\", \"files\": {}}", + "dest": "cargo/vendor/sha1-0.10.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/sha2/sha2-0.10.8.crate", + "sha256": "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8", + "dest": "cargo/vendor/sha2-0.10.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8\", \"files\": {}}", + "dest": "cargo/vendor/sha2-0.10.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/shannon/shannon-0.2.0.crate", + "sha256": "7ea5b41c9427b56caa7b808cb548a04fb50bb5b9e98590b53f28064ff4174561", + "dest": "cargo/vendor/shannon-0.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"7ea5b41c9427b56caa7b808cb548a04fb50bb5b9e98590b53f28064ff4174561\", \"files\": {}}", + "dest": "cargo/vendor/shannon-0.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/shell-words/shell-words-1.1.0.crate", + "sha256": "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde", + "dest": "cargo/vendor/shell-words-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde\", \"files\": {}}", + "dest": "cargo/vendor/shell-words-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/shlex/shlex-1.2.0.crate", + "sha256": "a7cee0529a6d40f580e7a5e6c495c8fbfe21b7b52795ed4bb5e62cdf92bc6380", + "dest": "cargo/vendor/shlex-1.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"a7cee0529a6d40f580e7a5e6c495c8fbfe21b7b52795ed4bb5e62cdf92bc6380\", \"files\": {}}", + "dest": "cargo/vendor/shlex-1.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/signal-hook-registry/signal-hook-registry-1.4.1.crate", + "sha256": "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1", + "dest": "cargo/vendor/signal-hook-registry-1.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1\", \"files\": {}}", + "dest": "cargo/vendor/signal-hook-registry-1.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/signature/signature-2.2.0.crate", + "sha256": "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de", + "dest": "cargo/vendor/signature-2.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de\", \"files\": {}}", + "dest": "cargo/vendor/signature-2.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/slab/slab-0.4.9.crate", + "sha256": "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67", + "dest": "cargo/vendor/slab-0.4.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67\", \"files\": {}}", + "dest": "cargo/vendor/slab-0.4.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/sluice/sluice-0.5.5.crate", + "sha256": "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5", + "dest": "cargo/vendor/sluice-0.5.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5\", \"files\": {}}", + "dest": "cargo/vendor/sluice-0.5.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/smallvec/smallvec-1.13.2.crate", + "sha256": "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67", + "dest": "cargo/vendor/smallvec-1.13.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67\", \"files\": {}}", + "dest": "cargo/vendor/smallvec-1.13.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/socket2/socket2-0.4.10.crate", + "sha256": "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d", + "dest": "cargo/vendor/socket2-0.4.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d\", \"files\": {}}", + "dest": "cargo/vendor/socket2-0.4.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/socket2/socket2-0.5.5.crate", + "sha256": "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9", + "dest": "cargo/vendor/socket2-0.5.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9\", \"files\": {}}", + "dest": "cargo/vendor/socket2-0.5.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/spin/spin-0.5.2.crate", + "sha256": "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d", + "dest": "cargo/vendor/spin-0.5.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d\", \"files\": {}}", + "dest": "cargo/vendor/spin-0.5.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/spin/spin-0.9.8.crate", + "sha256": "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67", + "dest": "cargo/vendor/spin-0.9.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67\", \"files\": {}}", + "dest": "cargo/vendor/spin-0.9.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/spinning_top/spinning_top-0.3.0.crate", + "sha256": "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300", + "dest": "cargo/vendor/spinning_top-0.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300\", \"files\": {}}", + "dest": "cargo/vendor/spinning_top-0.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/spki/spki-0.7.3.crate", + "sha256": "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d", + "dest": "cargo/vendor/spki-0.7.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d\", \"files\": {}}", + "dest": "cargo/vendor/spki-0.7.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/static_assertions/static_assertions-1.1.0.crate", + "sha256": "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f", + "dest": "cargo/vendor/static_assertions-1.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f\", \"files\": {}}", + "dest": "cargo/vendor/static_assertions-1.1.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/strsim/strsim-0.11.1.crate", + "sha256": "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f", + "dest": "cargo/vendor/strsim-0.11.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f\", \"files\": {}}", + "dest": "cargo/vendor/strsim-0.11.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/subtle/subtle-2.6.1.crate", + "sha256": "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292", + "dest": "cargo/vendor/subtle-2.6.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292\", \"files\": {}}", + "dest": "cargo/vendor/subtle-2.6.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/symphonia/symphonia-0.5.4.crate", + "sha256": "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9", + "dest": "cargo/vendor/symphonia-0.5.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9\", \"files\": {}}", + "dest": "cargo/vendor/symphonia-0.5.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/symphonia-bundle-mp3/symphonia-bundle-mp3-0.5.4.crate", + "sha256": "c01c2aae70f0f1fb096b6f0ff112a930b1fb3626178fba3ae68b09dce71706d4", + "dest": "cargo/vendor/symphonia-bundle-mp3-0.5.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"c01c2aae70f0f1fb096b6f0ff112a930b1fb3626178fba3ae68b09dce71706d4\", \"files\": {}}", + "dest": "cargo/vendor/symphonia-bundle-mp3-0.5.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/symphonia-codec-vorbis/symphonia-codec-vorbis-0.5.4.crate", + "sha256": "5a98765fb46a0a6732b007f7e2870c2129b6f78d87db7987e6533c8f164a9f30", + "dest": "cargo/vendor/symphonia-codec-vorbis-0.5.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"5a98765fb46a0a6732b007f7e2870c2129b6f78d87db7987e6533c8f164a9f30\", \"files\": {}}", + "dest": "cargo/vendor/symphonia-codec-vorbis-0.5.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/symphonia-core/symphonia-core-0.5.4.crate", + "sha256": "798306779e3dc7d5231bd5691f5a813496dc79d3f56bf82e25789f2094e022c3", + "dest": "cargo/vendor/symphonia-core-0.5.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"798306779e3dc7d5231bd5691f5a813496dc79d3f56bf82e25789f2094e022c3\", \"files\": {}}", + "dest": "cargo/vendor/symphonia-core-0.5.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/symphonia-format-ogg/symphonia-format-ogg-0.5.4.crate", + "sha256": "ada3505789516bcf00fc1157c67729eded428b455c27ca370e41f4d785bfa931", + "dest": "cargo/vendor/symphonia-format-ogg-0.5.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"ada3505789516bcf00fc1157c67729eded428b455c27ca370e41f4d785bfa931\", \"files\": {}}", + "dest": "cargo/vendor/symphonia-format-ogg-0.5.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/symphonia-metadata/symphonia-metadata-0.5.4.crate", + "sha256": "bc622b9841a10089c5b18e99eb904f4341615d5aa55bbf4eedde1be721a4023c", + "dest": "cargo/vendor/symphonia-metadata-0.5.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"bc622b9841a10089c5b18e99eb904f4341615d5aa55bbf4eedde1be721a4023c\", \"files\": {}}", + "dest": "cargo/vendor/symphonia-metadata-0.5.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/symphonia-utils-xiph/symphonia-utils-xiph-0.5.4.crate", + "sha256": "484472580fa49991afda5f6550ece662237b00c6f562c7d9638d1b086ed010fe", + "dest": "cargo/vendor/symphonia-utils-xiph-0.5.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"484472580fa49991afda5f6550ece662237b00c6f562c7d9638d1b086ed010fe\", \"files\": {}}", + "dest": "cargo/vendor/symphonia-utils-xiph-0.5.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/syn/syn-1.0.109.crate", + "sha256": "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237", + "dest": "cargo/vendor/syn-1.0.109" + }, + { + "type": "inline", + "contents": "{\"package\": \"72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237\", \"files\": {}}", + "dest": "cargo/vendor/syn-1.0.109", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/syn/syn-2.0.85.crate", + "sha256": "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56", + "dest": "cargo/vendor/syn-2.0.85" + }, + { + "type": "inline", + "contents": "{\"package\": \"5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56\", \"files\": {}}", + "dest": "cargo/vendor/syn-2.0.85", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/sync_wrapper/sync_wrapper-0.1.2.crate", + "sha256": "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160", + "dest": "cargo/vendor/sync_wrapper-0.1.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160\", \"files\": {}}", + "dest": "cargo/vendor/sync_wrapper-0.1.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/sysinfo/sysinfo-0.31.4.crate", + "sha256": "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be", + "dest": "cargo/vendor/sysinfo-0.31.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be\", \"files\": {}}", + "dest": "cargo/vendor/sysinfo-0.31.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/system-configuration/system-configuration-0.5.1.crate", + "sha256": "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7", + "dest": "cargo/vendor/system-configuration-0.5.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7\", \"files\": {}}", + "dest": "cargo/vendor/system-configuration-0.5.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/system-configuration-sys/system-configuration-sys-0.5.0.crate", + "sha256": "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9", + "dest": "cargo/vendor/system-configuration-sys-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9\", \"files\": {}}", + "dest": "cargo/vendor/system-configuration-sys-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/system-deps/system-deps-6.2.0.crate", + "sha256": "2a2d580ff6a20c55dfb86be5f9c238f67835d0e81cbdea8bf5680e0897320331", + "dest": "cargo/vendor/system-deps-6.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"2a2d580ff6a20c55dfb86be5f9c238f67835d0e81cbdea8bf5680e0897320331\", \"files\": {}}", + "dest": "cargo/vendor/system-deps-6.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/system-deps/system-deps-7.0.3.crate", + "sha256": "66d23aaf9f331227789a99e8de4c91bf46703add012bdfd45fdecdfb2975a005", + "dest": "cargo/vendor/system-deps-7.0.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"66d23aaf9f331227789a99e8de4c91bf46703add012bdfd45fdecdfb2975a005\", \"files\": {}}", + "dest": "cargo/vendor/system-deps-7.0.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/target-lexicon/target-lexicon-0.12.16.crate", + "sha256": "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1", + "dest": "cargo/vendor/target-lexicon-0.12.16" + }, + { + "type": "inline", + "contents": "{\"package\": \"61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1\", \"files\": {}}", + "dest": "cargo/vendor/target-lexicon-0.12.16", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/temp-dir/temp-dir-0.1.11.crate", + "sha256": "af547b166dd1ea4b472165569fc456cfb6818116f854690b0ff205e636523dab", + "dest": "cargo/vendor/temp-dir-0.1.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"af547b166dd1ea4b472165569fc456cfb6818116f854690b0ff205e636523dab\", \"files\": {}}", + "dest": "cargo/vendor/temp-dir-0.1.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tempfile/tempfile-3.8.1.crate", + "sha256": "7ef1adac450ad7f4b3c28589471ade84f25f731a7a0fe30d71dfa9f60fd808e5", + "dest": "cargo/vendor/tempfile-3.8.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"7ef1adac450ad7f4b3c28589471ade84f25f731a7a0fe30d71dfa9f60fd808e5\", \"files\": {}}", + "dest": "cargo/vendor/tempfile-3.8.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/termcolor/termcolor-1.3.0.crate", + "sha256": "6093bad37da69aab9d123a8091e4be0aa4a03e4d601ec641c327398315f62b64", + "dest": "cargo/vendor/termcolor-1.3.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"6093bad37da69aab9d123a8091e4be0aa4a03e4d601ec641c327398315f62b64\", \"files\": {}}", + "dest": "cargo/vendor/termcolor-1.3.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/thiserror/thiserror-1.0.50.crate", + "sha256": "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2", + "dest": "cargo/vendor/thiserror-1.0.50" + }, + { + "type": "inline", + "contents": "{\"package\": \"f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2\", \"files\": {}}", + "dest": "cargo/vendor/thiserror-1.0.50", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/thiserror-impl/thiserror-impl-1.0.50.crate", + "sha256": "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8", + "dest": "cargo/vendor/thiserror-impl-1.0.50" + }, + { + "type": "inline", + "contents": "{\"package\": \"266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8\", \"files\": {}}", + "dest": "cargo/vendor/thiserror-impl-1.0.50", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/thread-id/thread-id-4.2.1.crate", + "sha256": "f0ec81c46e9eb50deaa257be2f148adf052d1fb7701cfd55ccfab2525280b70b", + "dest": "cargo/vendor/thread-id-4.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"f0ec81c46e9eb50deaa257be2f148adf052d1fb7701cfd55ccfab2525280b70b\", \"files\": {}}", + "dest": "cargo/vendor/thread-id-4.2.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/time/time-0.3.36.crate", + "sha256": "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885", + "dest": "cargo/vendor/time-0.3.36" + }, + { + "type": "inline", + "contents": "{\"package\": \"5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885\", \"files\": {}}", + "dest": "cargo/vendor/time-0.3.36", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/time-core/time-core-0.1.2.crate", + "sha256": "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3", + "dest": "cargo/vendor/time-core-0.1.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3\", \"files\": {}}", + "dest": "cargo/vendor/time-core-0.1.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/time-macros/time-macros-0.2.18.crate", + "sha256": "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf", + "dest": "cargo/vendor/time-macros-0.2.18" + }, + { + "type": "inline", + "contents": "{\"package\": \"3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf\", \"files\": {}}", + "dest": "cargo/vendor/time-macros-0.2.18", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tinyvec/tinyvec-1.6.0.crate", + "sha256": "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50", + "dest": "cargo/vendor/tinyvec-1.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50\", \"files\": {}}", + "dest": "cargo/vendor/tinyvec-1.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tinyvec_macros/tinyvec_macros-0.1.1.crate", + "sha256": "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20", + "dest": "cargo/vendor/tinyvec_macros-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20\", \"files\": {}}", + "dest": "cargo/vendor/tinyvec_macros-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tokio/tokio-1.41.0.crate", + "sha256": "145f3413504347a2be84393cc8a7d2fb4d863b375909ea59f2158261aa258bbb", + "dest": "cargo/vendor/tokio-1.41.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"145f3413504347a2be84393cc8a7d2fb4d863b375909ea59f2158261aa258bbb\", \"files\": {}}", + "dest": "cargo/vendor/tokio-1.41.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tokio-macros/tokio-macros-2.4.0.crate", + "sha256": "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752", + "dest": "cargo/vendor/tokio-macros-2.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752\", \"files\": {}}", + "dest": "cargo/vendor/tokio-macros-2.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tokio-rustls/tokio-rustls-0.24.1.crate", + "sha256": "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081", + "dest": "cargo/vendor/tokio-rustls-0.24.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081\", \"files\": {}}", + "dest": "cargo/vendor/tokio-rustls-0.24.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tokio-rustls/tokio-rustls-0.25.0.crate", + "sha256": "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f", + "dest": "cargo/vendor/tokio-rustls-0.25.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f\", \"files\": {}}", + "dest": "cargo/vendor/tokio-rustls-0.25.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tokio-rustls/tokio-rustls-0.26.0.crate", + "sha256": "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4", + "dest": "cargo/vendor/tokio-rustls-0.26.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4\", \"files\": {}}", + "dest": "cargo/vendor/tokio-rustls-0.26.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tokio-stream/tokio-stream-0.1.14.crate", + "sha256": "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842", + "dest": "cargo/vendor/tokio-stream-0.1.14" + }, + { + "type": "inline", + "contents": "{\"package\": \"397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842\", \"files\": {}}", + "dest": "cargo/vendor/tokio-stream-0.1.14", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tokio-tungstenite/tokio-tungstenite-0.24.0.crate", + "sha256": "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9", + "dest": "cargo/vendor/tokio-tungstenite-0.24.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9\", \"files\": {}}", + "dest": "cargo/vendor/tokio-tungstenite-0.24.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tokio-util/tokio-util-0.7.10.crate", + "sha256": "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15", + "dest": "cargo/vendor/tokio-util-0.7.10" + }, + { + "type": "inline", + "contents": "{\"package\": \"5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15\", \"files\": {}}", + "dest": "cargo/vendor/tokio-util-0.7.10", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/toml/toml-0.8.6.crate", + "sha256": "8ff9e3abce27ee2c9a37f9ad37238c1bdd4e789c84ba37df76aa4d528f5072cc", + "dest": "cargo/vendor/toml-0.8.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"8ff9e3abce27ee2c9a37f9ad37238c1bdd4e789c84ba37df76aa4d528f5072cc\", \"files\": {}}", + "dest": "cargo/vendor/toml-0.8.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/toml_datetime/toml_datetime-0.6.8.crate", + "sha256": "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41", + "dest": "cargo/vendor/toml_datetime-0.6.8" + }, + { + "type": "inline", + "contents": "{\"package\": \"0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41\", \"files\": {}}", + "dest": "cargo/vendor/toml_datetime-0.6.8", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/toml_edit/toml_edit-0.19.15.crate", + "sha256": "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421", + "dest": "cargo/vendor/toml_edit-0.19.15" + }, + { + "type": "inline", + "contents": "{\"package\": \"1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421\", \"files\": {}}", + "dest": "cargo/vendor/toml_edit-0.19.15", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/toml_edit/toml_edit-0.20.7.crate", + "sha256": "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81", + "dest": "cargo/vendor/toml_edit-0.20.7" + }, + { + "type": "inline", + "contents": "{\"package\": \"70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81\", \"files\": {}}", + "dest": "cargo/vendor/toml_edit-0.20.7", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/toml_edit/toml_edit-0.22.22.crate", + "sha256": "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5", + "dest": "cargo/vendor/toml_edit-0.22.22" + }, + { + "type": "inline", + "contents": "{\"package\": \"4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5\", \"files\": {}}", + "dest": "cargo/vendor/toml_edit-0.22.22", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tower/tower-0.4.13.crate", + "sha256": "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c", + "dest": "cargo/vendor/tower-0.4.13" + }, + { + "type": "inline", + "contents": "{\"package\": \"b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c\", \"files\": {}}", + "dest": "cargo/vendor/tower-0.4.13", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tower-layer/tower-layer-0.3.3.crate", + "sha256": "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e", + "dest": "cargo/vendor/tower-layer-0.3.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e\", \"files\": {}}", + "dest": "cargo/vendor/tower-layer-0.3.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tower-service/tower-service-0.3.2.crate", + "sha256": "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52", + "dest": "cargo/vendor/tower-service-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52\", \"files\": {}}", + "dest": "cargo/vendor/tower-service-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tracing/tracing-0.1.40.crate", + "sha256": "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef", + "dest": "cargo/vendor/tracing-0.1.40" + }, + { + "type": "inline", + "contents": "{\"package\": \"c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef\", \"files\": {}}", + "dest": "cargo/vendor/tracing-0.1.40", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tracing-attributes/tracing-attributes-0.1.27.crate", + "sha256": "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7", + "dest": "cargo/vendor/tracing-attributes-0.1.27" + }, + { + "type": "inline", + "contents": "{\"package\": \"34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7\", \"files\": {}}", + "dest": "cargo/vendor/tracing-attributes-0.1.27", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tracing-core/tracing-core-0.1.32.crate", + "sha256": "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54", + "dest": "cargo/vendor/tracing-core-0.1.32" + }, + { + "type": "inline", + "contents": "{\"package\": \"c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54\", \"files\": {}}", + "dest": "cargo/vendor/tracing-core-0.1.32", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tracing-futures/tracing-futures-0.2.5.crate", + "sha256": "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2", + "dest": "cargo/vendor/tracing-futures-0.2.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2\", \"files\": {}}", + "dest": "cargo/vendor/tracing-futures-0.2.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/try-lock/try-lock-0.2.4.crate", + "sha256": "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed", + "dest": "cargo/vendor/try-lock-0.2.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed\", \"files\": {}}", + "dest": "cargo/vendor/try-lock-0.2.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tungstenite/tungstenite-0.24.0.crate", + "sha256": "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a", + "dest": "cargo/vendor/tungstenite-0.24.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a\", \"files\": {}}", + "dest": "cargo/vendor/tungstenite-0.24.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/typenum/typenum-1.17.0.crate", + "sha256": "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825", + "dest": "cargo/vendor/typenum-1.17.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825\", \"files\": {}}", + "dest": "cargo/vendor/typenum-1.17.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/uds_windows/uds_windows-1.0.2.crate", + "sha256": "ce65604324d3cce9b966701489fbd0cf318cb1f7bd9dd07ac9a4ee6fb791930d", + "dest": "cargo/vendor/uds_windows-1.0.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"ce65604324d3cce9b966701489fbd0cf318cb1f7bd9dd07ac9a4ee6fb791930d\", \"files\": {}}", + "dest": "cargo/vendor/uds_windows-1.0.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-bidi/unicode-bidi-0.3.13.crate", + "sha256": "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460", + "dest": "cargo/vendor/unicode-bidi-0.3.13" + }, + { + "type": "inline", + "contents": "{\"package\": \"92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460\", \"files\": {}}", + "dest": "cargo/vendor/unicode-bidi-0.3.13", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-ident/unicode-ident-1.0.12.crate", + "sha256": "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b", + "dest": "cargo/vendor/unicode-ident-1.0.12" + }, + { + "type": "inline", + "contents": "{\"package\": \"3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b\", \"files\": {}}", + "dest": "cargo/vendor/unicode-ident-1.0.12", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-normalization/unicode-normalization-0.1.22.crate", + "sha256": "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921", + "dest": "cargo/vendor/unicode-normalization-0.1.22" + }, + { + "type": "inline", + "contents": "{\"package\": \"5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921\", \"files\": {}}", + "dest": "cargo/vendor/unicode-normalization-0.1.22", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-width/unicode-width-0.1.11.crate", + "sha256": "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85", + "dest": "cargo/vendor/unicode-width-0.1.11" + }, + { + "type": "inline", + "contents": "{\"package\": \"e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85\", \"files\": {}}", + "dest": "cargo/vendor/unicode-width-0.1.11", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/untrusted/untrusted-0.9.0.crate", + "sha256": "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1", + "dest": "cargo/vendor/untrusted-0.9.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1\", \"files\": {}}", + "dest": "cargo/vendor/untrusted-0.9.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/url/url-2.4.1.crate", + "sha256": "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5", + "dest": "cargo/vendor/url-2.4.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5\", \"files\": {}}", + "dest": "cargo/vendor/url-2.4.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/utf-8/utf-8-0.7.6.crate", + "sha256": "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9", + "dest": "cargo/vendor/utf-8-0.7.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9\", \"files\": {}}", + "dest": "cargo/vendor/utf-8-0.7.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/utf8parse/utf8parse-0.2.2.crate", + "sha256": "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821", + "dest": "cargo/vendor/utf8parse-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821\", \"files\": {}}", + "dest": "cargo/vendor/utf8parse-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/uuid/uuid-1.5.0.crate", + "sha256": "88ad59a7560b41a70d191093a945f0b87bc1deeda46fb237479708a1d6b6cdfc", + "dest": "cargo/vendor/uuid-1.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"88ad59a7560b41a70d191093a945f0b87bc1deeda46fb237479708a1d6b6cdfc\", \"files\": {}}", + "dest": "cargo/vendor/uuid-1.5.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/value-bag/value-bag-1.10.0.crate", + "sha256": "3ef4c4aa54d5d05a279399bfa921ec387b7aba77caf7a682ae8d86785b8fdad2", + "dest": "cargo/vendor/value-bag-1.10.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"3ef4c4aa54d5d05a279399bfa921ec387b7aba77caf7a682ae8d86785b8fdad2\", \"files\": {}}", + "dest": "cargo/vendor/value-bag-1.10.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/vcpkg/vcpkg-0.2.15.crate", + "sha256": "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426", + "dest": "cargo/vendor/vcpkg-0.2.15" + }, + { + "type": "inline", + "contents": "{\"package\": \"accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426\", \"files\": {}}", + "dest": "cargo/vendor/vcpkg-0.2.15", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/vergen/vergen-9.0.1.crate", + "sha256": "349ed9e45296a581f455bc18039878f409992999bc1d5da12a6800eb18c8752f", + "dest": "cargo/vendor/vergen-9.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"349ed9e45296a581f455bc18039878f409992999bc1d5da12a6800eb18c8752f\", \"files\": {}}", + "dest": "cargo/vendor/vergen-9.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/vergen-gitcl/vergen-gitcl-1.0.1.crate", + "sha256": "2a3a7f91caabecefc3c249fd864b11d4abe315c166fbdb568964421bccfd2b7a", + "dest": "cargo/vendor/vergen-gitcl-1.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"2a3a7f91caabecefc3c249fd864b11d4abe315c166fbdb568964421bccfd2b7a\", \"files\": {}}", + "dest": "cargo/vendor/vergen-gitcl-1.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/vergen-lib/vergen-lib-0.1.4.crate", + "sha256": "229eaddb0050920816cf051e619affaf18caa3dd512de8de5839ccbc8e53abb0", + "dest": "cargo/vendor/vergen-lib-0.1.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"229eaddb0050920816cf051e619affaf18caa3dd512de8de5839ccbc8e53abb0\", \"files\": {}}", + "dest": "cargo/vendor/vergen-lib-0.1.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/version-compare/version-compare-0.1.1.crate", + "sha256": "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29", + "dest": "cargo/vendor/version-compare-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29\", \"files\": {}}", + "dest": "cargo/vendor/version-compare-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/version-compare/version-compare-0.2.0.crate", + "sha256": "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b", + "dest": "cargo/vendor/version-compare-0.2.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b\", \"files\": {}}", + "dest": "cargo/vendor/version-compare-0.2.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/version_check/version_check-0.9.4.crate", + "sha256": "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f", + "dest": "cargo/vendor/version_check-0.9.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f\", \"files\": {}}", + "dest": "cargo/vendor/version_check-0.9.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/waker-fn/waker-fn-1.1.1.crate", + "sha256": "f3c4517f54858c779bbcbf228f4fca63d121bf85fbecb2dc578cdf4a39395690", + "dest": "cargo/vendor/waker-fn-1.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"f3c4517f54858c779bbcbf228f4fca63d121bf85fbecb2dc578cdf4a39395690\", \"files\": {}}", + "dest": "cargo/vendor/waker-fn-1.1.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/walkdir/walkdir-2.4.0.crate", + "sha256": "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee", + "dest": "cargo/vendor/walkdir-2.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee\", \"files\": {}}", + "dest": "cargo/vendor/walkdir-2.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/want/want-0.3.1.crate", + "sha256": "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e", + "dest": "cargo/vendor/want-0.3.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e\", \"files\": {}}", + "dest": "cargo/vendor/want-0.3.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wasi/wasi-0.11.0+wasi-snapshot-preview1.crate", + "sha256": "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423", + "dest": "cargo/vendor/wasi-0.11.0+wasi-snapshot-preview1" + }, + { + "type": "inline", + "contents": "{\"package\": \"9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423\", \"files\": {}}", + "dest": "cargo/vendor/wasi-0.11.0+wasi-snapshot-preview1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.95.crate", + "sha256": "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e", + "dest": "cargo/vendor/wasm-bindgen-0.2.95" + }, + { + "type": "inline", + "contents": "{\"package\": \"128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e\", \"files\": {}}", + "dest": "cargo/vendor/wasm-bindgen-0.2.95", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wasm-bindgen-backend/wasm-bindgen-backend-0.2.95.crate", + "sha256": "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358", + "dest": "cargo/vendor/wasm-bindgen-backend-0.2.95" + }, + { + "type": "inline", + "contents": "{\"package\": \"cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358\", \"files\": {}}", + "dest": "cargo/vendor/wasm-bindgen-backend-0.2.95", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.38.crate", + "sha256": "9afec9963e3d0994cac82455b2b3502b81a7f40f9a0d32181f7528d9f4b43e02", + "dest": "cargo/vendor/wasm-bindgen-futures-0.4.38" + }, + { + "type": "inline", + "contents": "{\"package\": \"9afec9963e3d0994cac82455b2b3502b81a7f40f9a0d32181f7528d9f4b43e02\", \"files\": {}}", + "dest": "cargo/vendor/wasm-bindgen-futures-0.4.38", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.95.crate", + "sha256": "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56", + "dest": "cargo/vendor/wasm-bindgen-macro-0.2.95" + }, + { + "type": "inline", + "contents": "{\"package\": \"e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56\", \"files\": {}}", + "dest": "cargo/vendor/wasm-bindgen-macro-0.2.95", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.95.crate", + "sha256": "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68", + "dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.95" + }, + { + "type": "inline", + "contents": "{\"package\": \"26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68\", \"files\": {}}", + "dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.95", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.95.crate", + "sha256": "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d", + "dest": "cargo/vendor/wasm-bindgen-shared-0.2.95" + }, + { + "type": "inline", + "contents": "{\"package\": \"65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d\", \"files\": {}}", + "dest": "cargo/vendor/wasm-bindgen-shared-0.2.95", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/web-sys/web-sys-0.3.65.crate", + "sha256": "5db499c5f66323272151db0e666cd34f78617522fb0c1604d31a27c50c206a85", + "dest": "cargo/vendor/web-sys-0.3.65" + }, + { + "type": "inline", + "contents": "{\"package\": \"5db499c5f66323272151db0e666cd34f78617522fb0c1604d31a27c50c206a85\", \"files\": {}}", + "dest": "cargo/vendor/web-sys-0.3.65", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/webpki/webpki-0.22.4.crate", + "sha256": "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53", + "dest": "cargo/vendor/webpki-0.22.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53\", \"files\": {}}", + "dest": "cargo/vendor/webpki-0.22.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/webpki-roots/webpki-roots-0.25.4.crate", + "sha256": "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1", + "dest": "cargo/vendor/webpki-roots-0.25.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1\", \"files\": {}}", + "dest": "cargo/vendor/webpki-roots-0.25.4", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/which/which-4.4.2.crate", + "sha256": "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7", + "dest": "cargo/vendor/which-4.4.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7\", \"files\": {}}", + "dest": "cargo/vendor/which-4.4.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/winapi/winapi-0.3.9.crate", + "sha256": "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419", + "dest": "cargo/vendor/winapi-0.3.9" + }, + { + "type": "inline", + "contents": "{\"package\": \"5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419\", \"files\": {}}", + "dest": "cargo/vendor/winapi-0.3.9", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/winapi-i686-pc-windows-gnu/winapi-i686-pc-windows-gnu-0.4.0.crate", + "sha256": "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6", + "dest": "cargo/vendor/winapi-i686-pc-windows-gnu-0.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6\", \"files\": {}}", + "dest": "cargo/vendor/winapi-i686-pc-windows-gnu-0.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/winapi-util/winapi-util-0.1.6.crate", + "sha256": "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596", + "dest": "cargo/vendor/winapi-util-0.1.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596\", \"files\": {}}", + "dest": "cargo/vendor/winapi-util-0.1.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/winapi-x86_64-pc-windows-gnu/winapi-x86_64-pc-windows-gnu-0.4.0.crate", + "sha256": "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f", + "dest": "cargo/vendor/winapi-x86_64-pc-windows-gnu-0.4.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f\", \"files\": {}}", + "dest": "cargo/vendor/winapi-x86_64-pc-windows-gnu-0.4.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows/windows-0.52.0.crate", + "sha256": "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be", + "dest": "cargo/vendor/windows-0.52.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be\", \"files\": {}}", + "dest": "cargo/vendor/windows-0.52.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows/windows-0.54.0.crate", + "sha256": "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49", + "dest": "cargo/vendor/windows-0.54.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49\", \"files\": {}}", + "dest": "cargo/vendor/windows-0.54.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows/windows-0.57.0.crate", + "sha256": "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143", + "dest": "cargo/vendor/windows-0.57.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143\", \"files\": {}}", + "dest": "cargo/vendor/windows-0.57.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-core/windows-core-0.51.1.crate", + "sha256": "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64", + "dest": "cargo/vendor/windows-core-0.51.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64\", \"files\": {}}", + "dest": "cargo/vendor/windows-core-0.51.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-core/windows-core-0.52.0.crate", + "sha256": "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9", + "dest": "cargo/vendor/windows-core-0.52.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9\", \"files\": {}}", + "dest": "cargo/vendor/windows-core-0.52.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-core/windows-core-0.54.0.crate", + "sha256": "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65", + "dest": "cargo/vendor/windows-core-0.54.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65\", \"files\": {}}", + "dest": "cargo/vendor/windows-core-0.54.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-core/windows-core-0.57.0.crate", + "sha256": "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d", + "dest": "cargo/vendor/windows-core-0.57.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d\", \"files\": {}}", + "dest": "cargo/vendor/windows-core-0.57.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-implement/windows-implement-0.57.0.crate", + "sha256": "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7", + "dest": "cargo/vendor/windows-implement-0.57.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7\", \"files\": {}}", + "dest": "cargo/vendor/windows-implement-0.57.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-interface/windows-interface-0.57.0.crate", + "sha256": "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7", + "dest": "cargo/vendor/windows-interface-0.57.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7\", \"files\": {}}", + "dest": "cargo/vendor/windows-interface-0.57.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-result/windows-result-0.1.2.crate", + "sha256": "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8", + "dest": "cargo/vendor/windows-result-0.1.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8\", \"files\": {}}", + "dest": "cargo/vendor/windows-result-0.1.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-sys/windows-sys-0.45.0.crate", + "sha256": "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0", + "dest": "cargo/vendor/windows-sys-0.45.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0\", \"files\": {}}", + "dest": "cargo/vendor/windows-sys-0.45.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-sys/windows-sys-0.48.0.crate", + "sha256": "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9", + "dest": "cargo/vendor/windows-sys-0.48.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9\", \"files\": {}}", + "dest": "cargo/vendor/windows-sys-0.48.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-sys/windows-sys-0.52.0.crate", + "sha256": "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d", + "dest": "cargo/vendor/windows-sys-0.52.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d\", \"files\": {}}", + "dest": "cargo/vendor/windows-sys-0.52.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-sys/windows-sys-0.59.0.crate", + "sha256": "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b", + "dest": "cargo/vendor/windows-sys-0.59.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b\", \"files\": {}}", + "dest": "cargo/vendor/windows-sys-0.59.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-targets/windows-targets-0.42.2.crate", + "sha256": "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071", + "dest": "cargo/vendor/windows-targets-0.42.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071\", \"files\": {}}", + "dest": "cargo/vendor/windows-targets-0.42.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-targets/windows-targets-0.48.5.crate", + "sha256": "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c", + "dest": "cargo/vendor/windows-targets-0.48.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c\", \"files\": {}}", + "dest": "cargo/vendor/windows-targets-0.48.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows-targets/windows-targets-0.52.6.crate", + "sha256": "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973", + "dest": "cargo/vendor/windows-targets-0.52.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973\", \"files\": {}}", + "dest": "cargo/vendor/windows-targets-0.52.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_aarch64_gnullvm/windows_aarch64_gnullvm-0.42.2.crate", + "sha256": "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8", + "dest": "cargo/vendor/windows_aarch64_gnullvm-0.42.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8\", \"files\": {}}", + "dest": "cargo/vendor/windows_aarch64_gnullvm-0.42.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_aarch64_gnullvm/windows_aarch64_gnullvm-0.48.5.crate", + "sha256": "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8", + "dest": "cargo/vendor/windows_aarch64_gnullvm-0.48.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8\", \"files\": {}}", + "dest": "cargo/vendor/windows_aarch64_gnullvm-0.48.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_aarch64_gnullvm/windows_aarch64_gnullvm-0.52.6.crate", + "sha256": "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3", + "dest": "cargo/vendor/windows_aarch64_gnullvm-0.52.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3\", \"files\": {}}", + "dest": "cargo/vendor/windows_aarch64_gnullvm-0.52.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_aarch64_msvc/windows_aarch64_msvc-0.42.2.crate", + "sha256": "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43", + "dest": "cargo/vendor/windows_aarch64_msvc-0.42.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43\", \"files\": {}}", + "dest": "cargo/vendor/windows_aarch64_msvc-0.42.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_aarch64_msvc/windows_aarch64_msvc-0.48.5.crate", + "sha256": "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc", + "dest": "cargo/vendor/windows_aarch64_msvc-0.48.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc\", \"files\": {}}", + "dest": "cargo/vendor/windows_aarch64_msvc-0.48.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_aarch64_msvc/windows_aarch64_msvc-0.52.6.crate", + "sha256": "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469", + "dest": "cargo/vendor/windows_aarch64_msvc-0.52.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469\", \"files\": {}}", + "dest": "cargo/vendor/windows_aarch64_msvc-0.52.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_i686_gnu/windows_i686_gnu-0.42.2.crate", + "sha256": "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f", + "dest": "cargo/vendor/windows_i686_gnu-0.42.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f\", \"files\": {}}", + "dest": "cargo/vendor/windows_i686_gnu-0.42.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_i686_gnu/windows_i686_gnu-0.48.5.crate", + "sha256": "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e", + "dest": "cargo/vendor/windows_i686_gnu-0.48.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e\", \"files\": {}}", + "dest": "cargo/vendor/windows_i686_gnu-0.48.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_i686_gnu/windows_i686_gnu-0.52.6.crate", + "sha256": "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b", + "dest": "cargo/vendor/windows_i686_gnu-0.52.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b\", \"files\": {}}", + "dest": "cargo/vendor/windows_i686_gnu-0.52.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_i686_gnullvm/windows_i686_gnullvm-0.52.6.crate", + "sha256": "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66", + "dest": "cargo/vendor/windows_i686_gnullvm-0.52.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66\", \"files\": {}}", + "dest": "cargo/vendor/windows_i686_gnullvm-0.52.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_i686_msvc/windows_i686_msvc-0.42.2.crate", + "sha256": "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060", + "dest": "cargo/vendor/windows_i686_msvc-0.42.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060\", \"files\": {}}", + "dest": "cargo/vendor/windows_i686_msvc-0.42.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_i686_msvc/windows_i686_msvc-0.48.5.crate", + "sha256": "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406", + "dest": "cargo/vendor/windows_i686_msvc-0.48.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406\", \"files\": {}}", + "dest": "cargo/vendor/windows_i686_msvc-0.48.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_i686_msvc/windows_i686_msvc-0.52.6.crate", + "sha256": "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66", + "dest": "cargo/vendor/windows_i686_msvc-0.52.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66\", \"files\": {}}", + "dest": "cargo/vendor/windows_i686_msvc-0.52.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_x86_64_gnu/windows_x86_64_gnu-0.42.2.crate", + "sha256": "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36", + "dest": "cargo/vendor/windows_x86_64_gnu-0.42.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36\", \"files\": {}}", + "dest": "cargo/vendor/windows_x86_64_gnu-0.42.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_x86_64_gnu/windows_x86_64_gnu-0.48.5.crate", + "sha256": "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e", + "dest": "cargo/vendor/windows_x86_64_gnu-0.48.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e\", \"files\": {}}", + "dest": "cargo/vendor/windows_x86_64_gnu-0.48.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_x86_64_gnu/windows_x86_64_gnu-0.52.6.crate", + "sha256": "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78", + "dest": "cargo/vendor/windows_x86_64_gnu-0.52.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78\", \"files\": {}}", + "dest": "cargo/vendor/windows_x86_64_gnu-0.52.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_x86_64_gnullvm/windows_x86_64_gnullvm-0.42.2.crate", + "sha256": "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3", + "dest": "cargo/vendor/windows_x86_64_gnullvm-0.42.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3\", \"files\": {}}", + "dest": "cargo/vendor/windows_x86_64_gnullvm-0.42.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_x86_64_gnullvm/windows_x86_64_gnullvm-0.48.5.crate", + "sha256": "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc", + "dest": "cargo/vendor/windows_x86_64_gnullvm-0.48.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc\", \"files\": {}}", + "dest": "cargo/vendor/windows_x86_64_gnullvm-0.48.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_x86_64_gnullvm/windows_x86_64_gnullvm-0.52.6.crate", + "sha256": "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d", + "dest": "cargo/vendor/windows_x86_64_gnullvm-0.52.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d\", \"files\": {}}", + "dest": "cargo/vendor/windows_x86_64_gnullvm-0.52.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_x86_64_msvc/windows_x86_64_msvc-0.42.2.crate", + "sha256": "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0", + "dest": "cargo/vendor/windows_x86_64_msvc-0.42.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0\", \"files\": {}}", + "dest": "cargo/vendor/windows_x86_64_msvc-0.42.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_x86_64_msvc/windows_x86_64_msvc-0.48.5.crate", + "sha256": "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538", + "dest": "cargo/vendor/windows_x86_64_msvc-0.48.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538\", \"files\": {}}", + "dest": "cargo/vendor/windows_x86_64_msvc-0.48.5", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/windows_x86_64_msvc/windows_x86_64_msvc-0.52.6.crate", + "sha256": "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec", + "dest": "cargo/vendor/windows_x86_64_msvc-0.52.6" + }, + { + "type": "inline", + "contents": "{\"package\": \"589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec\", \"files\": {}}", + "dest": "cargo/vendor/windows_x86_64_msvc-0.52.6", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/winnow/winnow-0.5.18.crate", + "sha256": "176b6138793677221d420fd2f0aeeced263f197688b36484660da767bca2fa32", + "dest": "cargo/vendor/winnow-0.5.18" + }, + { + "type": "inline", + "contents": "{\"package\": \"176b6138793677221d420fd2f0aeeced263f197688b36484660da767bca2fa32\", \"files\": {}}", + "dest": "cargo/vendor/winnow-0.5.18", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/winnow/winnow-0.6.20.crate", + "sha256": "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b", + "dest": "cargo/vendor/winnow-0.6.20" + }, + { + "type": "inline", + "contents": "{\"package\": \"36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b\", \"files\": {}}", + "dest": "cargo/vendor/winnow-0.6.20", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/winreg/winreg-0.50.0.crate", + "sha256": "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1", + "dest": "cargo/vendor/winreg-0.50.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1\", \"files\": {}}", + "dest": "cargo/vendor/winreg-0.50.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/xdg-home/xdg-home-1.0.0.crate", + "sha256": "2769203cd13a0c6015d515be729c526d041e9cf2c0cc478d57faee85f40c6dcd", + "dest": "cargo/vendor/xdg-home-1.0.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"2769203cd13a0c6015d515be729c526d041e9cf2c0cc478d57faee85f40c6dcd\", \"files\": {}}", + "dest": "cargo/vendor/xdg-home-1.0.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zbus/zbus-3.14.1.crate", + "sha256": "31de390a2d872e4cd04edd71b425e29853f786dc99317ed72d73d6fcf5ebb948", + "dest": "cargo/vendor/zbus-3.14.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"31de390a2d872e4cd04edd71b425e29853f786dc99317ed72d73d6fcf5ebb948\", \"files\": {}}", + "dest": "cargo/vendor/zbus-3.14.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zbus_macros/zbus_macros-3.14.1.crate", + "sha256": "41d1794a946878c0e807f55a397187c11fc7a038ba5d868e7db4f3bd7760bc9d", + "dest": "cargo/vendor/zbus_macros-3.14.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"41d1794a946878c0e807f55a397187c11fc7a038ba5d868e7db4f3bd7760bc9d\", \"files\": {}}", + "dest": "cargo/vendor/zbus_macros-3.14.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zbus_names/zbus_names-2.6.0.crate", + "sha256": "fb80bb776dbda6e23d705cf0123c3b95df99c4ebeaec6c2599d4a5419902b4a9", + "dest": "cargo/vendor/zbus_names-2.6.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"fb80bb776dbda6e23d705cf0123c3b95df99c4ebeaec6c2599d4a5419902b4a9\", \"files\": {}}", + "dest": "cargo/vendor/zbus_names-2.6.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zerocopy/zerocopy-0.7.35.crate", + "sha256": "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0", + "dest": "cargo/vendor/zerocopy-0.7.35" + }, + { + "type": "inline", + "contents": "{\"package\": \"1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0\", \"files\": {}}", + "dest": "cargo/vendor/zerocopy-0.7.35", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zerocopy-derive/zerocopy-derive-0.7.35.crate", + "sha256": "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e", + "dest": "cargo/vendor/zerocopy-derive-0.7.35" + }, + { + "type": "inline", + "contents": "{\"package\": \"fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e\", \"files\": {}}", + "dest": "cargo/vendor/zerocopy-derive-0.7.35", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zeroize/zeroize-1.8.1.crate", + "sha256": "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde", + "dest": "cargo/vendor/zeroize-1.8.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde\", \"files\": {}}", + "dest": "cargo/vendor/zeroize-1.8.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zvariant/zvariant-3.15.0.crate", + "sha256": "44b291bee0d960c53170780af148dca5fa260a63cdd24f1962fa82e03e53338c", + "dest": "cargo/vendor/zvariant-3.15.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"44b291bee0d960c53170780af148dca5fa260a63cdd24f1962fa82e03e53338c\", \"files\": {}}", + "dest": "cargo/vendor/zvariant-3.15.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zvariant_derive/zvariant_derive-3.15.0.crate", + "sha256": "934d7a7dfc310d6ee06c87ffe88ef4eca7d3e37bb251dece2ef93da8f17d8ecd", + "dest": "cargo/vendor/zvariant_derive-3.15.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"934d7a7dfc310d6ee06c87ffe88ef4eca7d3e37bb251dece2ef93da8f17d8ecd\", \"files\": {}}", + "dest": "cargo/vendor/zvariant_derive-3.15.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/zvariant_utils/zvariant_utils-1.0.1.crate", + "sha256": "7234f0d811589db492d16893e3f21e8e2fd282e6d01b0cddee310322062cc200", + "dest": "cargo/vendor/zvariant_utils-1.0.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"7234f0d811589db492d16893e3f21e8e2fd282e6d01b0cddee310322062cc200\", \"files\": {}}", + "dest": "cargo/vendor/zvariant_utils-1.0.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "inline", + "contents": "[source.vendored-sources]\ndirectory = \"cargo/vendor\"\n\n[source.crates-io]\nreplace-with = \"vendored-sources\"\n", + "dest": "cargo", + "dest-filename": "config" + } +] \ No newline at end of file diff --git a/data/appstream/1.png b/data/appstream/1.png new file mode 100644 index 0000000..ea1f28b Binary files /dev/null and b/data/appstream/1.png differ diff --git a/data/appstream/2.png b/data/appstream/2.png new file mode 100644 index 0000000..808afdb Binary files /dev/null and b/data/appstream/2.png differ diff --git a/data/appstream/3.png b/data/appstream/3.png new file mode 100644 index 0000000..97db9a2 Binary files /dev/null and b/data/appstream/3.png differ diff --git a/data/dev.alextren.Spot.Source.svg b/data/dev.alextren.Spot.Source.svg new file mode 100644 index 0000000..2ac6601 --- /dev/null +++ b/data/dev.alextren.Spot.Source.svg @@ -0,0 +1,1033 @@ + + + Adwaita Icon Template + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + GNOME Design Team + + + + + Adwaita Icon Template + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Hicolor + Symbolic + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/data/dev.alextren.Spot.appdata.xml b/data/dev.alextren.Spot.appdata.xml new file mode 100644 index 0000000..bd19894 --- /dev/null +++ b/data/dev.alextren.Spot.appdata.xml @@ -0,0 +1,356 @@ + + + dev.alextren.Spot + https://github.com/xou816/spot + Spot + Listen to music on Spotify + dev.alextren.Spot.desktop + CC0-1.0 + MIT + + + 360 + + + keyboard + pointing + touch + + + + workstation + mobile + + +

+ Listen to music on Spotify. + Requires a premium account. +

+

Current features:

+
    +
  • playback control (play/pause, prev/next, seeking)
  • +
  • play queue with shuffle option
  • +
  • selection mode: easily browse and select mutliple tracks to queue them
  • +
  • browse your saved albums and playlists
  • +
  • search albums and artists
  • +
  • view an artist's releases
  • +
  • view users' playlists
  • +
  • credentials management with Secret Service
  • +
  • MPRIS integration
  • +
+
+ + + https://raw.githubusercontent.com/xou816/spot/master/data/appstream/1.png + + + https://raw.githubusercontent.com/xou816/spot/master/data/appstream/2.png + + + https://raw.githubusercontent.com/xou816/spot/master/data/appstream/3.png + + + + +

+ It's been a while, hasn't it? I'm back, but please allow everyone some time to work through the issues and pull requests :) +

+
    +
  • added an option for gapless playback (thanks @supremesnickers!)
  • +
  • fixed theme management (thanks @ctsk!)
  • +
  • fixed the behaviour of the "previous" button for consistency with other media players (thanks @SomewhereOutInSpace!)
  • +
  • the user will now be prompted to unlock the keyring if needed (thanks @rainDiX!)
  • +
  • added playlist creation and edition
  • +
  • more bug fixes
  • +
+

+ Thanks to all the contributors who are somehow still around :) If you feel like you've been unjustly forgotten from the AUTHORS or TRANSLATORS file, feel free to open a PR. +

+
+
+ + +

+ What's new: +

+
    +
  • login dialog now uses a password entry (thanks @Toorero!)
  • +
  • fix a startup crash with some locales
  • +
+
+
+ + +

+ New features: +

+
    +
  • save tracks to library
  • +
  • basic Spotify URI handling (thanks @bbb651!)
  • +
  • settings dialog (thanks @sei0o!)
  • +
  • brand new icon (thanks @bertob!)
  • +
  • bug fixes
  • +
+

+ Thank you to everyone involved in this release! +

+
+
+ + + +

Bugfix release:

+
    +
  • fix a startup crash with some locales (thanks @jakubiszon26!)
  • +
  • fix a crash when attempting to view some users' profile (thanks @juxuanu!)
  • +
+
+
+ + +

+ There has been no release for a while, but this one packs quite a few features: +

+
    +
  • redesigned several parts of the application: headers, selection tools, library, album view... (thanks @jannuary!)
  • +
  • many, many, many more subtle design changes and tweaks still owed to @jannuary
  • +
  • playlists are now accessible from the sidebar (thanks @abegert!)
  • +
  • the album art is visible for individual songs, and the release year is visible for albums (thanks @abegert and @sei0o!)
  • +
  • volume control over MPRIS (thanks @Diegovsky!)
  • +
  • as always, updated translations for yet more languages; thanks to the many people involved on POEditor!
  • +
+

+ Thank you so much to everyone involved for this release, including those I could not mention. If you're willing to contribute, do not hesitate to seek advice from those trusted contributors, and be sure to direct your UX questions towards @jannuary (as long as they're fine with it!). +

+
+
+ + +

Quick fix for a startup crash, sorry for the inconvenience. Thanks @xRMG412!

+
+
+ + +

+ What's new: +

+
    +
  • browse saved tracks from Spot
  • +
  • a status page is now displayed when no albums or playlists have been added (thanks @Diegovsky!)
  • +
  • change the access point port from GSettings; this should help users running Spot behind a firewall (thanks @sei0o!)
  • +
  • display a warning in the login dialog if Caps Lock is enabled (thanks @przebor!)
  • +
  • added Purism form-factor metadata; this should improve the discoverability of Spot (thanks @1peter10!), and generally improved the appstream metadata to match the updated guidelines
  • +
  • various bugfixes (clear credentials on auth failure, load all tracks for long albums...)
  • +
+

Thank you to the many contributors to this release, as well as to all those contributing translations on POEditor!

+
+
+ + +

+ What's new: +

+
    +
  • ported app to GTK4 and libadwaita
  • +
  • MPRIS: handle shuffling and loop status
  • +
  • improved album size on small screens
  • +
+
+
+ + +

+ What's new: +

+
    +
  • added a new album info modal with full release details (thanks TotalDarkness-NRF!)
  • +
  • improved player controls: added the ability to repeat a single track or the whole queue (thanks TotalDarkness-NRF!)
  • +
  • improved login credentials management: (re)use auth token when possible instead of always using email and password (thanks nn1ks!)
  • +
  • MPRIS fixes: raising the player is now properly implemented (thanks eladyn!)
  • +
  • ...actually fix an issue with playlists not being modifiable when login in with email
  • +
+

+ Special thanks to TotalDarkness-NRF for contributing several major features for this version. +

+
+
+ + +

+ What's new: +

+
    +
  • ability to remove tracks from writable playlists
  • +
  • improved login error handling
  • +
  • fixed an issue with playlists not being modifiable when login in with email
  • +
  • MPRIS: the desktop entry for Spot is now properly referenced (thanks nicolasfella!)
  • +
  • quality: various clippy fixes (thanks nn1ks!)
  • +
  • new translations: Turkish, Indonesian and Brazilian Portuguese (thanks YusufOzmen01, cho2 and lucraraujo, as well as ondras12345 for the reviews!)
  • +
  • +
+
+
+ + +

+ What's new: +

+
    +
  • long playlists are now handled (somewhat) properly, although this has some drawbacks (shuffling isn't so random...)
  • +
  • the MPRIS implementation now supports seeking, and should report the proper album art and album name; thanks Douile!
  • +
  • selection tools now allow adding to a playlist (not yet removing, though)
  • +
  • Russian translation
  • +
+

+ Thanks to all contributors, translators and bug reporters! +

+
+
+ + +

+ What's new: +

+
    +
  • new selection tools: move tracks up and down in queue, quickly select multiple tracks from current view...
  • +
  • touch and hold a song list (or right click) to enter selection mode
  • +
  • Portuguese translation
  • +
  • fixed session restoration requiring manually repeating last action after a long period of inactivity
  • +
  • fixed parsing for playlists with local tracks
  • +
+
+
+ + +

+ What's new: +

+
    +
  • browse users' playlists by clicking on their name in playlists you follow (thanks a bunch, Douile!)
  • +
  • Catalan, Czech, Polish and Spanish translations; thanks to all the translators involved!
  • +
  • bug fixes
  • +
+
+
+ + +

+ This release fixes the broken "Search" icon, and adds French, German and Dutch translations. Thanks to all translators for their contribution! +

+
+
+ + +

+ New features: +

+
    +
  • redesigned seekbar, login dialog and search (type from any screen to start a search)
  • +
  • song durations in playlist widgets (thanks realJavabot!)
  • +
  • play queue management (queue/dequeue/clear queue)
  • +
  • selection mode: enter selection mode to easily queue multiple tracks
  • +
  • keyboards shortcuts
  • +
  • various playback options through GSettings (no GUI yet)
  • +
+
+
+ + +

+ New feature: contextual menus for songs are now available everywhere, allowing you to easily navigate to related artists or share tracks. Thanks Douile! +

+

+ This release also includes numerous fixes, including a few crashes, performance issues, and most importantly playlists being truncated. +

+
+
+ + +

+ New feature: the main window can now be closed without stopping playback. Use Quit or Ctrl+Q to exit the app. +

+

+ This release fixes numerous issues, including: main window being too large and too tall for phones, startup crash for playlist without images, "About" dialog not closing in qtile, keyboard navigation being broken in the login dialog. Special thanks to Douile for contributing fixes for many of these isssues! +

+
+
+ + +

+ Hotfix for a crash in search results +

+
+
+ + +

+ New features: +

+
    +
  • search results now include artists
  • +
  • albums can be saved and unsaved from the library
  • +
+
+
+ + +

+ New features: +

+
    +
  • browse saved playlists
  • +
  • quick access to "Now playing"
  • +
  • artists top tracks
  • +
  • API token renewal
  • +
+
+
+ + +

+ New features: +

+
    +
  • adaptive UI with libhandy
  • +
+
+
+ + +

+ New features: +

+
    +
  • minimal MPRIS integration
  • +
  • "About" dialog
  • +
+
+
+ + +

+ New features: +

+
    +
  • improved playlist widget, added menu to jump from "Now playing" to related albums
  • +
  • added in-app notifications for some errors
  • +
+

Fixes:

+
    +
  • fixed an issue where songs would be skipped in a playlist when autoplaying
  • +
  • fixed character encoding in search queries
  • +
+
+
+ + +

Notables changes: symbolic icons everyhere (thanks gabmus!), shuffle play, logout

+
+
+ + +

Initial release

+
+
+
+
diff --git a/data/dev.alextren.Spot.desktop b/data/dev.alextren.Spot.desktop new file mode 100644 index 0000000..e06a59d --- /dev/null +++ b/data/dev.alextren.Spot.desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Version=1.5 +Name=Spot +Exec=spot %u +GenericName=Music Player +Icon=dev.alextren.Spot +Terminal=false +Type=Application +Categories=GTK;GNOME;Music;AudioVideo; +MimeType=x-scheme-handler/spotify; +StartupNotify=true +X-Purism-FormFactor=Workstation;Mobile; +SingleMainWindow=true diff --git a/data/dev.alextren.Spot.gschema.xml b/data/dev.alextren.Spot.gschema.xml new file mode 100644 index 0000000..bd97fc3 --- /dev/null +++ b/data/dev.alextren.Spot.gschema.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + 'system' + The theme preference + + + 1080 + The width of the window + + + 720 + The height of the window + + + false + A flag to enable maximized mode + + + '160' + Songs bitrate (96, 160, 320kbps) + + + 'pulseaudio' + Audio backend + + + true + A flag to enable gap-less playback + + + 'default' + Alsa device (if audio backend is 'alsa') + + + 0 + Port to communicate with Spotify's server (access point). Setting to 0 (default) allows Spot to use servers running on any port. + + + \ No newline at end of file diff --git a/data/hicolor/scalable/apps/dev.alextren.Spot.svg b/data/hicolor/scalable/apps/dev.alextren.Spot.svg new file mode 100644 index 0000000..71f857d --- /dev/null +++ b/data/hicolor/scalable/apps/dev.alextren.Spot.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/data/hicolor/symbolic/apps/dev.alextren.Spot-symbolic.svg b/data/hicolor/symbolic/apps/dev.alextren.Spot-symbolic.svg new file mode 100644 index 0000000..b4029bc --- /dev/null +++ b/data/hicolor/symbolic/apps/dev.alextren.Spot-symbolic.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/data/meson.build b/data/meson.build new file mode 100644 index 0000000..99ecc46 --- /dev/null +++ b/data/meson.build @@ -0,0 +1,31 @@ +install_data('dev.alextren.Spot.desktop', + install_dir: get_option('datadir') / 'applications' +) + +install_subdir('hicolor', + install_dir: get_option('datadir') / 'icons' +) + +install_data('dev.alextren.Spot.appdata.xml', + install_dir: get_option('datadir') / 'appdata' +) + +install_data('dev.alextren.Spot.gschema.xml', + install_dir: get_option('datadir') / 'glib-2.0/schemas' +) + +compile_schemas = find_program('glib-compile-schemas', required: true) +if compile_schemas.found() + test('Validate schema file', compile_schemas, + args: ['--strict', '--dry-run', meson.current_source_dir()] + ) +endif + +appstream_util = find_program('appstream-util', required: false) +if appstream_util.found() + test( + 'Validate appstream appdata', + appstream_util, + args: ['validate-relax', meson.current_source_dir() / 'dev.alextren.Spot.appdata.xml'] + ) +endif diff --git a/dev.alextren.Spot.development.json b/dev.alextren.Spot.development.json new file mode 100644 index 0000000..e8ba03d --- /dev/null +++ b/dev.alextren.Spot.development.json @@ -0,0 +1,68 @@ +{ + "app-id": "dev.alextren.Spot", + "runtime": "org.gnome.Platform", + "runtime-version": "master", + "sdk": "org.gnome.Sdk", + "sdk-extensions": [ + "org.freedesktop.Sdk.Extension.rust-stable" + ], + "command": "spot", + "finish-args": [ + "--share=network", + "--share=ipc", + "--socket=fallback-x11", + "--socket=wayland", + "--socket=pulseaudio", + "--device=dri", + "--talk-name=org.freedesktop.secrets", + "--own-name=org.mpris.MediaPlayer2.Spot" + ], + "separate-locales": false, + "build-options": { + "append-path": "/usr/lib/sdk/rust-stable/bin", + "env": { + "RUST_BACKTRACE": "full", + "RUST_LOG": "spot=debug" + } + }, + "cleanup": [ + "/include", + "/lib/pkgconfig", + "/man", + "/share/doc", + "/share/gtk-doc", + "/share/man", + "/share/pkgconfig", + "*.la", + "*.a" + ], + "modules": [ + { + "name": "blueprint-compiler", + "buildsystem": "meson", + "sources": [ + { + "type": "archive", + "url": "https://gitlab.gnome.org/jwestman/blueprint-compiler/-/archive/v0.8.1/blueprint-compiler-v0.8.1.tar.gz", + "sha256": "9207697cfac6e87a3c0ccf463be1a95c3bd06aa017c966a7e352ad5bc486cf3c" + } + ] + }, + { + "name": "spot", + "builddir": true, + "buildsystem": "meson", + "config-opts": [ + "-Doffline=true", + "-Dbuildtype=debug" + ], + "sources": [ + { + "type": "dir", + "path": "." + }, + "cargo-sources.json" + ] + } + ] +} diff --git a/dev.alextren.Spot.snapshots.json b/dev.alextren.Spot.snapshots.json new file mode 100644 index 0000000..6ae609c --- /dev/null +++ b/dev.alextren.Spot.snapshots.json @@ -0,0 +1,71 @@ +{ + "app-id": "dev.alextren.Spot", + "runtime": "org.gnome.Platform", + "runtime-version": "44", + "sdk": "org.gnome.Sdk", + "sdk-extensions": [ + "org.freedesktop.Sdk.Extension.rust-stable" + ], + "command": "spot", + "finish-args": [ + "--share=network", + "--share=ipc", + "--socket=fallback-x11", + "--socket=wayland", + "--socket=pulseaudio", + "--device=dri", + "--talk-name=org.freedesktop.secrets", + "--own-name=org.mpris.MediaPlayer2.Spot" + ], + "separate-locales": false, + "build-options": { + "append-path": "/usr/lib/sdk/rust-stable/bin", + "env": { + "CARGO_HOME": "/run/build/spot/cargo", + "RUST_BACKTRACE": "full", + "RUST_LOG": "spot=debug" + }, + "strip": false, + "no-debuginfo": true + }, + "cleanup": [ + "/include", + "/lib/pkgconfig", + "/man", + "/share/doc", + "/share/gtk-doc", + "/share/man", + "/share/pkgconfig", + "*.la", + "*.a" + ], + "modules": [ + { + "name": "blueprint-compiler", + "buildsystem": "meson", + "sources": [ + { + "type": "archive", + "url": "https://gitlab.gnome.org/jwestman/blueprint-compiler/-/archive/v0.8.1/blueprint-compiler-v0.8.1.tar.gz", + "sha256": "9207697cfac6e87a3c0ccf463be1a95c3bd06aa017c966a7e352ad5bc486cf3c" + } + ] + }, + { + "name": "spot", + "builddir": true, + "buildsystem": "meson", + "config-opts": [ + "-Doffline=true", + "-Dbuildtype=debug" + ], + "sources": [ + { + "type": "dir", + "path": "." + }, + "cargo-sources.json" + ] + } + ] +} diff --git a/doc/.latexmkrc b/doc/.latexmkrc new file mode 100644 index 0000000..b4730ac --- /dev/null +++ b/doc/.latexmkrc @@ -0,0 +1,4 @@ +$pdf_mode = 1; +$pdf_previewer = ''; +$pdflatex = 'lualatex -interaction=nonstopmode -synctex=1 -shell-escape %O %S'; +$out_dir = 'target'; diff --git a/doc/Dockerfile b/doc/Dockerfile new file mode 100644 index 0000000..3de29a9 --- /dev/null +++ b/doc/Dockerfile @@ -0,0 +1,4 @@ +FROM atrendel/doxerlive:15-basic +RUN apk add gettext py3-pygments +ADD Makefile /var/doxerlive/ +RUN make install diff --git a/doc/Makefile b/doc/Makefile new file mode 100644 index 0000000..34b9ad6 --- /dev/null +++ b/doc/Makefile @@ -0,0 +1,30 @@ +all: install build + +build: doc.tex + latexmk -c doc.tex + +watch: doc.tex + latexmk -pvc doc.tex + +DEPENDENCIES = luatex $\ + fontspec $\ + lm $\ + xcolor $\ + xcolor-solarized $\ + fontawesome $\ + xifthen $\ + ifmtarg $\ + pgf $\ + pgf-blur $\ + ec $\ + etoolbox $\ + xkeyval $\ + minted kvoptions fancyvrb fvextra upquote float ifplatform pdftexcmds xstring lineno framed catchfile + +install: + tlmgr update --self + tlmgr update texlive-scripts + tlmgr install $(DEPENDENCIES) + +clean: + latexmk -C diff --git a/doc/doc.pdf b/doc/doc.pdf new file mode 100644 index 0000000..5c6f720 Binary files /dev/null and b/doc/doc.pdf differ diff --git a/doc/doc.tex b/doc/doc.tex new file mode 100644 index 0000000..40361ea --- /dev/null +++ b/doc/doc.tex @@ -0,0 +1,191 @@ +\documentclass[12pt, a4paper]{article} +\usepackage[margin=25mm]{geometry} +\usepackage{tikz} +\usepackage[outputdir=target]{minted} + +\usemintedstyle{lovelace} + +\renewcommand\familydefault{\sfdefault} + +\definecolor{spotifygreen}{HTML}{1DB954} + +\usetikzlibrary{calc} +\usetikzlibrary{arrows.meta} +\usetikzlibrary{decorations.pathreplacing} +\usetikzlibrary{shapes} +\usetikzlibrary{positioning} + +\tikzstyle{box}=[fill=black, solid, line width=0mm, text=white, rounded corners=2mm, font={\bfseries}, inner sep=3mm] +\tikzstyle{sizedbox}=[box, text width=3cm, anchor=north, align=center] +\tikzstyle{link}=[->, >=latex, line width=.5mm, rounded corners, shorten >=1pt] +\tikzstyle{smalllink}=[->, >=latex, line width=.2mm, rounded corners, shorten >=1pt, dashed, gray] + + +\begin{document} + +\section{Data flow within Spot} + +\subsection{Overview} + +\paragraph{Single source of truth.} There is a single place that is considered the source of truth for anything that is related to the app state, and that is, well, the \texttt{AppState}. The app state aggregates the state of the UI, as well as the player state. This makes it easier to keep things in sync -- when possible, anything state-related should be read from the app state over some local, possibly out-of-date state. + +\paragraph{Centralized.} That state is centralized and unique. This allows various parts of the application to access any part of it, and conversely makes it easy to perform state updates that affect various and sometimes unrelated parts of the application. + +\paragraph{Controlled mutations.} There is only one way to modify the app state, and that is by dispatching \emph{actions} -- plain structs that represent a mutation to the state. Updates to the state produce \emph{events}, which \texttt{EventListeners} can use to update the UI. + +\begin{figure}[!h] + + \centering + + \begin{tikzpicture} + + \node[box, fill=spotifygreen, minimum height=2cm] (model) at (0, 0) {\ttfamily AppModel}; + \node[box] (ui) at (-4, -3) {Gtk widgets}; + \node[box] (listeners) at (4, -3) {Listeners}; + + \draw[link] (ui) edge[bend left=20] node[above, sloped] {\footnotesize actions} (model); + \draw[link] (model) edge[bend left=10] node[above, sloped] {\footnotesize events} (listeners); + \draw[link] (listeners) edge[bend left=60] node[below, sloped] {\footnotesize update} (ui); + \draw[link, dashed] (listeners) edge[bend left=10] node[below, sloped] {\footnotesize read-only access} (model); + + \end{tikzpicture} + + \caption{The data flow and and its relation to the UI -- the \texttt{AppModel} enforces read-only access to the state.} + \label{fig:flow} + +\end{figure} + +This draws heavy inspiration from the Flux architecture\footnote{See https://facebook.github.io/flux/docs/in-depth-overview for instance}; the one big difference here is that there is no way to automatically find out which portion of the UI should be updated. Instead, listeners are responsible for figuring out the updates to apply based on the events. + +It should be noted that the app state is only readable from the main thread for simplicity. + +\subsection{How actions are handled} + +Here is the relevant part of the code\footnote{Variables have been renamed for clarity...} related to handling actions and notifying listeners: + +\begin{minted}{rust} +let events = self.model.update_state(action); + +for event in events.iter() { + for listener in self.listeners.iter_mut() { + listener.on_event(event); + } +} +\end{minted} + +That first line is the only time that the app state is borrowed mutably -- to apply actions. + +On the technical side: all actions being dispatched, synchronous or not, are eventually sent through a \texttt{futures::channel::mpsc} channel. The consumer on the other end of the channel is a future that will be executed by GLib. This allows Gtk to process \emph{all actions} at its own pace, as part of its main loop. + +Note: futures are used a lot in the code to perform asynchronous operations such as calls to the Spotify API. To ease the use of futures, the dispatcher allows working with asynchronous actions, that is, futures that output one or more actions. Again, these futures are eventually handled in the main Gtk loop. + +\subsection{A listener: the player subsystem} + +Any element that wishes to update the state or react to changes from the state has to follow that same pattern. For instance, the ``player'' part of Spot receives \texttt{Commands} (mapped from events by a \texttt{PlayerNotifier}) to start playing music, and dispatches actions back the app through a \texttt{SpotifyPlayerDelegate} (see figure \ref{fig:player}). + +These two extra elements add some indirection so that the player is not too strongly coupled to the rest of the app (it does not and should not care about most events, afterall!). Moreover, those commands are handled in a separate thread where the player lives. + +\begin{figure}[!ht] + \centering + + \begin{tikzpicture} + + \node[box, fill=spotifygreen, minimum height=2cm] (model) at (0, 0) {\ttfamily AppModel}; + + \draw[smalllink] (-5, -3) node[sizedbox] (listeners) {Components} + -- +(0, -1.5) node[right] {update} + -- +(0, -2) node[sizedbox] (ui) {Gtk widgets}; + + \draw[smalllink] (5, -3) node[sizedbox, fill=gray] (notifier) {\ttfamily PlayerNotifier} + -- +(0, -1.5) node[right] {command} + -- +(0, -2) node[sizedbox, fill=gray] (player) {\ttfamily SpotifyPlayer} + -- +(0, -3.5) node[right] {calls} + -- +(0, -4) node[sizedbox, fill=gray] (delegate) {\parbox{\textwidth}{\ttfamily SpotifyPlayer Delegate}}; + + \draw[link] (ui) edge[bend right=32] node[below, sloped] {\footnotesize actions} (model); + \draw[link] (model) edge[bend right=10] node[above, sloped] {\footnotesize events} (listeners); + + \draw[link] (model) edge[bend left=10] node[above, sloped] {\footnotesize events} (notifier); + \draw[link] (delegate) edge[bend left=32] node[below, sloped] {\footnotesize actions} (model); + + \draw[dashed, gray] ($(notifier.north west) + (-0.25, 0.25)$) rectangle ($(delegate.south east) + (0.25, -0.25)$); + + \draw[dashed, gray] ($(listeners.north west) + (-0.25, 0.25)$) rectangle ($(ui.south east) + (0.25, -0.25)$); + + + \end{tikzpicture} + + \caption{The player subsystem} + \label{fig:player} + +\end{figure} + + +\subsection{Another listener: the MPRIS subsystem} + +Similarly, the MPRIS subsystem follows that same pattern. It spawns a small DBUS server that translates DBUS messages to actions, and an \texttt{AppPlaybackStateListener} listens to incoming events. + +One major difference is that the MPRIS server maintains its own state here, since the app state cannot be accessed from outside the main thread. To make sure this local state stays in sync, DBUS messages should not alter the local state directly -- instead, we should wait for a roundtrip through the app and incoming events. + +\section{Components} + +\subsection{Overview} + +Components are thin wrappers around Gtk widgets, dedicated to binding them so that they produce the right actions, and updating them when specific events occur by conforming to \texttt{EventListener}. + +\subsection{Modeling interactions} + +Components should have some associated \texttt{struct} to model the interactions with the rest of the app. Let's consider the play/pause button as an example. Its behavior is defined in the \texttt{PlaybackModel}: + +\begin{minted}{rust} +impl PlaybackModel { + fn is_playing(&self) -> bool { /**/ } + fn toggle_playback(&self) { /**/ } +} +\end{minted} + +What we need to make our button work is a way to know its current state (is a song playing?) and a way to change that state (toggling on activation). Note that it would be tempting to simply query the widget's state, which \emph{should} be in sync with the actual playback state, but what we should really do instead is query the app state, which is the one source of truth for anything state-related. + +Why do this? First, toggling the playback might fail (e.g. if no song is playing), but more importantly something else could alter the playback state (e.g. a DBUS query). + +\begin{minted}{rust} +fn is_playing(&self) -> bool { + self.app_model.get_state().playback.is_playing() +} +\end{minted} + +As for toggling the playback, remember that we can only mutate the state through actions (the \mintinline{rust}|get_state| call above returns some \mintinline{rust}|Deref|). In other words, we express what kind of action we want to perform, with no guarantee that it'll succeed. + +\begin{minted}{rust} +fn toggle_playback(&self) { + self.dispatcher.dispatch(PlaybackAction::TogglePlay.into()); +} +\end{minted} + +\subsection{Binding the widget} + +All that's left is binding the widget to our model. By wrapping our model in an \texttt{Rc}, it becomes easy to clone it into the kind of \texttt{'static} closure Gtk needs. + +\begin{minted}{rust} +// model is an Rc +widget.connect_play_pause(clone!(@weak model => move || model.toggle_playback())); +\end{minted} + +Finally, we need our component to listen to relevant events, and update our widget accordingly. + +\begin{minted}{rust} +impl EventListener for PlaybackControl { + fn on_event(&mut self, event: &AppEvent) { + match event { + AppEvent::PlaybackEvent(PlaybackEvent::PlaybackPaused) + | AppEvent::PlaybackEvent(PlaybackEvent::PlaybackResumed) => { + let is_playing = self.model.is_playing(); + self.widget.set_playing(is_playing); + } + /**/ + } + } +} +\end{minted} + +\end{document} diff --git a/doc/enter.sh b/doc/enter.sh new file mode 100755 index 0000000..59d5ed0 --- /dev/null +++ b/doc/enter.sh @@ -0,0 +1,3 @@ +#!/bin/sh +docker build --network=host -t spot-doc . +docker run --rm -it -e THEUID="$(id -u "$USER")" -v "$PWD":/var/doxerlive spot-doc ash diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..7f725e5 --- /dev/null +++ b/flake.lock @@ -0,0 +1,112 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1731098351, + "narHash": "sha256-HQkYvKvaLQqNa10KEFGgWHfMAbWBfFp+4cAgkut+NNE=", + "owner": "ipetkov", + "repo": "crane", + "rev": "ef80ead953c1b28316cc3f8613904edc2eb90c28", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1726560853, + "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1731245184, + "narHash": "sha256-vmLS8+x+gHRv1yzj3n+GTAEObwmhxmkkukB2DwtJRdU=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "aebe249544837ce42588aa4b2e7972222ba12e8f", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1728538411, + "narHash": "sha256-f0SBJz1eZ2yOuKUr5CA9BHULGXVSn6miBuUWdTyhUhU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b69de56fac8c2b6f8fd27f2eca01dcda8e0a4221", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "crane": "crane", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1731464916, + "narHash": "sha256-WZ5rpjr/wCt7yBOUsvDE2i22hYz9g8W921jlwVktRQ4=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "2c19bad6e881b5a154cafb7f9106879b5b356d1f", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4892b24 --- /dev/null +++ b/flake.nix @@ -0,0 +1,89 @@ +{ + description = "A GUI for yt-dlp written in Rust"; + + inputs = { + flake-utils.url = "github:numtide/flake-utils"; + nixpkgs.url = "github:nixos/nixpkgs?ref=nixpkgs-unstable"; + rust-overlay.url = "github:oxalica/rust-overlay"; + crane = { + url = "github:ipetkov/crane"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { + self, + nixpkgs, + flake-utils, + crane, + rust-overlay, + ... + }: + flake-utils.lib.eachDefaultSystem (system: + let + craneLib = (crane.mkLib nixpkgs.legacyPackages.${system}); + + pkgs = import nixpkgs { inherit system; overlays = [ rust-overlay.overlays.default ]; }; + + nativeBuildInputs = with pkgs; [ + gettext + meson + ninja + pkg-config + gtk4 # for gtk-update-icon-cache + glib # for glib-compile-schemas + desktop-file-utils + cargo + rustPlatform.cargoSetupHook + rustc + wrapGAppsHook4 + blueprint-compiler + ]; + + buildInputs = with pkgs; [ + glib + gtk4 + libadwaita + libhandy + openssl + alsa-lib + libpulseaudio + gst_all_1.gst-plugins-base + gst_all_1.gstreamer + ]; + + cargoArtifacts = craneLib.buildDepsOnly ({ + src = craneLib.cleanCargoSource (craneLib.path ./.); + inherit buildInputs nativeBuildInputs; + pname = "spot"; + }); + in with pkgs; { + packages = rec { + spot = craneLib.buildPackage { + src = craneLib.path ./.; + + inherit buildInputs nativeBuildInputs cargoArtifacts; + + }; + + default = spot; + }; + + devShells.default = mkShell { + inherit buildInputs nativeBuildInputs; + + packages = with pkgs; [ + (rust-bin.stable.latest.default.override { + extensions = [ "rust-src" "rust-analyzer" ]; + }) + meson + ninja + cargo + ]; + }; + }) // { + overlays.default = final: prev: { + inherit (self.packages.${final.system}) spot; + }; + }; +} diff --git a/meson.build b/meson.build new file mode 100644 index 0000000..2d4e994 --- /dev/null +++ b/meson.build @@ -0,0 +1,25 @@ +project( + 'spot', + version: '0.5.0', + meson_version: '>= 0.59.0', + default_options: ['warning_level=2', 'buildtype=release'], +) + + +subdir('data') +subdir('po') +subdir('src') + +flatpak_cargo_generator = find_program(meson.project_source_root() / 'build-aux/flatpak-cargo-generator.py') + +cargo_sources = custom_target( + 'cargo-update-sources', + build_by_default: false, + output: 'cargo-sources.json', + input: meson.project_source_root() / 'Cargo.lock', + command: [ + flatpak_cargo_generator, + '@INPUT@', + '-o', '@SOURCE_ROOT@/cargo-sources.json' + ] +) diff --git a/meson_options.txt b/meson_options.txt new file mode 100644 index 0000000..6ecf18f --- /dev/null +++ b/meson_options.txt @@ -0,0 +1,2 @@ +option('offline', type: 'boolean', value: true) +option('features', type: 'string', value: '') diff --git a/po/LINGUAS b/po/LINGUAS new file mode 100644 index 0000000..c687aff --- /dev/null +++ b/po/LINGUAS @@ -0,0 +1 @@ +en ru fr nl de ca cs pl es pt tr id pt-br fi eu ia nb sl ja ar it uk bn bg et diff --git a/po/POTFILES b/po/POTFILES new file mode 100644 index 0000000..88ec00a --- /dev/null +++ b/po/POTFILES @@ -0,0 +1,47 @@ +# grep gettext src/**/*.rs | cut -d: -f1 | uniq +src/app/batch_loader.rs +src/app/components/device_selector/widget.rs +src/app/components/labels.rs +src/app/components/login/login_model.rs +src/app/components/mod.rs +src/app/components/navigation/factory.rs +src/app/components/notification/mod.rs +src/app/components/playback/playback_controls.rs +src/app/components/playback/playback_info.rs +src/app/components/selection/component.rs +src/app/components/sidebar/sidebar_item.rs +src/app/components/sidebar/sidebar.rs +src/app/components/user_menu/user_menu.rs +src/app/state/login_state.rs +src/connect/player.rs +src/main.rs + +# find src -name "*.blp" -print +src/window.blp +src/app/components/saved_playlists/saved_playlists.blp +src/app/components/artist_details/artist_details.blp +src/app/components/saved_tracks/saved_tracks.blp +src/app/components/search/search.blp +src/app/components/settings/settings.blp +src/app/components/artist/artist.blp +src/app/components/user_details/user_details.blp +src/app/components/selection/selection_toolbar.blp +src/app/components/scrolling_header/scrolling_header.blp +src/app/components/details/album_header.blp +src/app/components/details/release_details.blp +src/app/components/details/details.blp +src/app/components/now_playing/now_playing.blp +src/app/components/login/login.blp +src/app/components/playlist_details/playlist_details.blp +src/app/components/playlist_details/playlist_header.blp +src/app/components/playlist_details/playlist_headerbar.blp +src/app/components/headerbar/headerbar.blp +src/app/components/device_selector/device_selector.blp +src/app/components/sidebar/create_playlist.blp +src/app/components/sidebar/sidebar_row.blp +src/app/components/album/album.blp +src/app/components/playlist/song.blp +src/app/components/playback/playback_widget.blp +src/app/components/playback/playback_info.blp +src/app/components/playback/playback_controls.blp +src/app/components/library/library.blp diff --git a/po/ar.po b/po/ar.po new file mode 100644 index 0000000..98e1763 --- /dev/null +++ b/po/ar.po @@ -0,0 +1,393 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: ar\n" +"Plural-Forms: nplurals=6; plural= n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5;\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "شاهد ألبوم" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "نسخ الرابط" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "أضف إلى الصف" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "أحدف من الصف" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "أضف إلى {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "لايمكن حفظ كلمة المرور. تحقق من أن ال Session Keyring مفتوح." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "حدث خطأ. تحقق من الlog لمزيد التفاصيل!" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "مكتبة" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "قائمة أغاني" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "يشتغل الأن" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "لا توجد أغنية طور التشغيل" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "عن" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "غادر" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "تسجيل الخروج" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "إعادة الاتصال" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "أفضل الأغاني" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "أصدارات" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "قم بتسجيل الدخول إلى Spotify Premium" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "أسم المستخدم" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "كلمة السر" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "فشل عملىة التحقق!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "تسجيل دخول" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "ألبومات" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "الفنانين" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "أغاني محفوظة" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "أوقف" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "شغل" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "عشوائي" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "سابق" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "التالي" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "كرر" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "ليس لديك قوائم تشغيل محفوظة." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "قائمات التشغيل خاصتك ستظهر هنا." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "ليس لديك ألبومات مسجلة." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "المكتبة خاصتك ستظهر هنا." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} تم تحديد صفر أغنية" +msgstr[1] "{} تم تحديد أغنية واحدة" +msgstr[2] "{} تم تحديد أغنيتان" +msgstr[3] "{} تم تحديد بعض الأغاني" +msgstr[4] "{} تم تحديد عديد الأغاني" +msgstr[5] "{} الأغاني الأخرى المحددة" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "أبحث في Spotify." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "أكتب للبجث." + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "أحدف" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "أضف إلى قائمة التشغيل..." + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "ألغاء" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "اختر الكل" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "{} المزيد من" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "الناشر" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "أطلقت" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "الأغاني" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "المدة" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "حقوق النشر" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{}بواسطة {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "" + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "" + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "" + diff --git a/po/bg.po b/po/bg.po new file mode 100644 index 0000000..ac8ad69 --- /dev/null +++ b/po/bg.po @@ -0,0 +1,389 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: bg\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "" + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "" + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "" + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "" + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "" + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "" +msgstr[1] "" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "" + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "" + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "" + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "" + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "" + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "" + diff --git a/po/bn.po b/po/bn.po new file mode 100644 index 0000000..97735cb --- /dev/null +++ b/po/bn.po @@ -0,0 +1,438 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: bn\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +#, fuzzy +msgid "View album" +msgstr "অ্যালবাম দেখুন" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +#, fuzzy +msgid "Copy link" +msgstr "লিংক কপি করুন" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +#, fuzzy +msgid "Add to queue" +msgstr "সারিতে যোগ করুন" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +#, fuzzy +msgid "Remove from queue" +msgstr "সারি থেকে সরান" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +#, fuzzy +msgid "Add to {}" +msgstr "{} তে যোগ করুন" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +#, fuzzy +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "পাসওয়ার্ড সেভ করা যায়নি। সেশন কীরিং আনলক করা আছে কিনা নিশ্চিত করুন।" + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +#, fuzzy +msgid "An error occured. Check logs for details!" +msgstr "একটি ত্রুটি ঘটেছে। বিস্তারিত জানতে লগ চেক করুন!" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +#, fuzzy +msgid "Library" +msgstr "লাইব্রেরী" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +#, fuzzy +msgid "Playlists" +msgstr "প্লেলিস্ট" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +#, fuzzy +msgid "Now playing" +msgstr "এখন প্লে হচ্ছে" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +#, fuzzy +msgid "No song playing" +msgstr "কোনো গান প্লে হচ্ছেনা" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +#, fuzzy +msgid "About" +msgstr "অ্যাবাউট" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +#, fuzzy +msgid "Quit" +msgstr "প্রস্থান করুন" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +#, fuzzy +msgid "Log out" +msgstr "লগ আউট" + +#: src/app/state/login_state.rs:115 +#, fuzzy +msgid "Connection restored" +msgstr "সংযোগ পুনরুদ্ধার করা হয়েছে" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +#, fuzzy +msgid "Top tracks" +msgstr "টপ ট্র্যাকগুলি" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +#, fuzzy +msgid "Releases" +msgstr "রিলিজ" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +#, fuzzy +msgid "Login to Spotify Premium" +msgstr "স্পটিফাই প্রিমিয়ামে লগইন করুন" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +#, fuzzy +msgid "Username" +msgstr "ইউজারনেম" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +#, fuzzy +msgid "Password" +msgstr "পাসওয়ার্ড" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +#, fuzzy +msgid "Authentication failed!" +msgstr "অথেন্টিকেশন ব্যর্থ হয়েছে!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +#, fuzzy +msgid "Log in" +msgstr "লগ ইন" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +#, fuzzy +msgid "Albums" +msgstr "অ্যালবাম" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +#, fuzzy +msgid "Artists" +msgstr "আর্টিস্ট" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +#, fuzzy +msgid "Saved tracks" +msgstr "সেভ করা ট্র্যাকগুলি" + +#: src/app/components/playback/playback_controls.rs:64 +#, fuzzy +msgid "Pause" +msgstr "পজ" + +#: src/app/components/playback/playback_controls.rs:66 +#, fuzzy +msgid "Play" +msgstr "প্লে" + +#: src/app/components/playback/playback_controls.blp:15 +#, fuzzy +msgid "Shuffle" +msgstr "শাফল" + +#: src/app/components/playback/playback_controls.blp:24 +#, fuzzy +msgid "Previous" +msgstr "পূর্ববর্তী" + +#: src/app/components/playback/playback_controls.blp:46 +#, fuzzy +msgid "Next" +msgstr "পরবর্তী" + +#: src/app/components/playback/playback_controls.blp:55 +#, fuzzy +msgid "Repeat" +msgstr "রিপিট" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +#, fuzzy +msgid "You have no saved playlists." +msgstr "আপনার কোনো সেভ করা প্লেলিস্ট নেই।" + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +#, fuzzy +msgid "Your playlists will be shown here." +msgstr "আপনার প্লেলিস্ট এখানে দেখানো হবে।" + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +#, fuzzy +msgid "You have no saved albums." +msgstr "আপনার কোনো সেভ করা অ্যালবাম নেই।" + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +#, fuzzy +msgid "Your library will be shown here." +msgstr "আপনার লাইব্রেরী এখানে দেখানো হবে।" + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +#, fuzzy +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} টি গান সিলেক্ট করা হয়েছে" +msgstr[1] "{} টি গান সিলেক্ট করা হয়েছে" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +#, fuzzy +msgid "Search Spotify." +msgstr "স্পটিফাইয়ে খুঁজুন।" + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +#, fuzzy +msgid "Type to search." +msgstr "খোঁজার জন্য এখানে টাইপ করুন।" + +#: src/app/components/selection/selection_toolbar.blp:61 +#, fuzzy +msgid "Remove" +msgstr "রিমুভ করুন" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +#, fuzzy +msgid "Add to playlist..." +msgstr "প্লেলিস্টে যোগ করুন..." + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +#, fuzzy +msgid "Cancel" +msgstr "বাতিল করুন" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +#, fuzzy +msgid "Select all" +msgstr "সবকিছু সিলেক্ট করুন" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +#, fuzzy +msgid "More from {}" +msgstr "{} -র থেকে আরও" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +#, fuzzy +msgid "Label" +msgstr "লেবেল" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +#, fuzzy +msgid "Released" +msgstr "রিলিজ হয়েছে" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +#, fuzzy +msgid "Tracks" +msgstr "ট্র্যাক গুলি" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +#, fuzzy +msgid "Duration" +msgstr "সময়" + +#: src/app/components/details/release_details.blp:72 +#, fuzzy +msgid "Copyright" +msgstr "কপিরাইট" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +#, fuzzy +msgid "{} by {}" +msgstr "{} বাই {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "" + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "" + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "" + diff --git a/po/ca.po b/po/ca.po new file mode 100644 index 0000000..6566660 --- /dev/null +++ b/po/ca.po @@ -0,0 +1,391 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: ca\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Mostra l'àlbum" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Copia l'enllaç" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.ui:39 +msgid "Add to queue" +msgstr "Afegeix a la cua" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Suprimeix de la cua" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Afegeix a {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:56 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "No s'ha pogut desar la sessió. Assegura't que l'anell de claus de la sessió està desbloquejat." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:128 +msgid "An error occured. Check logs for details!" +msgstr "Ha ocorregut un error. Comprova els registres per més detalls!" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Biblioteca" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Llistes de reproducció" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "S'està reproduïnt" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.ui:32 +msgid "No song playing" +msgstr "Cap cançó en reproducció" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "Quant a" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Surt" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Tanca la sessió" + +#: src/app/state/login_state.rs:112 +msgid "Connection restored" +msgstr "Connexió restablerta" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.ui:26 +msgid "Top tracks" +msgstr "Millors pistes" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.ui:53 +msgid "Releases" +msgstr "Llançaments" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.ui:45 +msgid "Login to Spotify Premium" +msgstr "Inicia la sessió a Spotify Premium" + +#. Placeholder for the username field +#: src/app/components/login/login.ui:72 +msgid "Username" +msgstr "Nom d'usuari" + +#. Placeholder for the password field +#: src/app/components/login/login.ui:89 +msgid "Password" +msgstr "Contrasenya" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.ui:114 +msgid "Authentication failed!" +msgstr "L'autenticació ha fallat!" + +#. Log in button label +#: src/app/components/login/login.ui:129 +msgid "Log in" +msgstr "Inicia sessió" + +#. This is the title of a section of the search results +#: src/app/components/search/search.ui:72 +msgid "Albums" +msgstr "Àlbums" + +#. This is the title of a section of the search results +#: src/app/components/search/search.ui:105 +msgid "Artists" +msgstr "Artistes" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Pistes desades" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "Pausa" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "Reprodueix" + +#: src/app/components/playback/playback_controls.ui:17 +msgid "Shuffle" +msgstr "Barreja" + +#: src/app/components/playback/playback_controls.ui:27 +msgid "Previous" +msgstr "Anterior" + +#: src/app/components/playback/playback_controls.ui:50 +msgid "Next" +msgstr "Següent" + +#: src/app/components/playback/playback_controls.ui:60 +msgid "Repeat" +msgstr "Repeteix" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.ui:26 +msgid "You have no saved playlists." +msgstr "No teniu llistes de reproducció desades." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.ui:27 +msgid "Your playlists will be shown here." +msgstr "Les vostres llistes de reproducció es mostraran aquí." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.ui:26 +msgid "You have no saved albums." +msgstr "No teniu àlbums desats." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.ui:27 +msgid "Your library will be shown here." +msgstr "Aquí es mostrarà la vostra biblioteca." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} cançó seleccionada" +msgstr[1] "{} cançons seleccionades" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.ui:116 +msgid "Search Spotify." +msgstr "Cerca a Spotify." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.ui:117 +msgid "Type to search." +msgstr "Escriu per començar a buscar." + +#: src/app/components/selection/selection_toolbar.ui:69 +msgid "Remove" +msgstr "Elimina" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.ui:56 +msgid "Add to playlist..." +msgstr "Afegeix a la llista..." + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.ui:50 +#: src/app/components/headerbar/headerbar.ui:47 +msgid "Cancel" +msgstr "Cancel·la" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.ui:58 +msgid "Select all" +msgstr "Selecciona-ho tot" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Més de {}" + +#. This refers to a music label +#: src/app/components/details/release_details.ui:38 +msgid "Label" +msgstr "Etiqueta" + +#. This refers to a release date +#: src/app/components/details/release_details.ui:48 +msgid "Released" +msgstr "Llançaments" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.ui:58 +msgid "Tracks" +msgstr "Millors pistes" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Duració" + +#: src/app/components/details/release_details.ui:68 +msgid "Copyright" +msgstr "Copyright" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{} per {}" + +#: src/app/components/sidebar/sidebar.rs:48 +msgid "Unnamed playlist" +msgstr "Llista sense nom" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "Totes les llistes" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "Cançons desades!" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "Preferències" + +#: src/main.rs:80 +msgid "Failed to open link!" +msgstr "No s'ha pogut obrir l'enllaç!" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.ui:13 +msgid "Audio" +msgstr "Àudio" + +#. Title for an item in preferences +#: src/app/components/settings/settings.ui:16 +msgid "Audio Backend" +msgstr "Sistema d'àudio" + +#. Title for an item in preferences +#: src/app/components/settings/settings.ui:29 +msgid "ALSA Device" +msgstr "Dispositiu ALSA" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.ui:30 +msgid "Applied only if audio backend is ALSA" +msgstr "Usat només si el sistema d'àudio és l'ALSA" + +#. Title for an item in preferences +#: src/app/components/settings/settings.ui:40 +msgid "Audio Quality" +msgstr "Qualitat de l'àudio" + +#: src/app/components/settings/settings.ui:44 +msgid "Normal" +msgstr "Normal" + +#. Qualitat d'àudio -> femení, la qualitat +#: src/app/components/settings/settings.ui:45 +msgid "High" +msgstr "Alta" + +#. El mateix que amb "High". Cal usar el femení +#: src/app/components/settings/settings.ui:46 +msgid "Very high" +msgstr "Molt alta" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.ui:70 +msgid "Appearance" +msgstr "Aparença" + +#. Title for an item in preferences +#: src/app/components/settings/settings.ui:73 +msgid "Theme" +msgstr "Tema" + +#: src/app/components/settings/settings.ui:77 +msgid "Light" +msgstr "Clar" + +#: src/app/components/settings/settings.ui:78 +msgid "Dark" +msgstr "Fosc" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.ui:89 +msgid "Network" +msgstr "Xarxa" + +#. Title for an item in preferences +#: src/app/components/settings/settings.ui:92 +msgid "Access Point Port" +msgstr "Port del punt d'accés" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.ui:93 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "El port usat per les connexions al punt d'accés de l'Spotify. Establiu-ho a 0 si qualsevol port és correcte." + +#: src/app/components/selection/selection_toolbar.ui:90 +msgid "Save to library" +msgstr "Desa-ho a la llibreria" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "" + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.ui:54 +msgid "Gapless playback" +msgstr "" + +#: src/app/components/settings/settings.ui:79 +msgid "System" +msgstr "" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.ui:63 +msgid "Done" +msgstr "" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.ui:10 +msgid "Name" +msgstr "" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.ui:33 +msgid "Create" +msgstr "" + diff --git a/po/cs.po b/po/cs.po new file mode 100644 index 0000000..bbcfd43 --- /dev/null +++ b/po/cs.po @@ -0,0 +1,393 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: cs\n" +"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Zobrazit album" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Zkopírovat odkaz" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Přidat do fronty" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Odebrat z fronty" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Přidat do {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Heslo nelze uložit. Zkontrolujte, že je klíčenka (session keyring) odemčená." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "Došlo k chybě. Podrobnosti najdete v protokolu." + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Knihovna" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Playlisty" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "Právě hraje" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Nic nehraje" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "O aplikaci" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Konec" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Odhlásit se" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "Připojení obnoveno" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Populární" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Diskografie" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Přihlásit se ke Spotify Premium" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Uživatelské jméno" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Heslo" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "Chyba autentizace!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Přihlásit se" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Alba" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Umělci" + +#. Where is this text displayed? +#. I see it is supposed to be in `src/app/components/navigation/home.rs:47`, but that does not seem to be the case: https://github.com/xou816/spot/blob/1b96cd07129f27c7f3f3a2e66299c44a52e489d4/src/app/components/navigation/home.rs +#. Maybe it is something that has not been commited yet (2021-10-02T10:42:52+02:00), but @xou816 has it locally and has generated pot / po files from it in https://github.com/xou816/spot/commit/86414c03d207cdf4e2c1cbddd47c7ca5629d1303 +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Uložené skladby" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "Pozastavit" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "Přehrát" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "Náhodné přehrávání" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "Předchozí" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "Následující" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "Opakovat" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "Nemáte žádné uložené playlisty." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "Tady budou zobrazeny vaše playlisty." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "Nemáte žádná uložená alba." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "Tady bude zobrazena vaše knihovna." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} skladba vybrána" +msgstr[1] "{} skladby vybrány" +msgstr[2] "{} skladeb vybráno" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "Hledat na Spotify." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "Zadejte a vyhledejte." + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "Odstranit" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "Přidat do playlistu..." + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "Zrušit" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "Vybrat vše" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Více od {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Vydavatelství" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Vydáno" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Počet skladeb" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Délka" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Copyright" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{} od {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "Playlist bez názvu" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "Všechny playlisty" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "Skladby uloženy!" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "Nastavení" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "Nepodařilo se otevřít odkaz!" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "Zvuk" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "Zvukový backend" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "ALSA zařízení" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "Použito pouze pokud zvukový backend je ALSA" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "Kvalita zvuku" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "Normální" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "Vysoká" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "Velmi vysoká" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "Vzhled" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "Téma" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "Světlé" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "Tmavé" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "Síť" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "Port Access Pointu" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "Port používaný pro připojení ke Spotify Access Point. Nastavte na 0 pokud lze použít libovolný port." + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "Uložit do knihovny" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "Playlist byl vytvořen." + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "Zobrazit" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "Přidat playlist" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "Přehrávání bez mezer" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "Systém" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "Hotovo" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "Název" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "Vytvořit" + diff --git a/po/de.po b/po/de.po new file mode 100644 index 0000000..2478b8f --- /dev/null +++ b/po/de.po @@ -0,0 +1,389 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: de\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Album ansehen" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Link kopieren" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Zur Warteschlange hinzufügen" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Von Warteschlange entfernen" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Zu {} hinzufügen" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Das Passwort konnte nicht gespeichert werden. Stellen Sie sicher, dass der Sitzungs-Schlüsselbund entsperrt ist." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "Es ist ein Fehler aufgetreten. Überprüfen Sie die Protokolle für Details!" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Bibliothek" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Playlists" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "Wiedergabe" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Keine Wiedergabe" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "Info" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Beenden" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Abmelden" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "Verbindung wiederhergestellt" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Beliebte Titel" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Diskografie" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Bei Spotify Premium anmelden" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Benutzername" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Passwort" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "Anmeldung fehlgeschlagen!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Anmelden" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Alben" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Künstler" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Gespeicherte Titel" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "Pause" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "Play" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "Shuffle" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "Zurück" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "Weiter" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "Wiederholen" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "Keine gespeicherten Playlists vorhanden." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "Playlists werden hier angezeigt." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "Keine gespeicherten Alben vorhanden." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "Die Bibliothek wird hier angezeigt." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} Titel ausgewählt" +msgstr[1] "{} Titel ausgewählt" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "Auf Spotify suchen." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "Tippe um zu suchen." + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "Entfernen" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "Zur Playlist hinzufügen..." + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "Abbrechen" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "Alle auswählen" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Mehr von {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Label" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Veröffentlicht" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Titel" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Dauer" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Copyright" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{} von {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "Unbenannte Playlist" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "Alle Playlists" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "Titel gespeichert!" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "Einstellungen" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "Öffnen des Links fehlgeschlagen!" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "Audio" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "Audio Backend" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "ALSA Gerät" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "Wird nur angewendet, wenn das Audio-Backend ALSA ist" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "Audioqualität" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "Normal" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "Hoch" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "Sehr hoch" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "Aussehen" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "Thema" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "Hell" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "Dunkel" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "Netzwerk" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "Access Point Port" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "Port, der für Verbindungen mit dem Spotify Access Point verwendet wird. Setze den Wert auf 0 für einen beliebigen Port." + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "In Bibliothek speichern" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "Neue Playlist erstellt." + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "Anzeigen" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "Neue Playlist" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "Lückenlose Wiedergabe" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "System" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "Fertig" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "Name" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "Erstellen" + diff --git a/po/en.po b/po/en.po new file mode 100644 index 0000000..f74849d --- /dev/null +++ b/po/en.po @@ -0,0 +1,389 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: en\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "View album" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Copy link" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Add to queue" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Remove from queue" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Add to {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Could not save password. Make sure the session keyring is unlocked." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "An error occured. Check logs for details!" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Library" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Playlists" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "Now playing" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "No song playing" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "About" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Quit" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Log out" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "Connection restored" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Top tracks" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Releases" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Login to Spotify Premium" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Username" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Password" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "Authentication failed!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Log in" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Albums" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Artists" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Saved Tracks" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "Pause" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "Play" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "Shuffle" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "Previous" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "Next" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "Repeat" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "You have no saved playlists." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "Your playlists will be shown here." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "You have no saved albums." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "Your library will be shown here." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} song selected" +msgstr[1] "{} songs selected" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "Search Spotify." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "Type to search." + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "Remove" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "Add to playlist..." + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "Cancel" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "Select all" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "More from {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Label" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Released" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Tracks" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Duration" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Copyright" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{} by {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "Unnamed playlist" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "" + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "" + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "" + diff --git a/po/es.po b/po/es.po new file mode 100644 index 0000000..8503368 --- /dev/null +++ b/po/es.po @@ -0,0 +1,389 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: es\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Ver álbum" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Copiar enlace" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Añadir a la cola" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Borrar de la lista" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Añadir a {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "No se pudo guardar la contraseña. Asegúrate de que el anillo de claves esta desbloqueado." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "Ha ocurrido un error. ¡Revisa los logs para más detalles!" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Biblioteca" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Lista de reproducción" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "Reproduciendo ahora" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Ninguna canción en reproducción" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "Acerca de" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Salir" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Cerrar sesión" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "Conexión restaurada" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Mejores canciones" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Lanzamientos" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Iniciar sesión en Spotify Premium" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Usuario" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Contraseña" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "¡Autenticación fallida!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Iniciar sesión" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Álbumes" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Artistas" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Pistas guardadas" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "Pausa" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "Reproducir" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "Activar aleatorio" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "Anterior" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "Siguiente" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "Activar repetir" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "No tienes listas de reproducción guardadas." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "Tus listas de reproducción se mostrarán aquí." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "No tienes álbumes guardados." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "Tu biblioteca se mostrará aquí." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} canción seleccionada" +msgstr[1] "{} canciones seleccionadas" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "Buscar en Spotify." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "Escribe para buscar." + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "Retirar" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "Añadir a la lista de reproducción..." + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "Cancelar" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "Seleccionar todo" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Más de {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Etiqueta" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Lanzado" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Canciones" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Duración" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Copyright" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{} de {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "Lista de reproducción sin nombre" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "Todas las listas de reproducción" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "¡Pistas guardadas!" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "Preferencias" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "¡No se ha podido abrir el enlace!" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "Sonido" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "Backend de audio" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "Dispositivo ALSA" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "Sólo se aplica si el backend de audio es ALSA" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "Calidad de sonido" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "Normal" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "Alta" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "Muy alta" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "Apariencia" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "Tema" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "Claro" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "Oscuro" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "Red" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "Puerto del punto de acceso" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "Puerto utilizado para las conexiones con el punto de acceso de Spotify. Poner a 0 si cualquier puerto está bien." + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "Guardar en la biblioteca" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "" + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "" + diff --git a/po/et.po b/po/et.po new file mode 100644 index 0000000..7c128a7 --- /dev/null +++ b/po/et.po @@ -0,0 +1,389 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: et\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Vaata albumit" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Kopeeri link" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Lisa järjekorda" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Eemalda järjekorrast" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Lisa {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Salasõna salvestamine ebaõnnestus. Kindel, et sessiooni võtmering on lukust lahti?" + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "Tekkis viga. Rohkem infot logides." + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Teek" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Esitusloendid" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "Praegu mängib" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Ükski laul ei mängi" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "Teave" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Sulge" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Logi välja" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "Ühendus taastatud" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Populaarsemad lood" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Diskograafia" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Logi sisse Spotify Premiumisse" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Kasutajanimi" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Salasõna" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "Autentimine ebaõnnestus!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Logi sisse" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Albumid" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Esitajad" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Salvestatud lood" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "Peata" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "Esita" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "Juhuesitus" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "Eelmine" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "Järgmine" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "Korda" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "Sul pole ühtegi salvestatud esitusloendit." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "Sinu esitusloendid näidatakse siin." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "Sul pole ühtegi salvestatud albumit." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "Sinu teeki näidatakse siin." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} lugu valitud" +msgstr[1] "{} lugu valitud" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "Otsi Spotify'st." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "Tipi, et otsida." + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "Eemalda" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "Lisa esitusloendisse..." + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "Loobu" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "Vali kõik" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Rohkem esitajast {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Väljaandja" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Välja antud" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Lood" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Pikkus" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Autoriõigused" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{} - {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "Nimetamata esitusloend" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "Kõik esitusloendid" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "Lugu salvestatud!" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "Eelistused" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "Lingi avamine ebaõnnestus!" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "Heli" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "Heli sisend" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "ALSA heliseade" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "Kehtib ainult, kui heli sisend on ALSA" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "Heli kvaliteet" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "Keskmine" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "Kõrge" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "Väga kõrge" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "Välimus" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "Teema" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "Hele teema" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "Tume teema" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "Võrk" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "Sissepääsu port" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "Port Spotify ühendamiseks. Säti 0, et lubada iga port." + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "Salvesta teeki" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "" + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "" + diff --git a/po/eu.po b/po/eu.po new file mode 100644 index 0000000..ca66aa7 --- /dev/null +++ b/po/eu.po @@ -0,0 +1,389 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: eu\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Albuma ikusi" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Kopiatu esteka" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Gehitu isatsari" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Ezabatu isatsetik" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Gehitu {} (r)i" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Ezin izan da pasahitza gorde. Ziurtatu saioko giltzatakoa desblokeatuta dagoela." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "Errore bat gertatu da. Egiaztatu xehetasunak erregistroetan!" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Biblioteka" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Erreprodukzio-zerrendak" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "Orain erreproduzitzen" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Ez da abestirik erreproduzitzen ari" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "Honi buruz" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Utzi" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Saioa itxi" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "Konexioa leheneratu da" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Pista nagusiak" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Jaurtiketak" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Saioa hasi Spotify Premiumen" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Erabiltzailearen izena" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Pasahitza" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "Autentifikazioak huts egin du!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Hasi saioa" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Albumak" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Artistak" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Gordetako pistak" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "Gelditu" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "Erreproduzitu" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "Nahasketa" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "Aurrekoa" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "Hurrengoa" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "Errepikatu" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "Ez duzu erreprodukzio zerrendarik gordeta." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "Zure erreprodukzio zerrendak hemen agertuko dira." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "Ez duzu albumik gordeta." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "Zure liburutegia hemen agertuko da." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} Aukeratutako abesti bat" +msgstr[1] "{} Aukeratutako abestiak" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "Bilatu Spotifyn." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "Idatzi bilatzeko." + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "Ezabatu" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "Erreprodukzio-zerrendara gehitu..." + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "Ezeztatu" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "Hautatu dena" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Gehiago honi buruz {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Etiketa" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Argitaratua" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Pistak" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Iraupena" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Egile-eskubideak" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{} {} -ek eginda" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "" + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "" + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "" + diff --git a/po/fi.po b/po/fi.po new file mode 100644 index 0000000..cbc9334 --- /dev/null +++ b/po/fi.po @@ -0,0 +1,389 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: fi\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Näytä albumi" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Kopioi linkki" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Lisää jonoon" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Poista jonosta" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Lisää kohteeseen {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Salasanaa ei voitu tallentaa. Varmista, että avainnippua ei ole lukittu." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "Tapahtui virhe. Katso lisätiedot lokista!" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Kirjasto" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Soittolistat" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "Nyt toistetaan" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Ei toistettavaa kappaletta" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "Tietoja" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Lopeta" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Kirjaudu ulos" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "Yhteys palautunut" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Suosituimmat kappaleet" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Julkaisut" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Kirjaudu Spotify Premiumiin" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Käyttäjätunnus" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Salasana" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "Tunnistautuminen epäonnistui!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Kirjaudu sisään" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Albumit" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Esittäjät" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Tallennetut kappaleet" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "Keskeytä" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "Toista" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "Sekoita" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "Edellinen" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "Seuraava" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "Kertaa" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "Ei tallennettuja soittolistoja." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "Soittolistasi näytetään täällä." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "Ei tallennettuja albumeja." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "Kirjastosi näytetään täällä." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} kappale valittu" +msgstr[1] "{} kappaletta valittu" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "Etsi Spotifysta." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "Kirjoita etsiäksesi." + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "Poista" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "Lisää soittolistaan..." + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "Peru" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "Valitse kaikki" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Lisää esittäjältä {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Levy-yhtiö" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Julkaistu" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Kappaleita" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Kesto" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Tekijänoikeus" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{} tehnyt {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "Nimetön soittolista" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "Kaikki soittolistat" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "Kappaleet tallennettu!" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "Asetukset" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "Linkin avaaminen epäonnistui!" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "Ääni" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "Äänen taustaosa" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "ALSA-laite" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "Sovelletaan vain jos äänen taustaosa on ALSA" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "Äänenlaatu" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "Normaali" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "Korkea" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "Hyvin korkea" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "Ulkoasu" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "Teema" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "Vaalea" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "Tumma" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "Verkko" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "Access Pointin portti" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "Spotifyn Access Pointiin tuleviin yhteyksiin käytettävä portti. Aseta arvoksi 0, jos mikä tahansa portti kelpaa." + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "Tallenna kirjastoon" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "Uusi soittolista luotu." + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "Näytä" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "Uusi soittolista" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "Keskeytyksetön toisto" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "Järjestelmä" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "Valmis" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "Nimi" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "Luo" + diff --git a/po/fr.po b/po/fr.po new file mode 100644 index 0000000..0b0c8a7 --- /dev/null +++ b/po/fr.po @@ -0,0 +1,389 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Voir l'album" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Copier le lien" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Ajouter à la file d'attente" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Retirer de la file d'attente" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Ajouter à {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Le mot de passe n'a pu être enregistré, assurez-vous que le Trousseau de session est déverouillé." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "Une erreur est survenue. Consultez les journaux de débogage pour plus d'information." + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Bibliothèque" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Listes de lecture" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "En cours de lecture" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Aucune lecture en cours" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "À propos" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Quitter" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Déconnexion" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "Connexion rétablie" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Morceaux populaires" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Discographie" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Connexion à Spotify Premium" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Nom d'utilisateur" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Mot de passe" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "L'authentification a échoué !" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Connexion" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Albums" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Artistes" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Titres aimés" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "Pause" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "Lecture" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "Aléatoire" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "Précédent" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "Suivant" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "Répéter" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "Aucune liste de lecture enregistrée." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "Vos listes de lectures apparaîtrons ici." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "Aucun album dans votre bibliothèque." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "Vos albums apparaîtrons ici." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} morceau sélectionné" +msgstr[1] "{} morceaux sélectionnés" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "Rechercher dans Spotify." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "Saisissez un terme à rechercher." + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "Retirer" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "Ajouter à..." + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "Annuler" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "Tout sélectionner" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Plus de {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Label" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Date de publication" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Morceaux" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Durée" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Copyright" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{} de {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "Liste de lecture sans titre" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "Toutes les listes de lecture" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "Pistes enregistrées !" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "Préférences" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "Échec de l'ouverture du lien." + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "Audio" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "Gestionnaire de l'audio" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "Appareil ALSA" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "Ne s'applique que si ALSA est sélectionné" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "Qualité du flux audio" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "Normale" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "Élevée" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "Très élevée" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "Apparence" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "Thème" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "Clair" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "Sombre" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "Réseau" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "Port du point d'accès" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "Port utilisé pour se connecter au point d'accès de Spotify. Laisser la valeur à 0 pour gérer ce paramètre automatiquement. " + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "Enregistrer dans la bibliothèque" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "Liste de lecture créée." + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "Voir" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "Nouvelle liste de lecture" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "Lecture continue" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "Valeur système" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "Terminé" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "Nom" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "Créer" + diff --git a/po/ia.po b/po/ia.po new file mode 100644 index 0000000..122f5ef --- /dev/null +++ b/po/ia.po @@ -0,0 +1,389 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: ia\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Vider album" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Copiar ligamine" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Adder al cauda" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Remover ab cauda" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Adder a {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Non poteva salvar le contrasigno. Assecura te qui le magazin de claves es disblocate." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "Ocurreva un error. Verifica le registros pro detalios!" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Bibliotheca" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Listas de reproduction" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "Ora in reproduction" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Nulle canto in reproduction" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "A proposito de" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Quitar" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Clauder le session" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "Connexion restabilite" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Melior tracias" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Lanciamentos" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Initiar session in Spotify Premium" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Nomine de usator" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Contrasigno" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "Falleva le authentication!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Initiar session" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Albumes" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Artistas" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Tracias salvate" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "Pausar" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "Reproducer" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "Aleatori" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "Previe" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "Sequente" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "Repeter" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "Tu non ha listas de reproduction salvate." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "Tu listas de reproduction essera monstrate hic." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "Tu ha nulle albumes salvate." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "Tu bibliotheca essera monstrate ci." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} canto seligite" +msgstr[1] "{} cantos seligite" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "Cercar Spotify." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "Scribe pro cercar." + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "Remover" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "Adder al lista de reproduction..." + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "Cancellar" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "Seliger toto" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Plus ab {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Sigillo" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Lanciate" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Tracias" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Duration" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Copyright" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{} per {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "Lista de reproduction sin nomine" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "Tote le listas de reproduction" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "Tracias salvate!" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "Preferentias" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "Falleva in aperir ligamine!" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "Apparentia" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "Thema" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "Clar" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "Obscur" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "Rete" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "" + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "" + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "" + diff --git a/po/id.po b/po/id.po new file mode 100644 index 0000000..fc6c9bd --- /dev/null +++ b/po/id.po @@ -0,0 +1,388 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: id\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Lihat album" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Salin tautan" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Tambah ke Antrian" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Hapus dari antrian" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Tambah ke {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Tak bisa menyimpan sandi. Pastikan bahwa kunci sesi tidak terkunci." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "Timbul galat. Periksa log untuk detailnya!" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Pustaka" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Daftar putar" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "Sedang memutar" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Tidak ada lagu yang diputar" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "Tentang" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Keluar" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Log keluar" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "Sambungan dipulihkan" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Trek teratas" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Rilis" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Masuk ke Spotify Premium" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Nama Pengguna" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Kata Sandi" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "Autentikasi gagal!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Log masuk" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Album" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Artis" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Trek yang disimpan" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "Jeda" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "Putar" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "Acak" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "Sebelumnya" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "Selanjutnya" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "Ulangi" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "Anda tidak memiliki daftar putar yang disimpan." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "Daftar putar Anda akan ditampilkan di sini." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "Anda tidak memiliki album yang disimpan." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "Pustaka Anda akan ditampilkan di sini." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} lagu dipilih" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "Cari Spotify." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "Ketik untuk mencari." + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "Buang" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "Tambah ke daftar putar…" + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "Batal" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "Pilih semua" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Lebih banyak dari {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Label" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Dirilis" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Trek" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Durasi" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Hak Cipta" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{} oleh {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "Daftar putar tanpa nama" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "Semua Daftar Putar" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "Trek disimpan!" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "Preferensi" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "Gagal membuka tautan!" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "Audio" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "Backend Audio" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "Perangkat ALSA" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "Diterapkan hanya jika backend audio adalah ALSA" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "Kualitas Audio" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "Normal" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "Tinggi" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "Sangat tinggi" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "Penampilan" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "Tema" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "Terang" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "Gelap" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "Jaringan" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "Port Titik Akses" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "Port yang digunakan untuk koneksi ke Titik Akses Spotify. Atur ke 0 jika ada port yang baik-baik saja." + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "Simpan ke pustaka" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "Daftar putar baru dibuat." + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "Tilik" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "Daftar Putar Baru" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "Pemutaran tanpa celah" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "Sistem" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "Selesai" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "Nama" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "Mencipta" + diff --git a/po/it.po b/po/it.po new file mode 100644 index 0000000..e1acfef --- /dev/null +++ b/po/it.po @@ -0,0 +1,390 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: it\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Guarda l'album" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Copia link" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Aggiungi alla coda di riproduzione" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Rimuovi dalla coda di riproduzione" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Aggiungi a {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Impossibile salvare la password. Controlla che la session keyring sia sbloccata." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "C'è stato un errore. Controlla i log per i dettagli!" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Raccolta" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Playlist" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "In riproduzione" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Nessun brano in riproduzione" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "Informazioni su" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Esci" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Log out" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "Connessione ripristinata" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Brani più popolari" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Releases" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Login su Spotify Premium" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Username" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Password" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "Autenticazione fallita!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Log in" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Album" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Artisti" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Tracce salvate" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "Pausa" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "Riproduci" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "Mescola" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "Precedente" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "Successivo" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "Ripeti" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "Non hai playlist salvate." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "Le tue playlist verranno mostrate qui." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "Non hai album salvati." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "La tua libreria verrà mostrata qui." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} brano selezionato" +msgstr[1] "{} brani selezionati" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "Cerca Spotify." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "Digita per cercare." + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "Rimuovi" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "Aggiungi alla playlist..." + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "Annulla" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "Seleziona tutto" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Di più da {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Etichetta discografica" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Data di uscita" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Tracce" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Durata" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Copyright" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{} di {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "Playlist senza nome" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "Tutte le playlist" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "Tracce salvate!" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "Preferenze" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "Apertura del link fallita!" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "Audio" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "Backend Audio" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "Device ALSA" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "Applicato solo se il backend audio è ALSA" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "Qualità Audio" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "Normale" + +#: src/app/components/settings/settings.blp:48 +#, fuzzy +msgid "High" +msgstr "Alta" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "Molto alta" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "Aspetto" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "Tema" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "Chiaro" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "Scuro" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "Rete" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "Porta Access Point" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "Porta usata per le connessioni all'Access Point Spotify. Impostala a 0 se qualsiasi valore va bene." + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "Salva nella libreria" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "Creata nuova playlist." + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "Visualizza" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "Nuova Playlist" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "Riproduzione gapless" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "Sistema" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "Fatto" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "Nome" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "Crea" + diff --git a/po/ja.po b/po/ja.po new file mode 100644 index 0000000..ebc0f2a --- /dev/null +++ b/po/ja.po @@ -0,0 +1,389 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: ja\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "アルバムを見る" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "リンクをコピー" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "キューに追加" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "キューから削除" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "{}に追加" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "パスワードを保存できませんでした。セッションキーリングのロックを解除してください。" + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "エラーが発生しました。詳細はログを確認してください。" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "ライブラリ" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "プレイリスト" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "再生中" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "再生中の曲はありません" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "このアプリについて" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "終了" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "ログアウト" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "再接続しました" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "人気のトラック" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "作品" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Spotifyプレミアムにログイン" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "ユーザー名" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "パスワード" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "認証に失敗しました。" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "ログイン" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "アルバム" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "アーティスト" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "お気に入り" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "一時停止" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "再生" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "ランダム" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "前" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "次" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "繰り返し" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "保存したプレイリストはありません。" + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "プレイリストがここに表示されます。" + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "保存したアルバムがありません。" + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "ライブラリはここに表示されます。" + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{}曲選択中" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "検索する。" + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "検索ワードを入力してください。" + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "削除" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "プレイリストに追加…" + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "選択解除" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "すべて選択" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +#, fuzzy +msgid "More from {}" +msgstr "" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "レーベル" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "リリース" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "曲数" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "再生時間" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "著作権表示" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{} から {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "" + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "" + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "" + diff --git a/po/meson.build b/po/meson.build new file mode 100644 index 0000000..b75aa95 --- /dev/null +++ b/po/meson.build @@ -0,0 +1,2 @@ +i18n = import('i18n') +i18n.gettext('spot', args: ['--from-code=UTF-8', '--add-comments'], preset: 'glib') diff --git a/po/nb.po b/po/nb.po new file mode 100644 index 0000000..9815f6e --- /dev/null +++ b/po/nb.po @@ -0,0 +1,390 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: nb\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Vis album" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Kopier lenke" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Legg til i køen" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Fjern fra køen" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Legg i {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Kunne ikke lagre passordet. Sørg for at nøkkelringen til sesjonen er låst opp." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "En feil oppstod. Sjekk logger for detaljer!" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Bibliotek" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Spillelister" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "Nå spiller" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Ingen sang spilles" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "Om" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Avslutt" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Logg ut" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "Tilkoblingen er gjenopprettet" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Topp spor" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Utgivelser" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Logg på Spotify Premium" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Brukernavn" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Passord" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "Autentiseringen mislyktes!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Logg inn" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Album" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Artister" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Lagrede spor" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "Pause" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "Spill av" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "Tilfeldig" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "Forrige" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "Neste" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "Gjenta" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "Du har ingen lagrede spillelister." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "Spillelistene dine vises her." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "Du har ingen lagrede album." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "Biblioteket ditt vises her." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +#, fuzzy +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} sang valgt" +msgstr[1] "{} sanger valgt" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "Søk Spotify." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "Skriv for å søke." + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "Fjern" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "Legg til spilleliste..." + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "Avbryt" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "Velg alle" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Mer fra {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Utgiver" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Utgitt" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Spor" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Varighet" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Opphavsrett" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{} av {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "Navnløs spilleliste" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "Alle Spillelister" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "Spor lagret!" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "Innstillinger" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "Kunne ikke åpne linken!" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "Lyd" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "Lyd Backend" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "ALSA Enhet" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "Brukes bare hvis lyd backend er ALSA" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "Lyd Kvalitet" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "Normal" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "Høy" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "Veldig høy" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "Utseende" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "Tema" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "Lys" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "Mørk" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "Nettverk" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "Aksesspunkt Port" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "Port som brukes for tilkoblinger til Spotify sitt aksesspunkt. Sett til 0 hvis tilfeldig port er greit." + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "Lagre til bibliotek" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "" + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "" + diff --git a/po/nl.po b/po/nl.po new file mode 100644 index 0000000..1a1e8cd --- /dev/null +++ b/po/nl.po @@ -0,0 +1,389 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: nl\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Album bekijken" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Link kopiëren" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Toevoegen aan wachtrij" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Verwijderen uit wachtrij" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Toevoegen aan {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Het wachtwoord kan niet worden opgeslagen - zorg dat de sessiesleutelhanger ontgrendeld is." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "Er is een fout opgetreden - bekijk het logboek!" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Verzameling" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Afspeellijsten" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "Je luistert naar" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Er wordt niks afgespeeld" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "Over" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Afsluiten" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Uitloggen" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "De verbinding is hersteld" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Topnummers" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Uitgaven" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Inloggen op Spotify Premium" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Gebruikersnaam" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Wachtwoord" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "Het inloggen is mislukt." + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Inloggen" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Albums" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Artiesten" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Opgeslagen nummers" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "Pauzeren" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "Afspelen" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "Willekeurig" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "Vorige" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "Volgende" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "Herhalen" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "Je hebt nog geen opgeslagen afspeellijsten." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "Je afspeellijsten worden hier getoond." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "Je hebt nog geen opgeslagen albums." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "Je verzameling wordt hier getoond." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} nummer geselecteerd" +msgstr[1] "{} nummers geselecteerd" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "Doorzoek Spotify." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "Typ om te zoeken" + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "Verwijderen" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "Toevoegen aan afspeellijst…" + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "Annuleren" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "Alles selecteren" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Meer van {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Label" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Uitgebracht" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Nummers" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Duur" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Copyright" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{} van {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "Naamloze afspeellijst" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "Alle afspeellijsten" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "De nummers zijn opgeslagen!" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "Voorkeuren" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "De link kan niet worden geopend!" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "Audio" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "Audiobackend" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "ALSA-apparaat" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "Alleen van toepassing als het audiobackend is ingesteld op ALSA" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "Audiokwaliteit" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "Normaal" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "Hoog" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "Hoogst" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "Vormgeving" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "Thema" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "Licht" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "Donker" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "Netwerk" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "Poort van toegangspunt" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "De poort die wordt gebruikt om verbinding te maken met Spotify's toegangspunt. 0 = elke poort." + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "Opslaan in verzameling" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "De afspeellijst is aangemaakt." + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "Bekijken" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "Nieuwe afspeellijst" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "Afspelen zonder pauzes" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "Systeem" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "Klaar" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "Naam" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "Aanmaken" + diff --git a/po/pl.po b/po/pl.po new file mode 100644 index 0000000..d9985c1 --- /dev/null +++ b/po/pl.po @@ -0,0 +1,391 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: pl\n" +"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Zobacz album" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Skopiuj link" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Dodaj do kolejki" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Usuń z kolejki" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Dodaj do {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Nie można zapisać hasła. Upewnij się że narzędzie zarządzające kluczami jest odblokowane." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "Wystąpił błąd. Sprawdź logi po więcej informacji!" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Biblioteka" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Playlisty" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "Aktualnie odtwarzane" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Aktualnie nie gra żadna piosenka" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "O programie" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Wyjdź" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Wyloguj się" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "Odzyskano połączenie" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Najlepsze piosenki" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Premiery" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Zaloguj się do Spotify Premium" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Nazwa użytkownika" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Hasło" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "Błąd autoryzacji!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Zaloguj" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Albumy" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Artyści" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Zachowane piosenki" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "Nie masz zapisanych playlist." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "Twoje playlisty będą tu pokazane." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "Nie masz zapisanych albumów." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "Twoja biblioteka będzie tu pokazana." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} wybrana piosenka" +msgstr[1] "{} wybrane piosenki" +msgstr[2] "{} wybranych piosenek" +msgstr[3] "" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "" + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "" + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "" + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Więcej od {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Wydawca" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Premiery" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Najlepsze piosenki" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Długość" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Prawa autorskie" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "" + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "" + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "" + diff --git a/po/poeditor.yml b/po/poeditor.yml new file mode 100644 index 0000000..33a5442 --- /dev/null +++ b/po/poeditor.yml @@ -0,0 +1,5 @@ +api_token: 7ac85acabcb7842c23f5d4060485f25e # read only token +projects: +- format: po + id: 469205 + terms_path: '{language_code}.po' diff --git a/po/pt-br.po b/po/pt-br.po new file mode 100644 index 0000000..9fd65f4 --- /dev/null +++ b/po/pt-br.po @@ -0,0 +1,389 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: pt-br\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Ver álbum" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Copiar link" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Adicionar a fila" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Remover da fila" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Adicionar a {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Não foi possível salvar a senha. Verifique se o seu chaveiro de sessão está desbloqueado." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "Ocorreu um erro. Verifique os logs para mais detalhes!" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Biblioteca" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Listas de reprodução" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "Tocando agora" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Nenhum música sendo executada" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "Sobre" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Fechar" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Sair" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "Conexão reestabelecida" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Melhores faixas" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Lançamentos" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Entrar no Spotify Premium" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Nome de usuário" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Senha" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "A autenticação falhou!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Entrar" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Álbuns" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Artistas" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "" + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "" + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "" + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "" + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} música selecionada" +msgstr[1] "{} músicas selecionadas" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "" + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "" + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "" + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Mais de {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Editora" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Lançamento" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Faixas" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Duração" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Copyright" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "" + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "" + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "" + diff --git a/po/pt.po b/po/pt.po new file mode 100644 index 0000000..7a81fe8 --- /dev/null +++ b/po/pt.po @@ -0,0 +1,389 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: pt\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Ver álbum" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Copiar ligação" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Adicionar à fila" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Remover da fila" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Adicionar a {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Não foi possível guardar a palavra-passe. Garanta que o chaveiro da sessão está desbloqueado." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "Ocorreu um erro! Verifique os logs para mais detalhes!" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Biblioteca" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Listas de reprodução" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "A reproduzir" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Nenhuma canção a ser reproduzida" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "Sobre" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Sair" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Terminar sessão" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "Conexão restaurada" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Melhores faixas" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Lançamentos" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Autenticar-se no Spotify Premium" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Utilizador" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Palavra-passe" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "Autenticação falhou!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Autenticar" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Álbums" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Artistas" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Músicas salvas" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "Pausar" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "Tocar" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "Misturar" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "Anterior" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "Próximo" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "Repetir" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "Não tem listas de reprodução salvas." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "As tuas listas de reprodução aparecerão aqui." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "Não tens albúns salvos." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "A tua biblioteca vai aparecer aqui." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} canção selecionada" +msgstr[1] "{} canções selecionadas" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "Procurar no Spotify." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "Escrever para pesquisar." + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "Remover" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "Adicionar à lista de reprodução..." + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "Cancelar" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "Selecionar todos" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Mais de {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Editora" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Lançamento" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Faixas" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Duração" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Copyright" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{} por {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "Lista de reprodução sem nome" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "Todas as listas de reprodução" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "Músicas salvas!" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "Preferências" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "Falha ao abrir a ligação!" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "Áudio" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "Back-end de Áudio" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "Dispositivo ALSA" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "Aplicado apenas se back-end do áudio é ALSA" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "Qualidade do Áudio" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "Normal" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "Alta" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "Muito Alta" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "Aparência" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "Tema" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "Claro" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "Escuro" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "Rede" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "Porta do Ponto de Acesso" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "Porta usada para conexões ao Ponto de Acesso do Spotify. Definir para 0 se qualquer porta servir." + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "Salvar na biblioteca" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "Nova lista de reprodução criada." + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "Ver" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "Nova lista de reprodução" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "Reprodução sem intervalos" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "Sistema" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "Feito" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "Nome" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "Criar" + diff --git a/po/ru.po b/po/ru.po new file mode 100644 index 0000000..2b4e150 --- /dev/null +++ b/po/ru.po @@ -0,0 +1,391 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: ru\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Посмотреть альбом" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Скопировать ссылку" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Добавить в очередь" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Удалить из очереди" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Добавить в {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Не удалось сохранить пароль. Убедитесь, что связка ключей сеанса разблокирована." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "Произошла ошибка. Смотрите подробности в логах!" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Фонотека" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Плейлисты" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "Воспроизводится сейчас" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Песня сейчас не играет" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "О Spot" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Выйти из программы" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Выйти из аккаунта" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "Подключение восстановлено" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Топ треков" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Релизы" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Войти в Spotify Premium" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Имя аккаунта" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Пароль" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "Вход не удался!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Войти" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Альбомы" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Исполнители" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Сохраненные треки" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "Пауза" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "Воспроизведение" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "Перемешать" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "Предыдущий" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "Следующий" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "Повторить" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "У вас нет сохраненных плейлистов." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "Здесь будут показаны ваши плейлисты." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "У вас нет сохраненных альбомов." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "Здесь будет показана ваша фонотека." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} композиция выбрана" +msgstr[1] "{} композиции выбраны" +msgstr[2] "{} композиций выбрано" +msgstr[3] "" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "Поиск в Spotify." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "Введите для поиска." + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "Удалить" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "Добавить в плейлист..." + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "Отменить" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "Выбрать все" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Больше от {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Лейбл" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Релизы" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Треки" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Продолжительность" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Авторское право" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{} в исполнении {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "Безымянный плейлист" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "Все плейлисты" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "Треки сохранены!" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "Настройки" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "Не удалось открыть ссылку!" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "Аудио" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "Аудио Бэкэнд" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "ALSA Device" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "Применяется, только если звуковой бэкэнд ALSA" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "Качество звука" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "Нормальное" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "Высокое" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "Очень высокое" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "Внешний вид" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "Тема" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "Светлая" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "Темная" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "Сеть" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "Порт точки доступа" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "Порт, используемый для подключения к точке доступа Spotify. Установите на 0, если все порты в порядке." + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "Сохранить в библиотеку" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "Создан новый плейлист." + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "Вид" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "Новый плейлист" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "Непрерывное воспроизведение" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "Система" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "Сделано" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "Имя" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "Создать" + diff --git a/po/sl.po b/po/sl.po new file mode 100644 index 0000000..a82075f --- /dev/null +++ b/po/sl.po @@ -0,0 +1,391 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: sl\n" +"Plural-Forms: nplurals=4; plural=(n%100==1 ? 1 : n%100==2 ? 2 : n%100==3 || n%100==4 ? 3 : 0);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Oglej si album" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Kopiraj povezavo" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Dodaj v vrsto" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Odstrani iz vrste" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Dodaj na {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Gesla ni bilo mogoče shraniti. Preveri, da je session keyring odklenjen." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "Napaka! Poglej v dnevnik od programa za podrobnosti." + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Moja zbirka" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Seznami predvajanja" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "Se predvaja" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Ni skladb za predvajanje" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "O programu" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Izhod" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Odjava" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "Povezava ponovno vzpovstavljena" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Priljubljene skladbe" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Izdaje" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Prijava v Spotify Premium" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Uporabniško ime" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Geslo" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "Preverjanje pristnosti ni bilo uspešno!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Prijava" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Albumi" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Izvajalci" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Shranjene skladbe" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "Premor" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "Predvajaj" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "Naključno predvajanje" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "Prejšna skladba" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "Naslednja skladba" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "Ponavljajoče predvajanje" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "Nimaš shranjenih seznamov predvajanja." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "Tvoji seznami predvajanja bodo prikazani tukaj." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "Nimaš shranjenih albumov." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "Tvoja zbirka bo prikazana tukaj." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} izbrana skladba" +msgstr[1] "{} izbrani skladbi" +msgstr[2] "{} izbrane skladbe" +msgstr[3] "{} izbranih skladb" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "Razišči Spotify." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "Napiši za iskanje." + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "Odstrani" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "Dodaj na seznam predvajanja..." + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "Prekliči" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "Izberi vse" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Več od {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Založba" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Izdano" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Skladbe" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Trajanje" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Avtorska pravica" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{} od {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "" + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "" + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "" + diff --git a/po/spot.pot b/po/spot.pot new file mode 100644 index 0000000..da2113f --- /dev/null +++ b/po/spot.pot @@ -0,0 +1,418 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the spot package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: spot\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-03-18 19:15-0400\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:106 src/app/components/mod.rs:133 +msgid "An error occured. Check logs for details!" +msgstr "" + +#: src/app/components/device_selector/widget.rs:135 +#: src/app/components/device_selector/device_selector.blp:9 +#: src/app/components/device_selector/device_selector.blp:37 +msgid "This device" +msgstr "" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "" + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "" +msgstr[1] "" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "" + +#. translators: This is a sidebar entry that marks that the entries below are playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "" + +#. translators: This is a sidebar entry to browse to saved tracks. +#: src/app/components/navigation/factory.rs:76 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "" + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Translators: Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "" + +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "" + +#: src/connect/player.rs:65 +msgid "Connection to device lost!" +msgstr "" + +#: src/main.rs:76 +msgid "Failed to open link!" +msgstr "" + +#. Translators: A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "" + +#. Translators: A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "" + +#. Translators: Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "" + +#. Translators: Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "" + +#. Translators: This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "" + +#. Translators: This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "" + +#. Translators: Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "" + +#. Translators: Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "" + +#. Translators: Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "" + +#. Translators: Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "" + +#. Translators: Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "" + +#. Translators: Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "" + +#. Translators: Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "" + +#. Translators: Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "" + +#. Translators: Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "" + +#. Translators: Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "" + +#. Translators: Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "" + +#. Translators: Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "" + +#. Translators: Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "" +"Port used for connections to Spotify's Access Point. Set to 0 if any port is " +"fine." +msgstr "" + +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "" + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "" + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "" + +#. Translators: This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "" + +#. Translators: This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "" + +#. Translators: This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "" + +#: src/app/components/login/login.blp:46 +msgid "Welcome to Spot" +msgstr "" + +#. Translators: Login window title, must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:53 +msgid "" +"Log in with your Spotify Account. A Spotify Premium subscription is required " +"to use the app." +msgstr "" + +#. Translators: Placeholder for the username field +#: src/app/components/login/login.blp:66 +msgid "Username or Email" +msgstr "" + +#. Translators: Placeholder for the password field +#: src/app/components/login/login.blp:71 +msgid "Password" +msgstr "" + +#. Translators: This error is shown when authentication fails. +#: src/app/components/login/login.blp:81 +msgid "Incorrect login credentials." +msgstr "" + +#. Translators: Log in button label +#: src/app/components/login/login.blp:92 +msgid "Log in" +msgstr "" + +#. Translators: Exit playlist edition +#. Translators: Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "" + +#. Translators: Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "" + +#. Translators: Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "" + +#: src/app/components/device_selector/device_selector.blp:15 +msgid "Playing on" +msgstr "" + +#: src/app/components/device_selector/device_selector.blp:24 +msgid "Refresh devices" +msgstr "" + +#. Translators: label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "" + +#. Translators: Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "" + +#. Translators: A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "" + +#. Translators: A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "" diff --git a/po/tr.po b/po/tr.po new file mode 100644 index 0000000..4011113 --- /dev/null +++ b/po/tr.po @@ -0,0 +1,389 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: tr\n" +"Plural-Forms: nplurals=2; plural=(n>1);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Albümü görüntüle" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Bağlantıyı kopyala" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Sıraya ekle" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Sıradan çıkar" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "{} listesine ekle" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Parola kaydedilemedi. Oturum anahtarlığınızın kilidinin açık olduğundan emin olun." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "Bir hata meydana geldi. Günlükleri kontrol edin." + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Kütüphane" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Oynatma listesi" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "Şu an oynatılıyor" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Herhangi bir şarkı oynatılmıyor" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "Hakkında" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Çık" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Oturumdan çık" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "Bağlantı geri yüklendi." + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "En çok dinlenenler" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Parçalar" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Spotify Premium'a giriş yap" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Kullanıcı adı" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Parola" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "Kimlik doğrulama başarısız." + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Giriş yap" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Albümler" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Sanatçılar" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "Kaydedilen parçalar" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "Durdur" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "Oynat" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "Karıştır" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "Önceki" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "Sonraki" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "Yinele" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "Kaydedilmiş oynatma listeniz yok." + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "Oynatma listeniz burada gösterilir." + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "Kaydedilmiş ablümünüz yok." + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "Kütüphaneniz burada gösterilir." + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "{} seçildi" +msgstr[1] "{} seçildi" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "Spotify'da Ara." + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "Aramak için yazın." + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "Kaldır" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "Oynatma listesine ekle..." + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "İptal Et" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "Tümünü seç" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "Daha fazla {}" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "Yayıncı" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "Yayın tarihi" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "Parça" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "Süre" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "Telif hakkı" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "{}, {}" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "Adsız oynatma listesi" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "Tüm Oynatma Listeleri" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "Parçalar kaydedildi!" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "Tercihler" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "Bağlantı açılamadı!" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "Ses" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "Ses Arka Ucu" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "ALSA Aygıtı" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "Sadece, ses arka ucu ALSA ise uygulanır" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "Ses Kalitesi" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "Normal" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "Yüksek" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "Çok yüksek" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "Görünüm" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "Tema" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "Açık" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "Koyu" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "Ağ" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "Erişim Noktası Bağlantı Noktası" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "Spotify Erişim Noktasına bağlanmak için kullanılan bağlantı noktası. Herhangi bir bağlantı noktası iyiyse 0'a ayarlayın." + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "Kütüphaneye kaydet" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "Yeni çalma listesi oluşturuldu." + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "Görünüm" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "Yeni Çalma Listesi" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "Kesintisiz çalma" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "Sistem" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "Bitti" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "Ad" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "Oluştur" + diff --git a/po/uk.po b/po/uk.po new file mode 100644 index 0000000..1cf9170 --- /dev/null +++ b/po/uk.po @@ -0,0 +1,391 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Spot\n" +"Language: uk\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" + +#. translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. +#: src/app/components/labels.rs:5 +msgid "View album" +msgstr "Подивитися альбом" + +#. translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. +#: src/app/components/labels.rs:8 +msgid "Copy link" +msgstr "Копіювати посилання" + +#. translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. +#: src/app/components/labels.rs:11 +#: src/app/components/selection/selection_toolbar.blp:36 +msgid "Add to queue" +msgstr "Додати в чергу" + +#. translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. +#: src/app/components/labels.rs:14 +msgid "Remove from queue" +msgstr "Видалити з черги" + +#. translators: This is part of a larger text that says "Add to ". This text should be as short as possible. +#: src/app/components/labels.rs:21 +msgid "Add to {}" +msgstr "Додати в {}" + +#. translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). +#: src/app/components/login/login_model.rs:68 +msgid "Could not save password. Make sure the session keyring is unlocked." +msgstr "Не вдалося зберегти пароль. Переконайтесь, що зв'язка ключів сеансу розблокована." + +#. translators: This notification is the default message for unhandled errors. Logs refer to console output. +#: src/app/batch_loader.rs:91 src/app/components/mod.rs:129 +msgid "An error occured. Check logs for details!" +msgstr "Сталася помилка. Перевірте журнал для детальної інформації!" + +#. translators: This is a sidebar entry to browse to saved albums. +#: src/app/components/navigation/factory.rs:33 +#: src/app/components/sidebar/sidebar_item.rs:39 +msgid "Library" +msgstr "Бібліотека" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/navigation/factory.rs:54 +#: src/app/components/sidebar/sidebar_item.rs:45 +msgid "Playlists" +msgstr "Плейлисти" + +#. This is the visible name for the play queue. It appears in the sidebar as well. +#: src/app/components/now_playing/now_playing_model.rs:134 +#: src/app/components/sidebar/sidebar_item.rs:43 +msgid "Now playing" +msgstr "Відтворюється зараз" + +#. translators: Short text displayed instead of a song title when nothing plays +#. Short text displayed instead of a song title when nothing plays +#: src/app/components/playback/playback_info.rs:58 +#: src/app/components/playback/playback_info.blp:33 +msgid "No song playing" +msgstr "Пісня зараз не грає" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:67 +msgid "About" +msgstr "Про Spot" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:69 +msgid "Quit" +msgstr "Вийти з програми" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:74 +msgid "Log out" +msgstr "Вийти з аккаунту" + +#: src/app/state/login_state.rs:115 +msgid "Connection restored" +msgstr "З'єднання відновлено" + +#. Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. +#: src/app/components/artist_details/artist_details.blp:26 +msgid "Top tracks" +msgstr "Топ треків" + +#. Title of the sections that contains all releases from an artist (both singles and albums). +#: src/app/components/artist_details/artist_details.blp:54 +msgid "Releases" +msgstr "Релізи" + +#. Login window title -- shouldn't be too long, but must mention Premium (a premium account is required). +#: src/app/components/login/login.blp:49 +msgid "Login to Spotify Premium" +msgstr "Увійти в Spotify Premium" + +#. Placeholder for the username field +#: src/app/components/login/login.blp:76 +msgid "Username" +msgstr "Логін" + +#. Placeholder for the password field +#: src/app/components/login/login.blp:94 +msgid "Password" +msgstr "Пароль" + +#. This error is shown when authentication fails. +#: src/app/components/login/login.blp:116 +msgid "Authentication failed!" +msgstr "Помилка входу!" + +#. Log in button label +#: src/app/components/login/login.blp:131 +msgid "Log in" +msgstr "Увійти" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:69 +msgid "Albums" +msgstr "Альбоми" + +#. This is the title of a section of the search results +#: src/app/components/search/search.blp:100 +msgid "Artists" +msgstr "Виконавці" + +#: src/app/components/navigation/factory.rs:85 +#: src/app/components/sidebar/sidebar_item.rs:41 +msgid "Saved tracks" +msgstr "" + +#: src/app/components/playback/playback_controls.rs:64 +msgid "Pause" +msgstr "" + +#: src/app/components/playback/playback_controls.rs:66 +msgid "Play" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:15 +msgid "Shuffle" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:24 +msgid "Previous" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:46 +msgid "Next" +msgstr "" + +#: src/app/components/playback/playback_controls.blp:55 +msgid "Repeat" +msgstr "" + +#. A title that is shown when the user has not saved any playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:26 +msgid "You have no saved playlists." +msgstr "" + +#. A description of what happens when the user has saved playlists. +#: src/app/components/saved_playlists/saved_playlists.blp:30 +msgid "Your playlists will be shown here." +msgstr "" + +#. A title that is shown when the user has not saved any albums. +#: src/app/components/library/library.blp:25 +msgid "You have no saved albums." +msgstr "" + +#. A description of what happens when the user has saved albums. +#: src/app/components/library/library.blp:29 +msgid "Your library will be shown here." +msgstr "" + +#. translators: This shows up when in selection mode. This text should be as short as possible. +#: src/app/components/labels.rs:30 +msgid "{} song selected" +msgid_plural "{} songs selected" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" +msgstr[3] "" + +#. Title for the empty search page (initial state). +#: src/app/components/search/search.blp:110 +msgid "Search Spotify." +msgstr "" + +#. Subtitle for the empty search page (initial state). +#: src/app/components/search/search.blp:114 +msgid "Type to search." +msgstr "" + +#: src/app/components/selection/selection_toolbar.blp:61 +msgid "Remove" +msgstr "" + +#. playlist2-symbolic +#: src/app/components/selection/selection_toolbar.blp:49 +msgid "Add to playlist..." +msgstr "" + +#. Button label. Exits selection mode. +#: src/app/components/playlist_details/playlist_headerbar.blp:53 +#: src/app/components/headerbar/headerbar.blp:49 +msgid "Cancel" +msgstr "" + +#. Button label. Selects all visible songs. +#: src/app/components/headerbar/headerbar.blp:63 +msgid "Select all" +msgstr "" + +#. translators: This is part of a contextual menu attached to a single track; the full text is "More from ". +#: src/app/components/labels.rs:39 +msgid "More from {}" +msgstr "" + +#. This refers to a music label +#: src/app/components/details/release_details.blp:41 +msgid "Label" +msgstr "" + +#. This refers to a release date +#: src/app/components/details/release_details.blp:52 +msgid "Released" +msgstr "" + +#. This refers to a number of tracks +#: src/app/components/details/release_details.blp:63 +msgid "Tracks" +msgstr "" + +#. This refers to the duration of a release +#: src/app/components/details/release_details.ui:68 +msgid "Duration" +msgstr "" + +#: src/app/components/details/release_details.blp:72 +msgid "Copyright" +msgstr "" + +#. translators: This is part of a larger label that reads " by " +#: src/app/components/labels.rs:48 +msgid "{} by {}" +msgstr "" + +#: src/app/components/sidebar/sidebar.rs:49 +msgid "Unnamed playlist" +msgstr "" + +#. translators: This is a sidebar entry to browse to saved playlists. +#: src/app/components/sidebar/sidebar_item.rs:81 +msgid "All Playlists" +msgstr "" + +#: src/app/components/selection/component.rs:66 +msgid "Tracks saved!" +msgstr "" + +#. translators: This is a menu entry. +#: src/app/components/user_menu/user_menu.rs:65 +msgid "Preferences" +msgstr "" + +#: src/main.rs:75 +msgid "Failed to open link!" +msgstr "" + +#. Header for a group of preference items regarding audio +#: src/app/components/settings/settings.blp:13 +msgid "Audio" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:18 +msgid "Audio Backend" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:30 +msgid "ALSA Device" +msgstr "" + +#. Description for the item (ALSA Device) in preferences +#: src/app/components/settings/settings.blp:34 +msgid "Applied only if audio backend is ALSA" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:44 +msgid "Audio Quality" +msgstr "" + +#: src/app/components/settings/settings.blp:47 +msgid "Normal" +msgstr "" + +#: src/app/components/settings/settings.blp:48 +msgid "High" +msgstr "" + +#: src/app/components/settings/settings.blp:49 +msgid "Very high" +msgstr "" + +#. Header for a group of preference items regarding the application's appearance +#: src/app/components/settings/settings.blp:72 +msgid "Appearance" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:77 +msgid "Theme" +msgstr "" + +#: src/app/components/settings/settings.blp:80 +msgid "Light" +msgstr "" + +#: src/app/components/settings/settings.blp:81 +msgid "Dark" +msgstr "" + +#. Header for a group of preference items regarding network +#: src/app/components/settings/settings.blp:91 +msgid "Network" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:96 +msgid "Access Point Port" +msgstr "" + +#. Longer description for an item (Access Point Port) in preferences +#: src/app/components/settings/settings.blp:100 +msgid "Port used for connections to Spotify's Access Point. Set to 0 if any port is fine." +msgstr "" + +#: src/app/components/selection/selection_toolbar.blp:78 +msgid "Save to library" +msgstr "" + +#. translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. +#: src/app/components/notification/mod.rs:25 +msgid "New playlist created." +msgstr "" + +#. translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. +#: src/app/components/notification/mod.rs:27 +msgid "View" +msgstr "" + +#: src/app/components/sidebar/sidebar_item.rs:90 +msgid "New Playlist" +msgstr "" + +#. Title for an item in preferences +#: src/app/components/settings/settings.blp:57 +msgid "Gapless playback" +msgstr "" + +#: src/app/components/settings/settings.blp:82 +msgid "System" +msgstr "" + +#. Finish playlist edition +#: src/app/components/playlist_details/playlist_headerbar.blp:69 +msgid "Done" +msgstr "" + +#. label for the entry containing the name of a new playlist +#: src/app/components/sidebar/create_playlist.blp:10 +msgid "Name" +msgstr "" + +#. Button that creates a new playlist +#: src/app/components/sidebar/create_playlist.blp:30 +msgid "Create" +msgstr "" + diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..cf71118 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +#use_small_heuristics = "Max" +newline_style = "Unix" +use_field_init_shorthand = true diff --git a/spot.nix b/spot.nix new file mode 100644 index 0000000..a613875 --- /dev/null +++ b/spot.nix @@ -0,0 +1,73 @@ +{ lib +, stdenv +, fetchgit +, nix-update-script +, meson +, ninja +, gettext +, desktop-file-utils +, cargo +, rustPlatform +, rustc +, pkg-config +, glib +, libadwaita +, libhandy +, gtk4 +, openssl +, alsa-lib +, libpulseaudio +, wrapGAppsHook4 +, blueprint-compiler +, gst_all_1 +}: + +rustPlatform.buildRustPackage rec { + pname = "spot"; + version = "0.6.0"; + + src = fetchgit { + url = "https://github.com/stevenleadbeater/spot.git"; + rev = "b2291e0834d9a1a6e7ce1020fc88c483dfbc0b24"; + hash = "sha256-JFBwTAYKBwmjUI3B67zB60dLM+DjIzzbO/1iLsn+5TE="; + }; + cargoHash = "sha256-HIjA+oDFegsnnV3EViaWfsNhmqPB1yl8rIVS5LbTu/A="; + + nativeBuildInputs = [ + gettext + meson + ninja + pkg-config + gtk4 # for gtk-update-icon-cache + glib # for glib-compile-schemas + desktop-file-utils + cargo + rustPlatform.cargoSetupHook + rustc + wrapGAppsHook4 + blueprint-compiler + ]; + + buildInputs = [ + glib + gtk4 + libadwaita + libhandy + openssl + alsa-lib + libpulseaudio + gst_all_1.gst-plugins-base + gst_all_1.gstreamer + ]; + + # https://github.com/xou816/spot/issues/313 + mesonBuildType = "release"; + + meta = { + description = "Native Spotify client for the GNOME desktop"; + mainProgram = "spot"; + homepage = "https://github.com/stevenleadbeater/spot"; + maintainers = []; + }; +} + diff --git a/src/api/api_models.rs b/src/api/api_models.rs new file mode 100644 index 0000000..bc1089b --- /dev/null +++ b/src/api/api_models.rs @@ -0,0 +1,689 @@ +use form_urlencoded::Serializer; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashSet, + convert::{Into, TryFrom, TryInto}, + vec::IntoIter, +}; + +use crate::app::{models::*, SongsSource}; + +#[derive(Serialize)] +pub struct PlaylistDetails { + pub name: String, +} + +#[derive(Serialize)] +pub struct Uris { + pub uris: Vec, +} + +#[derive(Serialize)] +pub struct PlayOffset { + pub position: u32, +} + +#[derive(Serialize)] +#[serde(untagged)] +pub enum PlayRequest { + Contextual { + context_uri: String, + offset: PlayOffset, + }, + Uris { + uris: Vec, + offset: PlayOffset, + }, +} + +#[derive(Serialize)] +pub struct Ids { + pub ids: Vec, +} + +#[derive(Serialize)] +pub struct Name<'a> { + pub name: &'a str, +} + +pub enum SearchType { + Artist, + Album, +} + +impl SearchType { + fn into_string(self) -> &'static str { + match self { + Self::Artist => "artist", + Self::Album => "album", + } + } +} + +pub struct SearchQuery { + pub query: String, + pub types: Vec, + pub limit: usize, + pub offset: usize, +} + +impl SearchQuery { + pub fn into_query_string(self) -> String { + let mut types = self + .types + .into_iter() + .fold(String::new(), |acc, t| acc + t.into_string() + ","); + types.pop(); + + let re = Regex::new(r"(\W|\s)+").unwrap(); + let query = re.replace_all(&self.query[..], " "); + + let serialized = Serializer::new(String::new()) + .append_pair("q", query.as_ref()) + .append_pair("offset", &self.offset.to_string()[..]) + .append_pair("limit", &self.limit.to_string()[..]) + .append_pair("market", "from_token") + .finish(); + + format!("type={types}&{serialized}") + } +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Page { + items: Option>, + offset: Option, + limit: Option, + total: usize, +} + +impl Page { + fn new(items: Vec) -> Self { + let l = items.len(); + Self { + total: l, + items: Some(items), + offset: Some(0), + limit: Some(l), + } + } + + fn map(self, mapper: Mapper) -> Page + where + Mapper: Fn(T) -> U, + { + let Page { + items, + offset, + limit, + total, + } = self; + Page { + items: items.map(|item| item.into_iter().map(mapper).collect()), + offset, + limit, + total, + } + } + + pub fn limit(&self) -> usize { + self.limit + .or_else(|| Some(self.items.as_ref()?.len())) + .filter(|limit| *limit > 0) + .unwrap_or(50) + } + + pub fn total(&self) -> usize { + self.total + } + + pub fn offset(&self) -> usize { + self.offset.unwrap_or(0) + } +} + +impl IntoIterator for Page { + type Item = T; + type IntoIter = IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.items.unwrap_or_default().into_iter() + } +} + +impl Default for Page { + fn default() -> Self { + Self { + items: None, + total: 0, + offset: Some(0), + limit: Some(0), + } + } +} + +trait WithImages { + fn images(&self) -> &[Image]; + + fn best_image T>(&self, criterion: F) -> Option<&Image> { + let mut ords = self + .images() + .iter() + .map(|image| (criterion(image), image)) + .collect::>(); + + ords.sort_by(|a, b| (a.0).partial_cmp(&b.0).unwrap()); + Some(ords.first()?.1) + } + + fn best_image_for_width(&self, width: i32) -> Option<&Image> { + self.best_image(|i| (width - i.width.unwrap_or(0) as i32).abs()) + } +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Playlist { + pub id: String, + pub name: String, + pub images: Vec, + pub tracks: Page, + pub owner: PlaylistOwner, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct PlaylistOwner { + pub id: String, + pub display_name: String, +} + +impl WithImages for Playlist { + fn images(&self) -> &[Image] { + &self.images[..] + } +} + +#[derive(Deserialize, Debug, Clone)] +pub struct PlaylistTrack { + pub is_local: bool, + pub track: Option, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct SavedTrack { + pub added_at: String, + pub track: TrackItem, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct SavedAlbum { + pub album: Album, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct FullAlbum { + #[serde(flatten)] + pub album: Album, + #[serde(flatten)] + pub album_info: AlbumInfo, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Album { + pub id: String, + pub tracks: Option>, + pub artists: Vec, + pub release_date: Option, + pub name: String, + pub images: Vec, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct AlbumInfo { + pub label: String, + pub copyrights: Vec, + pub total_tracks: u32, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Copyright { + pub text: String, + #[serde(alias = "type")] + pub type_: char, +} + +impl WithImages for Album { + fn images(&self) -> &[Image] { + &self.images[..] + } +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Image { + pub url: String, + pub height: Option, + pub width: Option, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Artist { + pub id: String, + pub name: String, + pub images: Option>, +} + +impl WithImages for Artist { + fn images(&self) -> &[Image] { + #[allow(clippy::manual_unwrap_or_default)] + if let Some(ref images) = self.images { + images + } else { + &[] + } + } +} + +#[derive(Deserialize, Debug, Clone)] +pub struct User { + pub id: String, + pub display_name: String, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Device { + #[serde(alias = "type")] + pub type_: String, + pub name: String, + pub id: String, + pub is_active: bool, + pub is_restricted: bool, + pub volume_percent: u32, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Devices { + pub devices: Vec, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct PlayerQueue { + pub currently_playing: TrackItem, + pub queue: Vec, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct PlayerContext { + #[serde(alias = "type")] + pub type_: String, + pub uri: String, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct PlayerState { + pub progress_ms: u32, + pub is_playing: bool, + pub repeat_state: String, + pub shuffle_state: bool, + pub item: FailibleTrackItem, + pub context: Option, +} + +impl From for ConnectPlayerState { + fn from( + PlayerState { + progress_ms, + is_playing, + repeat_state, + shuffle_state, + item, + context, + }: PlayerState, + ) -> Self { + let repeat = match &repeat_state[..] { + "track" => RepeatMode::Song, + "context" => RepeatMode::Playlist, + _ => RepeatMode::None, + }; + let source = context.and_then(|PlayerContext { type_, uri }| match type_.as_str() { + "album" => { + let id = uri.split(':').last().unwrap_or_default(); + Some(SongsSource::Album(id.to_string())) + } + _ => None, + }); + let shuffle = shuffle_state; + let current_song_id = item.get().map(|i| i.track.id); + Self { + is_playing, + progress_ms, + repeat, + shuffle, + source, + current_song_id, + } + } +} + +#[derive(Deserialize, Debug, Clone)] +pub struct TopTracks { + pub tracks: Vec, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct AlbumTrackItem { + pub id: String, + pub track_number: Option, + pub uri: String, + pub name: String, + pub duration_ms: i64, + pub artists: Vec, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct TrackItem { + #[serde(flatten)] + pub track: AlbumTrackItem, + pub album: Album, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct BadTrackItem {} + +#[derive(Deserialize, Debug, Clone)] +#[serde(untagged)] +pub enum FailibleTrackItem { + Ok(Box), + Failing(BadTrackItem), +} + +impl FailibleTrackItem { + fn get(self) -> Option { + match self { + Self::Ok(track) => Some(*track), + Self::Failing(_) => None, + } + } +} + +#[derive(Deserialize, Debug, Clone)] +pub struct RawSearchResults { + pub albums: Option>, + pub artists: Option>, +} + +impl From for ArtistSummary { + fn from(artist: Artist) -> Self { + let photo = artist.best_image_for_width(200).map(|i| &i.url).cloned(); + let Artist { id, name, .. } = artist; + Self { id, name, photo } + } +} + +impl TryFrom for TrackItem { + type Error = (); + + fn try_from(PlaylistTrack { is_local, track }: PlaylistTrack) -> Result { + track.ok_or(())?.get().filter(|_| !is_local).ok_or(()) + } +} + +impl From for TrackItem { + fn from(track: SavedTrack) -> Self { + track.track + } +} + +impl From for Vec { + fn from( + PlayerQueue { + mut queue, + currently_playing, + }: PlayerQueue, + ) -> Self { + let mut ids = HashSet::::new(); + queue.insert(0, currently_playing); + let queue: Vec = queue + .into_iter() + .take_while(|e| { + if ids.contains(&e.track.id) { + false + } else { + ids.insert(e.track.id.clone()); + true + } + }) + .collect(); + Page::new(queue).into() + } +} + +impl From for Vec { + fn from(top_tracks: TopTracks) -> Self { + Page::new(top_tracks.tracks).into() + } +} + +impl From> for Vec +where + T: TryInto, +{ + fn from(page: Page) -> Self { + SongBatch::from(page).songs + } +} + +impl From<(Page, &Album)> for SongBatch { + fn from(page_and_album: (Page, &Album)) -> Self { + let (page, album) = page_and_album; + Self::from(page.map(|track| TrackItem { + track, + album: album.clone(), + })) + } +} + +impl From> for SongBatch +where + T: TryInto, +{ + fn from(page: Page) -> Self { + let batch = Batch { + offset: page.offset(), + batch_size: page.limit(), + total: page.total(), + }; + let songs = page + .into_iter() + .filter_map(|t| { + let TrackItem { track, album } = t.try_into().ok()?; + let AlbumTrackItem { + artists, + id, + uri, + name, + duration_ms, + track_number, + } = track; + let artists = artists + .into_iter() + .map(|a| ArtistRef { + id: a.id, + name: a.name, + }) + .collect::>(); + + let art = album.best_image_for_width(200).map(|i| &i.url).cloned(); + let Album { + id: album_id, + name: album_name, + .. + } = album; + + let album_ref = AlbumRef { + id: album_id, + name: album_name, + }; + + Some(SongDescription { + id, + track_number: track_number.map(|u| u as u32), + uri, + title: name, + artists, + album: album_ref, + duration: duration_ms as u32, + art, + }) + }) + .collect(); + SongBatch { songs, batch } + } +} + +impl TryFrom for SongBatch { + type Error = (); + + fn try_from(mut album: Album) -> Result { + let tracks = album.tracks.take().ok_or(())?; + Ok((tracks, &album).into()) + } +} + +impl From for AlbumFullDescription { + fn from(full_album: FullAlbum) -> Self { + let description = full_album.album.into(); + let release_details = full_album.album_info.into(); + Self { + description, + release_details, + } + } +} + +impl From for AlbumDescription { + fn from(album: Album) -> Self { + let artists = album + .artists + .iter() + .map(|a| ArtistRef { + id: a.id.clone(), + name: a.name.clone(), + }) + .collect::>(); + let songs = album + .clone() + .try_into() + .unwrap_or_else(|_| SongBatch::empty()); + let art = album.best_image_for_width(200).map(|i| i.url.clone()); + + Self { + id: album.id, + title: album.name, + artists, + release_date: album.release_date, + art, + songs, + is_liked: false, + } + } +} + +impl From for AlbumReleaseDetails { + fn from( + AlbumInfo { + label, + copyrights, + total_tracks, + }: AlbumInfo, + ) -> Self { + let copyright_text = copyrights + .iter() + .map(|Copyright { type_, text }| format!("[{type_}] {text}")) + .collect::>() + .join(",\n "); + + Self { + label, + copyright_text, + total_tracks: total_tracks as usize, + } + } +} + +impl From for PlaylistDescription { + fn from(playlist: Playlist) -> Self { + let art = playlist.best_image_for_width(200).map(|i| i.url.clone()); + let Playlist { + id, + name, + tracks, + owner, + .. + } = playlist; + let PlaylistOwner { + id: owner_id, + display_name, + } = owner; + let song_batch = tracks.into(); + PlaylistDescription { + id, + title: name, + art, + songs: song_batch, + owner: UserRef { + id: owner_id, + display_name, + }, + } + } +} + +impl From for ConnectDevice { + fn from( + Device { + id, name, type_, .. + }: Device, + ) -> Self { + let kind = match type_.to_lowercase().as_str() { + "smartphone" => ConnectDeviceKind::Phone, + "computer" => ConnectDeviceKind::Computer, + "speaker" => ConnectDeviceKind::Speaker, + _ => ConnectDeviceKind::Other, + }; + Self { + id, + label: name, + kind, + } + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_playlist_track_null() { + let track = r#"{"is_local": false, "track": null}"#; + let deserialized: PlaylistTrack = serde_json::from_str(track).unwrap(); + let track_item: Option = deserialized.try_into().ok(); + assert!(track_item.is_none()); + } + + #[test] + fn test_playlist_track_local() { + let track = r#"{"is_local": true, "track": {"name": ""}}"#; + let deserialized: PlaylistTrack = serde_json::from_str(track).unwrap(); + let track_item: Option = deserialized.try_into().ok(); + assert!(track_item.is_none()); + } + + #[test] + fn test_playlist_track_ok() { + let track = r#"{"is_local":false,"track":{"album":{"artists":[{"external_urls":{"spotify":""},"href":"","id":"","name":"","type":"artist","uri":""}],"id":"","images":[{"height":64,"url":"","width":64}],"name":""},"artists":[{"id":"","name":""}],"duration_ms":1,"id":"","name":"","uri":""}}"#; + let deserialized: PlaylistTrack = serde_json::from_str(track).unwrap(); + let track_item: Option = deserialized.try_into().ok(); + assert!(track_item.is_some()); + } +} diff --git a/src/api/cache.rs b/src/api/cache.rs new file mode 100644 index 0000000..b7e38d5 --- /dev/null +++ b/src/api/cache.rs @@ -0,0 +1,289 @@ +use async_std::fs; +use async_std::io; +use async_std::path::Path; +use async_std::path::PathBuf; +use async_std::prelude::*; +use core::mem::size_of; +use futures::join; +use regex::Regex; +use std::convert::From; +use std::future::Future; +use std::time::{Duration, SystemTime}; +use thiserror::Error; + +const EXPIRY_FILE_EXT: &str = ".expiry"; + +#[derive(Error, Debug)] +pub enum CacheError { + #[error("No content available")] + NoContent, + #[error("File could not be saved to cache: {0}")] + WriteError(std::io::Error), + #[error("File could not be read from cache: {0}")] + ReadError(std::io::Error), + #[error("File could not be removed from cache: {0}")] + RemoveError(std::io::Error), + #[error(transparent)] + ConversionError(#[from] std::string::FromUtf8Error), +} + +pub type ETag = String; + +pub enum CacheFile { + Fresh(Vec), + Expired(Vec, Option), + None, +} + +#[derive(PartialEq, Clone, Copy, Debug)] +pub enum CachePolicy { + Default, // query remote cache when stale + IgnoreExpiry, // always use cached value + Revalidate, // always query remote cache + IgnoreCached, // ignore cache alltogether +} + +#[derive(PartialEq, Clone, Debug)] +pub enum CacheExpiry { + Never, + AtUnixTimestamp(Duration, Option), +} + +impl CacheExpiry { + pub fn expire_in_seconds(seconds: u64, etag: Option) -> Self { + let timestamp = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap(); + Self::AtUnixTimestamp(timestamp + Duration::new(seconds, 0), etag) + } + + fn is_expired(&self) -> bool { + match self { + Self::Never => false, + Self::AtUnixTimestamp(ref duration, _) => { + let now = &SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap(); + now > duration + } + } + } + + fn etag(&self) -> Option<&String> { + match self { + Self::Never => None, + Self::AtUnixTimestamp(_, ref etag) => etag.as_ref(), + } + } +} + +#[derive(Clone)] +pub struct CacheManager { + root: PathBuf, +} + +impl CacheManager { + pub fn for_dir(dir: &str) -> Option { + let root: PathBuf = glib::user_cache_dir().into(); + let root = root.join(dir); + let mask = 0o744; + + glib::mkdir_with_parents(&root, mask); + + Some(Self { root }) + } + + fn cache_path(&self, resource: &str) -> PathBuf { + self.root.join(resource) + } + + fn cache_meta_path(&self, resource: &str) -> PathBuf { + let full = resource.to_string() + EXPIRY_FILE_EXT; + self.root.join(full) + } +} + +impl CacheManager { + async fn read_expiry_file(&self, resource: &str) -> Result { + let expiry_file = self.cache_meta_path(resource); + match fs::read(&expiry_file).await { + Err(e) => match e.kind() { + io::ErrorKind::NotFound => Ok(CacheExpiry::Never), + _ => Err(CacheError::ReadError(e)), + }, + Ok(buffer) => { + const OFFSET: usize = size_of::(); + + let mut duration: [u8; OFFSET] = Default::default(); + duration.copy_from_slice(&buffer[..OFFSET]); + let duration = Duration::from_secs(u64::from_be_bytes(duration)); + + let etag = String::from_utf8(buffer[OFFSET..].to_vec()).ok(); + + Ok(CacheExpiry::AtUnixTimestamp(duration, etag)) + } + } + } + + pub async fn read_cache_file( + &self, + resource: &str, + policy: CachePolicy, + ) -> Result { + if matches!(policy, CachePolicy::IgnoreCached) { + return Ok(CacheFile::None); + } + + let path = self.cache_path(resource); + let (file, expiry) = join!(fs::read(&path), self.read_expiry_file(resource)); + + match (file, policy) { + (Ok(buf), CachePolicy::IgnoreExpiry) => Ok(CacheFile::Fresh(buf)), + (Ok(buf), CachePolicy::Revalidate) => { + let expiry = expiry.unwrap_or(CacheExpiry::Never); + let etag = expiry.etag().cloned(); + Ok(CacheFile::Expired(buf, etag)) + } + (Ok(buf), CachePolicy::Default) => { + let expiry = expiry?; + let etag = expiry.etag().cloned(); + Ok(if expiry.is_expired() { + CacheFile::Expired(buf, etag) + } else { + CacheFile::Fresh(buf) + }) + } + (_, CachePolicy::IgnoreCached) => Ok(CacheFile::None), + (Err(e), _) => match e.kind() { + io::ErrorKind::NotFound => Ok(CacheFile::None), + _ => Err(CacheError::ReadError(e)), + }, + } + } +} + +impl CacheManager { + async fn set_expiry_for_path( + &self, + path: &PathBuf, + expiry: CacheExpiry, + ) -> Result<(), CacheError> { + if let CacheExpiry::AtUnixTimestamp(duration, etag) = expiry { + let mut content = duration.as_secs().to_be_bytes().to_vec(); + if let Some(etag) = etag { + content.append(&mut etag.into_bytes()); + } + fs::write(path, content) + .await + .map_err(CacheError::WriteError)?; + } + Ok(()) + } + + pub async fn clear_cache_pattern(&self, regex: &Regex) -> Result<(), CacheError> { + let mut entries = fs::read_dir(&self.root) + .await + .map_err(CacheError::ReadError)?; + + while let Some(Ok(entry)) = entries.next().await { + let matches = entry + .file_name() + .to_str() + .map(|s| regex.is_match(s)) + .unwrap_or(false); + if matches { + info!("Removing {}...", entry.file_name().to_str().unwrap_or("")); + fs::remove_file(entry.path()) + .await + .map_err(CacheError::RemoveError)?; + if let Some(expiry_file_path) = entry + .path() + .to_str() + .map(|path| path.to_string() + EXPIRY_FILE_EXT) + { + let _ = fs::remove_file(Path::new(&expiry_file_path)).await; + } + } + } + + Ok(()) + } + + pub async fn set_expired_pattern(&self, regex: &Regex) -> Result<(), CacheError> { + let mut entries = fs::read_dir(&self.root) + .await + .map_err(CacheError::ReadError)?; + + while let Some(Ok(entry)) = entries.next().await { + let matches = entry + .file_name() + .to_str() + .and_then(|s| s.strip_suffix(EXPIRY_FILE_EXT)) + .map(|s| regex.is_match(s)) + .unwrap_or(false); + if matches { + self.set_expiry_for_path(&entry.path(), CacheExpiry::expire_in_seconds(0, None)) + .await?; + } + } + + Ok(()) + } + + pub async fn write_cache_file( + &self, + resource: &str, + content: &[u8], + expiry: CacheExpiry, + ) -> Result<(), CacheError> { + let file = self.cache_path(resource); + let meta = self.cache_meta_path(resource); + let (r1, r2) = join!( + fs::write(&file, content), + self.set_expiry_for_path(&meta, expiry) + ); + r1.map_err(CacheError::WriteError)?; + r2?; + Ok(()) + } + + pub async fn get_or_write( + &self, + resource: &str, + policy: CachePolicy, + fetch: F, + ) -> Result, E> + where + O: Future>, + F: FnOnce(Option) -> O, + E: From, + { + let file = self.read_cache_file(resource, policy).await?; + match file { + CacheFile::Fresh(buf) => Ok(buf), + CacheFile::Expired(buf, etag) => match fetch(etag).await? { + FetchResult::NotModified(expiry) => { + let meta = self.cache_meta_path(resource); + self.set_expiry_for_path(&meta, expiry).await?; + Ok(buf) + } + FetchResult::Modified(fresh, expiry) => { + self.write_cache_file(resource, &fresh, expiry).await?; + Ok(fresh) + } + }, + CacheFile::None => match fetch(None).await? { + FetchResult::NotModified(_) => Err(E::from(CacheError::NoContent)), + FetchResult::Modified(fresh, expiry) => { + self.write_cache_file(resource, &fresh, expiry).await?; + Ok(fresh) + } + }, + } + } +} + +pub enum FetchResult { + NotModified(CacheExpiry), + Modified(Vec, CacheExpiry), +} diff --git a/src/api/cached_client.rs b/src/api/cached_client.rs new file mode 100644 index 0000000..16b87ff --- /dev/null +++ b/src/api/cached_client.rs @@ -0,0 +1,876 @@ +use futures::future::BoxFuture; +use futures::{join, FutureExt}; +use regex::Regex; +use serde::de::DeserializeOwned; +use serde_json::from_slice; +use std::convert::Into; +use std::future::Future; + +use super::cache::{CacheExpiry, CacheManager, CachePolicy, FetchResult}; +use super::client::*; +use crate::app::models::*; + +pub type SpotifyResult = Result; + +pub trait SpotifyApiClient { + fn get_artist(&self, id: &str) -> BoxFuture>; + + fn get_album(&self, id: &str) -> BoxFuture>; + + fn get_album_tracks( + &self, + id: &str, + offset: usize, + limit: usize, + ) -> BoxFuture>; + + fn get_playlist(&self, id: &str) -> BoxFuture>; + + fn get_playlist_tracks( + &self, + id: &str, + offset: usize, + limit: usize, + ) -> BoxFuture>; + + fn get_saved_albums( + &self, + offset: usize, + limit: usize, + ) -> BoxFuture>>; + + fn get_saved_tracks(&self, offset: usize, limit: usize) -> BoxFuture>; + + fn save_album(&self, id: &str) -> BoxFuture>; + + fn save_tracks(&self, ids: Vec) -> BoxFuture>; + + fn remove_saved_album(&self, id: &str) -> BoxFuture>; + + fn remove_saved_tracks(&self, ids: Vec) -> BoxFuture>; + + fn get_saved_playlists( + &self, + offset: usize, + limit: usize, + ) -> BoxFuture>>; + + fn add_to_playlist(&self, id: &str, uris: Vec) -> BoxFuture>; + + fn create_new_playlist( + &self, + name: &str, + user_id: &str, + ) -> BoxFuture>; + + fn remove_from_playlist(&self, id: &str, uris: Vec) -> BoxFuture>; + + fn update_playlist_details(&self, id: &str, name: String) -> BoxFuture>; + + fn search( + &self, + query: &str, + offset: usize, + limit: usize, + ) -> BoxFuture>; + + fn get_artist_albums( + &self, + id: &str, + offset: usize, + limit: usize, + ) -> BoxFuture>>; + + fn get_user(&self, id: &str) -> BoxFuture>; + + fn get_user_playlists( + &self, + id: &str, + offset: usize, + limit: usize, + ) -> BoxFuture>>; + + fn list_available_devices(&self) -> BoxFuture>>; + + fn get_player_queue(&self) -> BoxFuture>>; + + fn update_token(&self, token: String); + + fn player_pause(&self, device_id: String) -> BoxFuture>; + + fn player_resume(&self, device_id: String) -> BoxFuture>; + + #[allow(dead_code)] + fn player_next(&self, device_id: String) -> BoxFuture>; + + fn player_seek(&self, device_id: String, pos: usize) -> BoxFuture>; + + fn player_repeat(&self, device_id: String, mode: RepeatMode) -> BoxFuture>; + + fn player_shuffle(&self, device_id: String, shuffle: bool) -> BoxFuture>; + + fn player_volume(&self, device_id: String, volume: u8) -> BoxFuture>; + + fn player_play_in_context( + &self, + device_id: String, + context: String, + offset: usize, + ) -> BoxFuture>; + + fn player_play_no_context( + &self, + device_id: String, + uris: Vec, + offset: usize, + ) -> BoxFuture>; + + fn player_state(&self) -> BoxFuture>; +} + +enum SpotCacheKey<'a> { + SavedAlbums(usize, usize), + SavedTracks(usize, usize), + SavedPlaylists(usize, usize), + Album(&'a str), + AlbumLiked(&'a str), + AlbumTracks(&'a str, usize, usize), + Playlist(&'a str), + PlaylistTracks(&'a str, usize, usize), + ArtistAlbums(&'a str, usize, usize), + Artist(&'a str), + ArtistTopTracks(&'a str), + User(&'a str), + UserPlaylists(&'a str, usize, usize), +} + +impl<'a> SpotCacheKey<'a> { + fn into_raw(self) -> String { + match self { + Self::SavedAlbums(offset, limit) => format!("me_albums_{offset}_{limit}.json"), + Self::SavedTracks(offset, limit) => format!("me_tracks_{offset}_{limit}.json"), + Self::SavedPlaylists(offset, limit) => format!("me_playlists_{offset}_{limit}.json"), + Self::Album(id) => format!("album_{id}.json"), + Self::AlbumTracks(id, offset, limit) => { + format!("album_item_{id}_{offset}_{limit}.json") + } + Self::AlbumLiked(id) => format!("album_liked_{id}.json"), + Self::Playlist(id) => format!("playlist_{id}.json"), + Self::PlaylistTracks(id, offset, limit) => { + format!("playlist_item_{id}_{offset}_{limit}.json") + } + Self::ArtistAlbums(id, offset, limit) => { + format!("artist_albums_{id}_{offset}_{limit}.json") + } + Self::Artist(id) => format!("artist_{id}.json"), + Self::ArtistTopTracks(id) => format!("artist_top_tracks_{id}.json"), + Self::User(id) => format!("user_{id}.json"), + Self::UserPlaylists(id, offset, limit) => { + format!("user_playlists_{id}_{offset}_{limit}.json") + } + } + } +} + +lazy_static! { + pub static ref ME_TRACKS_CACHE: Regex = Regex::new(r"^me_tracks_\w+_\w+\.json$").unwrap(); + pub static ref ME_ALBUMS_CACHE: Regex = Regex::new(r"^me_albums_\w+_\w+\.json$").unwrap(); + pub static ref USER_CACHE: Regex = + Regex::new(r"^me_(albums|playlists|tracks)_\w+_\w+\.json$").unwrap(); +} + +fn playlist_cache_key(id: &str) -> Regex { + Regex::new(&format!(r"^playlist(_{id}|item_{id}_\w+_\w+)\.json$")).unwrap() +} + +pub struct CachedSpotifyClient { + client: SpotifyClient, + cache: CacheManager, +} + +impl CachedSpotifyClient { + pub fn new() -> CachedSpotifyClient { + CachedSpotifyClient { + client: SpotifyClient::new(), + cache: CacheManager::for_dir("spot/net").unwrap(), + } + } + + fn default_cache_policy(&self) -> CachePolicy { + if self.client.has_token() { + CachePolicy::Default + } else { + CachePolicy::IgnoreExpiry + } + } + + async fn wrap_write(write: &F, etag: Option) -> SpotifyResult + where + O: Future>>, + F: Fn(Option) -> O, + { + write(etag) + .map(|r| { + let SpotifyResponse { + kind, + max_age, + etag, + } = r?; + let expiry = CacheExpiry::expire_in_seconds(max_age, etag); + SpotifyResult::Ok(match kind { + SpotifyResponseKind::Ok(content, _) => { + FetchResult::Modified(content.into_bytes(), expiry) + } + SpotifyResponseKind::NotModified => FetchResult::NotModified(expiry), + }) + }) + .await + } + + async fn cache_get_or_write( + &self, + key: SpotCacheKey<'_>, + cache_policy: Option, + write: F, + ) -> SpotifyResult + where + O: Future>>, + F: Fn(Option) -> O, + T: DeserializeOwned, + { + let write = &write; + let cache_key = key.into_raw(); + let raw = self + .cache + .get_or_write( + &cache_key, + cache_policy.unwrap_or_else(|| self.default_cache_policy()), + |etag| Self::wrap_write(write, etag), + ) + .await?; + + let result = from_slice::(&raw); + match result { + Ok(t) => Ok(t), + // parsing failed: cache is likely invalid, request again, ignoring cache + Err(e) => { + dbg!(&cache_key, e); + let new_raw = self + .cache + .get_or_write(&cache_key, CachePolicy::IgnoreCached, |etag| { + Self::wrap_write(write, etag) + }) + .await?; + Ok(from_slice::(&new_raw)?) + } + } + } +} + +impl SpotifyApiClient for CachedSpotifyClient { + fn update_token(&self, new_token: String) { + self.client.update_token(new_token) + } + + fn get_saved_albums( + &self, + offset: usize, + limit: usize, + ) -> BoxFuture>> { + Box::pin(async move { + let page = self + .cache_get_or_write(SpotCacheKey::SavedAlbums(offset, limit), None, |etag| { + self.client + .get_saved_albums(offset, limit) + .etag(etag) + .send() + }) + .await?; + + let albums = page + .into_iter() + .map(|saved| saved.album.into()) + .collect::>(); + + Ok(albums) + }) + } + + fn get_saved_tracks(&self, offset: usize, limit: usize) -> BoxFuture> { + Box::pin(async move { + let page = self + .cache_get_or_write(SpotCacheKey::SavedTracks(offset, limit), None, |etag| { + self.client + .get_saved_tracks(offset, limit) + .etag(etag) + .send() + }) + .await?; + + Ok(page.into()) + }) + } + + fn get_saved_playlists( + &self, + offset: usize, + limit: usize, + ) -> BoxFuture>> { + Box::pin(async move { + let page = self + .cache_get_or_write(SpotCacheKey::SavedPlaylists(offset, limit), None, |etag| { + self.client + .get_saved_playlists(offset, limit) + .etag(etag) + .send() + }) + .await?; + + let albums = page + .into_iter() + .map(|playlist| playlist.into()) + .collect::>(); + + Ok(albums) + }) + } + + fn add_to_playlist(&self, id: &str, uris: Vec) -> BoxFuture> { + let id = id.to_owned(); + + Box::pin(async move { + self.cache + .set_expired_pattern(&playlist_cache_key(&id)) + .await + .unwrap_or(()); + + self.client + .add_to_playlist(&id, uris) + .send_no_response() + .await?; + Ok(()) + }) + } + + fn create_new_playlist( + &self, + name: &str, + user_id: &str, + ) -> BoxFuture> { + let name = name.to_owned(); + let user_id = user_id.to_owned(); + + Box::pin(async move { + let playlist = self + .client + .create_new_playlist(&name, &user_id) + .send() + .await? + .deserialize() + .unwrap(); + + Ok(playlist.into()) + }) + } + + fn remove_from_playlist(&self, id: &str, uris: Vec) -> BoxFuture> { + let id = id.to_owned(); + + Box::pin(async move { + self.cache + .set_expired_pattern(&playlist_cache_key(&id)) + .await + .unwrap_or(()); + + self.client + .remove_from_playlist(&id, uris) + .send_no_response() + .await?; + Ok(()) + }) + } + + fn update_playlist_details(&self, id: &str, name: String) -> BoxFuture> { + let id = id.to_owned(); + + Box::pin(async move { + self.cache + .set_expired_pattern(&playlist_cache_key(&id)) + .await + .unwrap_or(()); + + self.client + .update_playlist_details(&id, name) + .send_no_response() + .await?; + + Ok(()) + }) + } + + fn get_album(&self, id: &str) -> BoxFuture> { + let id = id.to_owned(); + + Box::pin(async move { + let album = self.cache_get_or_write(SpotCacheKey::Album(&id), None, |etag| { + self.client.get_album(&id).etag(etag).send() + }); + + let liked = self.cache_get_or_write( + SpotCacheKey::AlbumLiked(&id), + Some(if self.client.has_token() { + CachePolicy::Revalidate + } else { + CachePolicy::IgnoreExpiry + }), + |etag| self.client.is_album_saved(&id).etag(etag).send(), + ); + + let (album, liked) = join!(album, liked); + + let mut album: AlbumFullDescription = album?.into(); + album.description.is_liked = liked?[0]; + + Ok(album) + }) + } + + fn save_album(&self, id: &str) -> BoxFuture> { + let id = id.to_owned(); + + Box::pin(async move { + let _ = self.cache.set_expired_pattern(&ME_ALBUMS_CACHE).await; + self.client.save_album(&id).send_no_response().await?; + self.get_album(&id[..]).await.map(|a| a.description) + }) + } + + fn save_tracks(&self, ids: Vec) -> BoxFuture> { + Box::pin(async move { + let _ = self.cache.set_expired_pattern(&ME_TRACKS_CACHE).await; + self.client.save_tracks(ids).send_no_response().await?; + Ok(()) + }) + } + + fn remove_saved_album(&self, id: &str) -> BoxFuture> { + let id = id.to_owned(); + + Box::pin(async move { + let _ = self.cache.set_expired_pattern(&ME_ALBUMS_CACHE).await; + self.client.remove_saved_album(&id).send_no_response().await + }) + } + + fn remove_saved_tracks(&self, ids: Vec) -> BoxFuture> { + Box::pin(async move { + let _ = self.cache.set_expired_pattern(&ME_TRACKS_CACHE).await; + self.client + .remove_saved_tracks(ids) + .send_no_response() + .await + }) + } + + fn get_album_tracks( + &self, + id: &str, + offset: usize, + limit: usize, + ) -> BoxFuture> { + let id = id.to_owned(); + + Box::pin(async move { + let album = self.cache_get_or_write( + SpotCacheKey::Album(&id), + Some(CachePolicy::IgnoreExpiry), + |etag| self.client.get_album(&id).etag(etag).send(), + ); + + let songs = self.cache_get_or_write( + SpotCacheKey::AlbumTracks(&id, offset, limit), + None, + |etag| { + self.client + .get_album_tracks(&id, offset, limit) + .etag(etag) + .send() + }, + ); + + let (album, songs) = join!(album, songs); + Ok((songs?, &album?.album).into()) + }) + } + + fn get_playlist(&self, id: &str) -> BoxFuture> { + let id = id.to_owned(); + + Box::pin(async move { + let playlist = self + .cache_get_or_write(SpotCacheKey::Playlist(&id), None, |etag| { + self.client.get_playlist(&id).etag(etag).send() + }) + .await?; + + Ok(playlist.into()) + }) + } + + fn get_playlist_tracks( + &self, + id: &str, + offset: usize, + limit: usize, + ) -> BoxFuture> { + let id = id.to_owned(); + + Box::pin(async move { + let songs = self + .cache_get_or_write( + SpotCacheKey::PlaylistTracks(&id, offset, limit), + None, + |etag| { + self.client + .get_playlist_tracks(&id, offset, limit) + .etag(etag) + .send() + }, + ) + .await?; + + Ok(songs.into()) + }) + } + + fn get_artist_albums( + &self, + id: &str, + offset: usize, + limit: usize, + ) -> BoxFuture>> { + let id = id.to_owned(); + + Box::pin(async move { + let albums = self + .cache_get_or_write( + SpotCacheKey::ArtistAlbums(&id, offset, limit), + None, + |etag| { + self.client + .get_artist_albums(&id, offset, limit) + .etag(etag) + .send() + }, + ) + .await?; + + let albums = albums + .into_iter() + .map(|a| a.into()) + .collect::>(); + + Ok(albums) + }) + } + + fn get_artist(&self, id: &str) -> BoxFuture> { + let id = id.to_owned(); + + Box::pin(async move { + let artist = self.cache_get_or_write(SpotCacheKey::Artist(&id), None, |etag| { + self.client.get_artist(&id).etag(etag).send() + }); + + let albums = self.get_artist_albums(&id, 0, 20); + + let top_tracks = + self.cache_get_or_write(SpotCacheKey::ArtistTopTracks(&id), None, |etag| { + self.client.get_artist_top_tracks(&id).etag(etag).send() + }); + + let (artist, albums, top_tracks) = join!(artist, albums, top_tracks); + + let artist = artist?; + let result = ArtistDescription { + id: artist.id, + name: artist.name, + albums: albums?, + top_tracks: top_tracks?.into(), + }; + Ok(result) + }) + } + + fn search( + &self, + query: &str, + offset: usize, + limit: usize, + ) -> BoxFuture> { + let query = query.to_owned(); + + Box::pin(async move { + let results = self + .client + .search(query, offset, limit) + .send() + .await? + .deserialize() + .ok_or(SpotifyApiError::NoContent)?; + + let albums = results + .albums + .unwrap_or_default() + .into_iter() + .map(|saved| saved.into()) + .collect::>(); + + let artists = results + .artists + .unwrap_or_default() + .into_iter() + .map(|saved| saved.into()) + .collect::>(); + + Ok(SearchResults { albums, artists }) + }) + } + + fn get_user_playlists( + &self, + id: &str, + offset: usize, + limit: usize, + ) -> BoxFuture>> { + let id = id.to_owned(); + + Box::pin(async move { + let playlists = self + .cache_get_or_write( + SpotCacheKey::UserPlaylists(&id, offset, limit), + None, + |etag| { + self.client + .get_user_playlists(&id, offset, limit) + .etag(etag) + .send() + }, + ) + .await?; + + let playlists = playlists + .into_iter() + .map(|a| a.into()) + .collect::>(); + + Ok(playlists) + }) + } + + fn get_user(&self, id: &str) -> BoxFuture> { + let id = id.to_owned(); + + Box::pin(async move { + let user = self.cache_get_or_write(SpotCacheKey::User(&id), None, |etag| { + self.client.get_user(&id).etag(etag).send() + }); + + let playlists = self.get_user_playlists(&id, 0, 30); + + let (user, playlists) = join!(user, playlists); + + let user = user?; + let result = UserDescription { + id: user.id, + name: user.display_name, + playlists: playlists?, + }; + Ok(result) + }) + } + + fn list_available_devices(&self) -> BoxFuture>> { + Box::pin(async move { + let devices = self + .client + .get_player_devices() + .send() + .await? + .deserialize() + .ok_or(SpotifyApiError::NoContent)?; + Ok(devices + .devices + .into_iter() + .filter(|d| { + debug!("found device: {:?}", d); + !d.is_restricted + }) + .map(ConnectDevice::from) + .collect()) + }) + } + + fn get_player_queue(&self) -> BoxFuture>> { + Box::pin(async move { + let queue = self + .client + .get_player_queue() + .send() + .await? + .deserialize() + .ok_or(SpotifyApiError::NoContent)?; + Ok(queue.into()) + }) + } + + fn player_pause(&self, device_id: String) -> BoxFuture> { + Box::pin(self.client.player_pause(&device_id).send_no_response()) + } + + fn player_resume(&self, device_id: String) -> BoxFuture> { + Box::pin(self.client.player_resume(&device_id).send_no_response()) + } + + fn player_play_in_context( + &self, + device_id: String, + context_uri: String, + offset: usize, + ) -> BoxFuture> { + Box::pin( + self.client + .player_set_playing( + &device_id, + PlayRequest::Contextual { + context_uri, + offset: PlayOffset { + position: offset as u32, + }, + }, + ) + .send_no_response(), + ) + } + + fn player_play_no_context( + &self, + device_id: String, + uris: Vec, + offset: usize, + ) -> BoxFuture> { + Box::pin( + self.client + .player_set_playing( + &device_id, + PlayRequest::Uris { + uris, + offset: PlayOffset { + position: offset as u32, + }, + }, + ) + .send_no_response(), + ) + } + + fn player_next(&self, device_id: String) -> BoxFuture> { + Box::pin(self.client.player_next(&device_id).send_no_response()) + } + + fn player_seek(&self, device_id: String, pos: usize) -> BoxFuture> { + Box::pin(self.client.player_seek(&device_id, pos).send_no_response()) + } + + fn player_state(&self) -> BoxFuture> { + Box::pin(async move { + let result = self + .client + .player_state() + .send() + .await? + .deserialize() + .ok_or(SpotifyApiError::NoContent)?; + Ok(result.into()) + }) + } + + fn player_repeat(&self, device_id: String, mode: RepeatMode) -> BoxFuture> { + Box::pin( + self.client + .player_repeat( + &device_id, + match mode { + RepeatMode::Song => "track", + RepeatMode::Playlist => "context", + RepeatMode::None => "off", + }, + ) + .send_no_response(), + ) + } + + fn player_shuffle(&self, device_id: String, shuffle: bool) -> BoxFuture> { + Box::pin( + self.client + .player_shuffle(&device_id, shuffle) + .send_no_response(), + ) + } + + fn player_volume(&self, device_id: String, volume: u8) -> BoxFuture> { + Box::pin( + self.client + .player_volume(&device_id, volume) + .send_no_response(), + ) + } +} + +#[cfg(test)] +pub mod tests { + + use crate::api::api_models::*; + + #[test] + fn test_search_query() { + let query = SearchQuery { + query: "test".to_string(), + types: vec![SearchType::Album, SearchType::Artist], + limit: 5, + offset: 0, + }; + + assert_eq!( + query.into_query_string(), + "type=album,artist&q=test&offset=0&limit=5&market=from_token" + ); + } + + #[test] + fn test_search_query_spaces_and_stuff() { + let query = SearchQuery { + query: "test??? wow".to_string(), + types: vec![SearchType::Album], + limit: 5, + offset: 0, + }; + + assert_eq!( + query.into_query_string(), + "type=album&q=test+wow&offset=0&limit=5&market=from_token" + ); + } + + #[test] + fn test_search_query_encoding() { + let query = SearchQuery { + query: "кириллица".to_string(), + types: vec![SearchType::Album], + limit: 5, + offset: 0, + }; + + assert_eq!(query.into_query_string(), "type=album&q=%D0%BA%D0%B8%D1%80%D0%B8%D0%BB%D0%BB%D0%B8%D1%86%D0%B0&offset=0&limit=5&market=from_token"); + } +} diff --git a/src/api/client.rs b/src/api/client.rs new file mode 100644 index 0000000..b17d737 --- /dev/null +++ b/src/api/client.rs @@ -0,0 +1,716 @@ +use form_urlencoded::Serializer; +use isahc::config::Configurable; +use isahc::http::{method::Method, request::Builder, StatusCode, Uri}; +use isahc::{AsyncReadResponseExt, HttpClient, Request}; +use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; +use serde::{de::Deserialize, Serialize}; +use serde_json::from_str; +use std::convert::Into; +use std::marker::PhantomData; +use std::str::FromStr; +use std::sync::Mutex; +use thiserror::Error; + +pub use super::api_models::*; +use super::cache::CacheError; + +const SPOTIFY_HOST: &str = "api.spotify.com"; + +// https://url.spec.whatwg.org/#path-percent-encode-set +const PATH_ENCODE_SET: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'<') + .add(b'>') + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'}'); + +fn make_query_params<'a>() -> Serializer<'a, String> { + Serializer::new(String::new()) +} + +pub(crate) struct SpotifyRequest<'a, Body, Response> { + client: &'a SpotifyClient, + request: Builder, + body: Body, + _type: PhantomData, +} + +impl<'a, B, R> SpotifyRequest<'a, B, R> +where + B: Into, +{ + fn method(mut self, method: Method) -> Self { + self.request = self.request.method(method); + self + } + + fn uri(mut self, path: String, query: Option<&str>) -> Self { + let path_and_query = match query { + None => path, + Some(query) => format!("{path}?{query}"), + }; + let uri = Uri::builder() + .scheme("https") + .authority(SPOTIFY_HOST) + .path_and_query(&path_and_query[..]) + .build() + .unwrap(); + self.request = self.request.uri(uri); + self + } + + fn authenticated(mut self) -> Result { + let token = self.client.token.lock().unwrap(); + let token = token.as_ref().ok_or(SpotifyApiError::NoToken)?; + self.request = self + .request + .header("Authorization", format!("Bearer {token}")); + Ok(self) + } + + pub(crate) fn etag(mut self, etag: Option) -> Self { + if let Some(etag) = etag { + self.request = self.request.header("If-None-Match", etag); + } + self + } + + pub(crate) fn json_body(self, body: NewBody) -> SpotifyRequest<'a, Vec, R> + where + NewBody: Serialize, + { + let Self { + client, + request, + _type, + .. + } = self; + SpotifyRequest { + client, + request: request.header("Content-Type", "application/json"), + body: serde_json::to_vec(&body).unwrap(), + _type, + } + } + + pub(crate) async fn send(self) -> Result, SpotifyApiError> { + let Self { + client, + request, + body, + .. + } = self.authenticated()?; + client.send_req(request.body(body).unwrap()).await + } + + pub(crate) async fn send_no_response(self) -> Result<(), SpotifyApiError> { + let Self { + client, + request, + body, + .. + } = self.authenticated()?; + client + .send_req_no_response(request.body(body).unwrap()) + .await + } +} + +pub(crate) enum SpotifyResponseKind { + Ok(String, PhantomData), + NotModified, +} + +pub(crate) struct SpotifyResponse { + pub kind: SpotifyResponseKind, + pub max_age: u64, + pub etag: Option, +} + +impl<'a, T> SpotifyResponse +where + T: Deserialize<'a>, +{ + pub(crate) fn deserialize(&'a self) -> Option { + if let SpotifyResponseKind::Ok(ref content, _) = self.kind { + from_str(content) + .map_err(|e| error!("Deserialization failed: {}", e)) + .ok() + } else { + None + } + } +} + +#[derive(Error, Debug)] +pub enum SpotifyApiError { + #[error("Invalid token")] + InvalidToken, + #[error("No token")] + NoToken, + #[error("No content from request")] + NoContent, + #[error("Request rate exceeded")] + TooManyRequests, + #[error("Request failed ({0}): {1}")] + BadStatus(u16, String), + #[error(transparent)] + ClientError(#[from] isahc::Error), + #[error(transparent)] + IoError(#[from] std::io::Error), + #[error(transparent)] + CacheError(#[from] CacheError), + #[error(transparent)] + ParseError(#[from] serde_json::Error), + #[error(transparent)] + ConversionError(#[from] std::string::FromUtf8Error), +} + +pub(crate) struct SpotifyClient { + token: Mutex>, + client: HttpClient, +} + +impl SpotifyClient { + pub(crate) fn new() -> Self { + let mut builder = HttpClient::builder(); + if cfg!(debug_assertions) { + builder = builder.ssl_options(isahc::config::SslOption::DANGER_ACCEPT_INVALID_CERTS); + } + let client = builder.build().unwrap(); + Self { + token: Mutex::new(None), + client, + } + } + + pub(crate) fn request(&self) -> SpotifyRequest<'_, (), T> { + SpotifyRequest { + client: self, + request: Builder::new(), + body: (), + _type: PhantomData, + } + } + + pub(crate) fn has_token(&self) -> bool { + self.token.lock().unwrap().is_some() + } + + pub(crate) fn update_token(&self, new_token: String) { + if let Ok(mut token) = self.token.lock() { + *token = Some(new_token) + } + } + + fn clear_token(&self) { + if let Ok(mut token) = self.token.lock() { + *token = None + } + } + + fn parse_cache_control(cache_control: &str) -> Option { + cache_control + .split(',') + .find(|s| s.trim().starts_with("max-age=")) + .and_then(|s| s.split('=').nth(1)) + .and_then(|s| u64::from_str(s).ok()) + } + + async fn send_req( + &self, + request: Request, + ) -> Result, SpotifyApiError> + where + B: Into, + { + let mut result = self.client.send_async(request).await?; + + let etag = result + .headers() + .get("etag") + .and_then(|header| header.to_str().ok()) + .map(|s| s.to_owned()); + + let cache_control = result + .headers() + .get("cache-control") + .and_then(|header| header.to_str().ok()) + .and_then(Self::parse_cache_control); + + match result.status() { + StatusCode::NO_CONTENT => Err(SpotifyApiError::NoContent), + s if s.is_success() => Ok(SpotifyResponse { + kind: SpotifyResponseKind::Ok(result.text().await?, PhantomData), + max_age: cache_control.unwrap_or(10), + etag, + }), + StatusCode::UNAUTHORIZED => { + self.clear_token(); + Err(SpotifyApiError::InvalidToken) + } + StatusCode::TOO_MANY_REQUESTS => Err(SpotifyApiError::TooManyRequests), + StatusCode::NOT_MODIFIED => Ok(SpotifyResponse { + kind: SpotifyResponseKind::NotModified, + max_age: cache_control.unwrap_or(10), + etag, + }), + s => Err(SpotifyApiError::BadStatus( + s.as_u16(), + result + .text() + .await + .unwrap_or_else(|_| "(no details available)".to_string()), + )), + } + } + + async fn send_req_no_response(&self, request: Request) -> Result<(), SpotifyApiError> + where + B: Into, + { + let mut result = self.client.send_async(request).await?; + match result.status() { + StatusCode::UNAUTHORIZED => { + self.clear_token(); + Err(SpotifyApiError::InvalidToken) + } + StatusCode::TOO_MANY_REQUESTS => Err(SpotifyApiError::TooManyRequests), + StatusCode::NOT_MODIFIED => Ok(()), + s if s.is_success() => Ok(()), + s => Err(SpotifyApiError::BadStatus( + s.as_u16(), + result + .text() + .await + .unwrap_or_else(|_| "(no details available)".to_string()), + )), + } + } +} + +impl SpotifyClient { + pub(crate) fn get_artist(&self, id: &str) -> SpotifyRequest<'_, (), Artist> { + self.request() + .method(Method::GET) + .uri(format!("/v1/artists/{id}"), None) + } + + pub(crate) fn get_artist_albums( + &self, + id: &str, + offset: usize, + limit: usize, + ) -> SpotifyRequest<'_, (), Page> { + let query = make_query_params() + .append_pair("include_groups", "album,single") + .append_pair("country", "from_token") + .append_pair("offset", &offset.to_string()[..]) + .append_pair("limit", &limit.to_string()[..]) + .finish(); + + self.request() + .method(Method::GET) + .uri(format!("/v1/artists/{id}/albums"), Some(&query)) + } + + pub(crate) fn get_artist_top_tracks(&self, id: &str) -> SpotifyRequest<'_, (), TopTracks> { + let query = make_query_params() + .append_pair("market", "from_token") + .finish(); + + self.request() + .method(Method::GET) + .uri(format!("/v1/artists/{id}/top-tracks"), Some(&query)) + } + + pub(crate) fn is_album_saved(&self, id: &str) -> SpotifyRequest<'_, (), Vec> { + let query = make_query_params().append_pair("ids", id).finish(); + self.request() + .method(Method::GET) + .uri("/v1/me/albums/contains".to_string(), Some(&query)) + } + + pub(crate) fn save_album(&self, id: &str) -> SpotifyRequest<'_, (), ()> { + let query = make_query_params().append_pair("ids", id).finish(); + self.request() + .method(Method::PUT) + .uri("/v1/me/albums".to_string(), Some(&query)) + } + + pub(crate) fn save_tracks(&self, ids: Vec) -> SpotifyRequest<'_, Vec, ()> { + self.request() + .method(Method::PUT) + .uri("/v1/me/tracks".to_string(), None) + .json_body(Ids { ids }) + } + + pub(crate) fn remove_saved_album(&self, id: &str) -> SpotifyRequest<'_, (), ()> { + let query = make_query_params().append_pair("ids", id).finish(); + self.request() + .method(Method::DELETE) + .uri("/v1/me/albums".to_string(), Some(&query)) + } + + pub(crate) fn remove_saved_tracks(&self, ids: Vec) -> SpotifyRequest<'_, Vec, ()> { + self.request() + .method(Method::DELETE) + .uri("/v1/me/tracks".to_string(), None) + .json_body(Ids { ids }) + } + + pub(crate) fn get_album(&self, id: &str) -> SpotifyRequest<'_, (), FullAlbum> { + self.request() + .method(Method::GET) + .uri(format!("/v1/albums/{id}"), None) + } + + pub(crate) fn get_album_tracks( + &self, + id: &str, + offset: usize, + limit: usize, + ) -> SpotifyRequest<'_, (), Page> { + let query = make_query_params() + .append_pair("offset", &offset.to_string()[..]) + .append_pair("limit", &limit.to_string()[..]) + .finish(); + + self.request() + .method(Method::GET) + .uri(format!("/v1/albums/{id}/tracks"), Some(&query)) + } + + pub(crate) fn get_playlist(&self, id: &str) -> SpotifyRequest<'_, (), Playlist> { + let query = make_query_params() + .append_pair( + "fields", + "id,name,images,owner,tracks(total,items(is_local,track(name,id,uri,duration_ms,artists(name,id),album(name,id,images,artists))))", + ) + .finish(); + self.request() + .method(Method::GET) + .uri(format!("/v1/playlists/{id}"), Some(&query)) + } + + pub(crate) fn get_playlist_tracks( + &self, + id: &str, + offset: usize, + limit: usize, + ) -> SpotifyRequest<'_, (), Page> { + let query = make_query_params() + .append_pair("offset", &offset.to_string()[..]) + .append_pair("limit", &limit.to_string()[..]) + .finish(); + + self.request() + .method(Method::GET) + .uri(format!("/v1/playlists/{id}/tracks"), Some(&query)) + } + + pub(crate) fn add_to_playlist( + &self, + playlist: &str, + uris: Vec, + ) -> SpotifyRequest<'_, Vec, ()> { + self.request() + .method(Method::POST) + .uri(format!("/v1/playlists/{playlist}/tracks"), None) + .json_body(Uris { uris }) + } + + pub(crate) fn create_new_playlist( + &self, + name: &str, + user_id: &str, + ) -> SpotifyRequest<'_, Vec, Playlist> { + self.request() + .method(Method::POST) + .uri(format!("/v1/users/{user_id}/playlists"), None) + .json_body(Name { name }) + } + + pub(crate) fn remove_from_playlist( + &self, + playlist: &str, + uris: Vec, + ) -> SpotifyRequest<'_, Vec, ()> { + self.request() + .method(Method::DELETE) + .uri(format!("/v1/playlists/{playlist}/tracks"), None) + .json_body(Uris { uris }) + } + + pub(crate) fn update_playlist_details( + &self, + playlist: &str, + name: String, + ) -> SpotifyRequest<'_, Vec, ()> { + self.request() + .method(Method::PUT) + .uri(format!("/v1/playlists/{playlist}"), None) + .json_body(PlaylistDetails { name }) + } + + pub(crate) fn get_saved_albums( + &self, + offset: usize, + limit: usize, + ) -> SpotifyRequest<'_, (), Page> { + let query = make_query_params() + .append_pair("offset", &offset.to_string()[..]) + .append_pair("limit", &limit.to_string()[..]) + .finish(); + + self.request() + .method(Method::GET) + .uri("/v1/me/albums".to_string(), Some(&query)) + } + + pub(crate) fn get_saved_tracks( + &self, + offset: usize, + limit: usize, + ) -> SpotifyRequest<'_, (), Page> { + let query = make_query_params() + .append_pair("offset", &offset.to_string()[..]) + .append_pair("limit", &limit.to_string()[..]) + .finish(); + + self.request() + .method(Method::GET) + .uri("/v1/me/tracks".to_string(), Some(&query)) + } + + pub(crate) fn get_saved_playlists( + &self, + offset: usize, + limit: usize, + ) -> SpotifyRequest<'_, (), Page> { + let query = make_query_params() + .append_pair("offset", &offset.to_string()[..]) + .append_pair("limit", &limit.to_string()[..]) + .finish(); + + self.request() + .method(Method::GET) + .uri("/v1/me/playlists".to_string(), Some(&query)) + } + + pub(crate) fn search( + &self, + query: String, + offset: usize, + limit: usize, + ) -> SpotifyRequest<'_, (), RawSearchResults> { + let query = SearchQuery { + query, + types: vec![SearchType::Album, SearchType::Artist], + limit, + offset, + }; + + self.request() + .method(Method::GET) + .uri("/v1/search".to_string(), Some(&query.into_query_string())) + } + + pub(crate) fn get_user(&self, id: &str) -> SpotifyRequest<'_, (), User> { + let id = utf8_percent_encode(id, PATH_ENCODE_SET); + self.request() + .method(Method::GET) + .uri(format!("/v1/users/{id}"), None) + } + + pub(crate) fn get_user_playlists( + &self, + id: &str, + offset: usize, + limit: usize, + ) -> SpotifyRequest<'_, (), Page> { + let id = utf8_percent_encode(id, PATH_ENCODE_SET); + let query = make_query_params() + .append_pair("offset", &offset.to_string()[..]) + .append_pair("limit", &limit.to_string()[..]) + .finish(); + + self.request() + .method(Method::GET) + .uri(format!("/v1/users/{id}/playlists"), Some(&query)) + } + + pub(crate) fn get_player_devices(&self) -> SpotifyRequest<'_, (), Devices> { + self.request() + .method(Method::GET) + .uri("/v1/me/player/devices".to_string(), None) + } + + pub(crate) fn get_player_queue(&self) -> SpotifyRequest<'_, (), PlayerQueue> { + self.request() + .method(Method::GET) + .uri("/v1/me/player/queue".to_string(), None) + } + + pub(crate) fn player_state(&self) -> SpotifyRequest<'_, (), PlayerState> { + self.request() + .method(Method::GET) + .uri("/v1/me/player".to_string(), None) + } + + pub(crate) fn player_resume(&self, device_id: &str) -> SpotifyRequest<'_, (), ()> { + let query = make_query_params() + .append_pair("device_id", device_id) + .finish(); + self.request() + .method(Method::PUT) + .uri("/v1/me/player/play".to_string(), Some(&query)) + } + + pub(crate) fn player_set_playing( + &self, + device_id: &str, + request: PlayRequest, + ) -> SpotifyRequest<'_, Vec, ()> { + let query = make_query_params() + .append_pair("device_id", device_id) + .finish(); + self.request() + .method(Method::PUT) + .uri("/v1/me/player/play".to_string(), Some(&query)) + .json_body(request) + } + + pub(crate) fn player_pause(&self, device_id: &str) -> SpotifyRequest<'_, (), ()> { + let query = make_query_params() + .append_pair("device_id", device_id) + .finish(); + self.request() + .method(Method::PUT) + .uri("/v1/me/player/pause".to_string(), Some(&query)) + } + + pub(crate) fn player_next(&self, device_id: &str) -> SpotifyRequest<'_, (), ()> { + let query = make_query_params() + .append_pair("device_id", device_id) + .finish(); + self.request() + .method(Method::PUT) + .uri("/v1/me/player/next".to_string(), Some(&query)) + } + + pub(crate) fn player_seek(&self, device_id: &str, pos: usize) -> SpotifyRequest<'_, (), ()> { + let query = make_query_params() + .append_pair("device_id", device_id) + .append_pair("position_ms", &pos.to_string()[..]) + .finish(); + + self.request() + .method(Method::PUT) + .uri("/v1/me/player/seek".to_string(), Some(&query)) + } + + pub(crate) fn player_repeat(&self, device_id: &str, state: &str) -> SpotifyRequest<'_, (), ()> { + let query = make_query_params() + .append_pair("device_id", device_id) + .append_pair("state", state) + .finish(); + + self.request() + .method(Method::PUT) + .uri("/v1/me/player/repeat".to_string(), Some(&query)) + } + + pub(crate) fn player_shuffle( + &self, + device_id: &str, + shuffle: bool, + ) -> SpotifyRequest<'_, (), ()> { + let query = make_query_params() + .append_pair("device_id", device_id) + .append_pair("state", if shuffle { "true" } else { "false" }) + .finish(); + + self.request() + .method(Method::PUT) + .uri("/v1/me/player/shuffle".to_string(), Some(&query)) + } + + pub(crate) fn player_volume(&self, device_id: &str, volume: u8) -> SpotifyRequest<'_, (), ()> { + let query = make_query_params() + .append_pair("device_id", device_id) + .append_pair("volume_percent", &volume.to_string()) + .finish(); + + self.request() + .method(Method::PUT) + .uri("/v1/me/player/volume".to_string(), Some(&query)) + } +} + +#[cfg(test)] +pub mod tests { + + use super::*; + + #[test] + fn test_username_encoding() { + let username = "anna.lafuente❤"; + let client = SpotifyClient::new(); + let req = client.get_user(username); + assert_eq!( + req.request + .uri_ref() + .and_then(|u| u.path_and_query()) + .unwrap() + .as_str(), + "/v1/users/anna.lafuente%E2%9D%A4" + ); + } + + #[test] + fn test_search_query() { + let query = SearchQuery { + query: "test".to_string(), + types: vec![SearchType::Album, SearchType::Artist], + limit: 5, + offset: 0, + }; + + assert_eq!( + query.into_query_string(), + "type=album,artist&q=test&offset=0&limit=5&market=from_token" + ); + } + + #[test] + fn test_search_query_spaces_and_stuff() { + let query = SearchQuery { + query: "test??? wow".to_string(), + types: vec![SearchType::Album], + limit: 5, + offset: 0, + }; + + assert_eq!( + query.into_query_string(), + "type=album&q=test+wow&offset=0&limit=5&market=from_token" + ); + } + + #[test] + fn test_search_query_encoding() { + let query = SearchQuery { + query: "кириллица".to_string(), + types: vec![SearchType::Album], + limit: 5, + offset: 0, + }; + + assert_eq!(query.into_query_string(), "type=album&q=%D0%BA%D0%B8%D1%80%D0%B8%D0%BB%D0%BB%D0%B8%D1%86%D0%B0&offset=0&limit=5&market=from_token"); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..e2900d2 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,16 @@ +mod api_models; +mod cached_client; +mod client; + +pub mod cache; +pub(crate) mod oauth2; + +pub use cached_client::{CachedSpotifyClient, SpotifyApiClient, SpotifyResult}; +pub use client::SpotifyApiError; + +pub async fn clear_user_cache() -> Option<()> { + cache::CacheManager::for_dir("spot/net")? + .clear_cache_pattern(&cached_client::USER_CACHE) + .await + .ok() +} diff --git a/src/api/oauth2.rs b/src/api/oauth2.rs new file mode 100644 index 0000000..22ae6ad --- /dev/null +++ b/src/api/oauth2.rs @@ -0,0 +1,293 @@ +//! Provides a Spotify access token using the OAuth authorization code flow +//! with PKCE. +//! +//! Assuming sufficient scopes, the returned access token may be used with Spotify's +//! Web API, and/or to establish a new Session with [`librespot_core`]. +//! +//! The authorization code flow is an interactive process which requires a web browser +//! to complete. The resulting code must then be provided back from the browser to this +//! library for exchange into an access token. Providing the code can be automatic via +//! a spawned http server (mimicking Spotify's client), or manually via stdin. The latter +//! is appropriate for headless systems. + +use log::{error, info, trace}; +use oauth2::reqwest::http_client; +use oauth2::{ + basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, + RedirectUrl, Scope, TokenResponse, TokenUrl, +}; +use std::io; +use std::time::{Duration, Instant}; +use std::{ + io::{BufRead, BufReader, Write}, + net::{SocketAddr, TcpListener}, + sync::mpsc, +}; +use thiserror::Error; +use url::Url; + +#[derive(Debug, Error)] +pub enum OAuthError { + #[error("Unable to parse redirect URI {uri} ({e})")] + AuthCodeBadUri { uri: String, e: url::ParseError }, + + #[error("Auth code param not found in URI {uri}")] + AuthCodeNotFound { uri: String }, + + #[error("Failed to read redirect URI from stdin")] + AuthCodeStdinRead, + + #[error("Failed to bind server to {addr} ({e})")] + AuthCodeListenerBind { addr: SocketAddr, e: io::Error }, + + #[error("Listener terminated without accepting a connection")] + AuthCodeListenerTerminated, + + #[error("Failed to read redirect URI from HTTP request")] + AuthCodeListenerRead, + + #[error("Failed to parse redirect URI from HTTP request")] + AuthCodeListenerParse, + + #[error("Failed to write HTTP response")] + AuthCodeListenerWrite, + + #[error("Invalid Spotify OAuth URI")] + InvalidSpotifyUri, + + #[error("Invalid Redirect URI {uri} ({e})")] + InvalidRedirectUri { uri: String, e: url::ParseError }, + + #[error("Failed to receive code")] + Recv, + + #[error("Failed to exchange code for access token ({e})")] + ExchangeCode { e: String }, +} + +#[derive(Debug)] +pub struct OAuthToken { + pub access_token: String, + #[allow(dead_code)] + pub refresh_token: String, + pub expires_at: Instant, + #[allow(dead_code)] + pub token_type: String, + #[allow(dead_code)] + pub scopes: Vec, +} + +/// Return code query-string parameter from the redirect URI. +fn get_code(redirect_url: &str) -> Result { + let url = Url::parse(redirect_url).map_err(|e| OAuthError::AuthCodeBadUri { + uri: redirect_url.to_string(), + e, + })?; + let code = url + .query_pairs() + .find(|(key, _)| key == "code") + .map(|(_, code)| AuthorizationCode::new(code.into_owned())) + .ok_or(OAuthError::AuthCodeNotFound { + uri: redirect_url.to_string(), + })?; + + Ok(code) +} + +/// Prompt for redirect URI on stdin and return auth code. +fn get_authcode_stdin() -> Result { + println!("Provide redirect URL"); + let mut buffer = String::new(); + let stdin = io::stdin(); + stdin + .read_line(&mut buffer) + .map_err(|_| OAuthError::AuthCodeStdinRead)?; + + get_code(buffer.trim()) +} + +/// Spawn HTTP server at provided socket address to accept OAuth callback and return auth code. +fn get_authcode_listener(socket_address: SocketAddr) -> Result { + let listener = + TcpListener::bind(socket_address).map_err(|e| OAuthError::AuthCodeListenerBind { + addr: socket_address, + e, + })?; + info!("OAuth server listening on {:?}", socket_address); + + // The server will terminate itself after collecting the first code. + let mut stream = listener + .incoming() + .flatten() + .next() + .ok_or(OAuthError::AuthCodeListenerTerminated)?; + let mut reader = BufReader::new(&stream); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .map_err(|_| OAuthError::AuthCodeListenerRead)?; + + let redirect_url = request_line + .split_whitespace() + .nth(1) + .ok_or(OAuthError::AuthCodeListenerParse)?; + let code = get_code(&("http://localhost".to_string() + redirect_url)); + + let message = "Go back to your terminal :)"; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}", + message.len(), + message + ); + stream + .write_all(response.as_bytes()) + .map_err(|_| OAuthError::AuthCodeListenerWrite)?; + + code +} + +// If the specified `redirect_uri` is HTTP, loopback, and contains a port, +// then the corresponding socket address is returned. +fn get_socket_address(redirect_uri: &str) -> Option { + let url = match Url::parse(redirect_uri) { + Ok(u) if u.scheme() == "http" && u.port().is_some() => u, + _ => return None, + }; + let socket_addr = match url.socket_addrs(|| None) { + Ok(mut addrs) => addrs.pop(), + _ => None, + }; + if let Some(s) = socket_addr { + if s.ip().is_loopback() { + return socket_addr; + } + } + None +} + +/// Obtain a Spotify access token using the authorization code with PKCE OAuth flow. +/// The redirect_uri must match what is registered to the client ID. +pub fn get_access_token( + client_id: &str, + redirect_uri: &str, + scopes: Vec<&str>, +) -> Result { + let auth_url = AuthUrl::new("https://accounts.spotify.com/authorize".to_string()) + .map_err(|_| OAuthError::InvalidSpotifyUri)?; + let token_url = TokenUrl::new("https://accounts.spotify.com/api/token".to_string()) + .map_err(|_| OAuthError::InvalidSpotifyUri)?; + let redirect_url = + RedirectUrl::new(redirect_uri.to_string()).map_err(|e| OAuthError::InvalidRedirectUri { + uri: redirect_uri.to_string(), + e, + })?; + let client = BasicClient::new( + ClientId::new(client_id.to_string()), + None, + auth_url, + Some(token_url), + ) + .set_redirect_uri(redirect_url); + + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + // Generate the full authorization URL. + // Some of these scopes are unavailable for custom client IDs. Which? + let request_scopes: Vec = scopes + .clone() + .into_iter() + .map(|s| Scope::new(s.into())) + .collect(); + let (auth_url, _) = client + .authorize_url(CsrfToken::new_random) + .add_scopes(request_scopes) + .set_pkce_challenge(pkce_challenge) + .url(); + + println!("Browse to: {}", auth_url); + if let Err(err) = open::that(auth_url.to_string()) { + eprintln!("An error occurred when opening '{}': {}", auth_url, err) + } + + let code = match get_socket_address(redirect_uri) { + Some(addr) => get_authcode_listener(addr), + _ => get_authcode_stdin(), + }?; + trace!("Exchange {code:?} for access token"); + + // Do this sync in another thread because I am too stupid to make the async version work. + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let resp = client + .exchange_code(code) + .set_pkce_verifier(pkce_verifier) + .request(http_client); + if let Err(e) = tx.send(resp) { + error!("OAuth channel send error: {e}"); + } + }); + let token_response = rx.recv().map_err(|_| OAuthError::Recv)?; + let token = token_response.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + trace!("Obtained new access token: {token:?}"); + + let token_scopes: Vec = match token.scopes() { + Some(s) => s.iter().map(|s| s.to_string()).collect(), + _ => scopes.into_iter().map(|s| s.to_string()).collect(), + }; + let refresh_token = match token.refresh_token() { + Some(t) => t.secret().to_string(), + _ => "".to_string(), // Spotify always provides a refresh token. + }; + Ok(OAuthToken { + access_token: token.access_token().secret().to_string(), + refresh_token, + expires_at: Instant::now() + + token + .expires_in() + .unwrap_or_else(|| Duration::from_secs(3600)), + token_type: format!("{:?}", token.token_type()).to_string(), // Urgh!? + scopes: token_scopes, + }) +} + +#[cfg(test)] +mod test { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + + use super::*; + + #[test] + fn get_socket_address_none() { + // No port + assert_eq!(get_socket_address("http://127.0.0.1/foo"), None); + assert_eq!(get_socket_address("http://127.0.0.1:/foo"), None); + assert_eq!(get_socket_address("http://[::1]/foo"), None); + // Not localhost + assert_eq!(get_socket_address("http://56.0.0.1:1234/foo"), None); + assert_eq!( + get_socket_address("http://[3ffe:2a00:100:7031::1]:1234/foo"), + None + ); + // Not http + assert_eq!(get_socket_address("https://127.0.0.1/foo"), None); + } + + #[test] + fn get_socket_address_localhost() { + let localhost_v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 1234); + let localhost_v6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)), 8888); + + assert_eq!( + get_socket_address("http://127.0.0.1:1234/foo"), + Some(localhost_v4) + ); + assert_eq!( + get_socket_address("http://[0:0:0:0:0:0:0:1]:8888/foo"), + Some(localhost_v6) + ); + assert_eq!( + get_socket_address("http://[::1]:8888/foo"), + Some(localhost_v6) + ); + } +} diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..310d315 --- /dev/null +++ b/src/app.css @@ -0,0 +1,15 @@ +@define-color accent_bg_color @green_5; +@define-color accent_color @green_3; + +.container { + transition: opacity 0.3s ease; + opacity: 0; +} + +.container--loaded { + opacity: 1; +} + +.playlist__title-entry--ro { + background: none; +} diff --git a/src/app/batch_loader.rs b/src/app/batch_loader.rs new file mode 100644 index 0000000..7c0dd3b --- /dev/null +++ b/src/app/batch_loader.rs @@ -0,0 +1,105 @@ +use gettextrs::gettext; +use std::sync::Arc; + +use crate::api::{SpotifyApiClient, SpotifyApiError}; +use crate::app::models::*; +use crate::app::AppAction; + +// A wrapper around the Spotify API to load batches of songs from various sources (see below) +#[derive(Clone)] +pub struct BatchLoader { + api: Arc, +} + +// The sources mentionned above +#[derive(Clone, Debug)] +pub enum SongsSource { + Playlist(String), + Album(String), + SavedTracks, +} + +impl PartialEq for SongsSource { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Playlist(l), Self::Playlist(r)) => l == r, + (Self::Album(l), Self::Album(r)) => l == r, + (Self::SavedTracks, Self::SavedTracks) => true, + _ => false, + } + } +} + +impl Eq for SongsSource {} + +impl SongsSource { + pub fn has_spotify_uri(&self) -> bool { + matches!(self, Self::Playlist(_) | Self::Album(_)) + } + + pub fn spotify_uri(&self) -> Option { + match self { + Self::Playlist(id) => Some(format!("spotify:playlist:{}", id)), + Self::Album(id) => Some(format!("spotify:album:{}", id)), + _ => None, + } + } +} + +// How to query for a batch: specify a source, and a batch to get (offset + number of elements to get) +#[derive(Debug)] +pub struct BatchQuery { + pub source: SongsSource, + pub batch: Batch, +} + +impl BatchQuery { + // Given a query, compute the next batch to get (if any) + pub fn next(&self) -> Option { + let Self { source, batch } = self; + Some(Self { + source: source.clone(), + batch: batch.next()?, + }) + } +} + +impl BatchLoader { + pub fn new(api: Arc) -> Self { + Self { api } + } + + // Query a batch and create an action when it's been retrieved succesfully + pub async fn query( + &self, + query: BatchQuery, + create_action: ActionCreator, + ) -> Option + where + ActionCreator: FnOnce(SongsSource, SongBatch) -> AppAction, + { + let api = Arc::clone(&self.api); + + let Batch { + offset, batch_size, .. + } = query.batch; + let result = match &query.source { + SongsSource::Playlist(id) => api.get_playlist_tracks(id, offset, batch_size).await, + SongsSource::SavedTracks => api.get_saved_tracks(offset, batch_size).await, + SongsSource::Album(id) => api.get_album_tracks(id, offset, batch_size).await, + }; + + match result { + Ok(batch) => Some(create_action(query.source, batch)), + // No token? Why was the batch loader called? Ah, whatever + Err(SpotifyApiError::NoToken) => None, + Err(err) => { + error!("Spotify API error: {}", err); + Some(AppAction::ShowNotification(gettext( + // translators: This notification is the default message for unhandled errors. Logs refer to console output. + "An error occured. Check logs for details!", + ))) + } + } + } +} diff --git a/src/app/components/album/album.blp b/src/app/components/album/album.blp new file mode 100644 index 0000000..661bbf7 --- /dev/null +++ b/src/app/components/album/album.blp @@ -0,0 +1,75 @@ +using Gtk 4.0; +using Adw 1; + +template $AlbumWidget : Adw.Bin { + Button cover_btn { + hexpand: false; + halign: center; + + Box { + halign: center; + valign: start; + margin-top: 6; + margin-bottom: 6; + orientation: vertical; + spacing: 6; + + Image cover_image { + icon-name: "media-playback-start-symbolic"; + + styles [ + "card", + ] + } + + Label album_label { + label: "Album"; + justify: center; + wrap: true; + wrap-mode: word; + ellipsize: end; + max-width-chars: 1; + margin-top: 6; + + styles [ + "title-4", + ] + } + + Label artist_label { + label: "Artist"; + justify: center; + wrap: true; + wrap-mode: word; + ellipsize: end; + max-width-chars: 1; + + styles [ + "body", + ] + } + + Label year_label { + label: "Year"; + justify: center; + wrap: true; + wrap-mode: word_char; + max-width-chars: 1; + sensitive: false; + + styles [ + "body", + ] + } + } + + styles [ + "flat", + ] + } + + styles [ + "container", + "album", + ] +} diff --git a/src/app/components/album/album.css b/src/app/components/album/album.css new file mode 100644 index 0000000..9226f52 --- /dev/null +++ b/src/app/components/album/album.css @@ -0,0 +1,34 @@ +/* large style */ + +leaflet.unfolded .album .card { + min-width: 200px; + min-height: 200px; + border-radius: 6px; +} + +leaflet.unfolded .album { + margin-top: 6px; + margin-bottom: 6px; +} + +leaflet.unfolded .album button { + border-radius: 12px; +} +/* small style */ + +leaflet.folded .album .card { + min-width: 100px; + min-height: 100px; + border-radius: 6px; + margin-top: 0px; + margin-bottom: 0px; +} + +leaflet.unfolded .album { + margin-top: 0px; + margin-bottom: 0px; +} + +leaflet.folded .album button { + border-radius: 6px; +} diff --git a/src/app/components/album/album.rs b/src/app/components/album/album.rs new file mode 100644 index 0000000..cc88953 --- /dev/null +++ b/src/app/components/album/album.rs @@ -0,0 +1,129 @@ +use crate::app::components::display_add_css_provider; +use crate::app::dispatch::Worker; +use crate::app::loader::ImageLoader; +use crate::app::models::AlbumModel; + +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; +use libadwaita::subclass::prelude::BinImpl; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/album.ui")] + pub struct AlbumWidget { + #[template_child] + pub album_label: TemplateChild, + + #[template_child] + pub artist_label: TemplateChild, + + #[template_child] + pub year_label: TemplateChild, + + #[template_child] + pub cover_btn: TemplateChild, + + #[template_child] + pub cover_image: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for AlbumWidget { + const NAME: &'static str = "AlbumWidget"; + type Type = super::AlbumWidget; + type ParentType = libadwaita::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for AlbumWidget {} + impl WidgetImpl for AlbumWidget {} + impl BinImpl for AlbumWidget {} +} + +glib::wrapper! { + pub struct AlbumWidget(ObjectSubclass) @extends gtk::Widget, libadwaita::Bin; +} + +impl Default for AlbumWidget { + fn default() -> Self { + Self::new() + } +} + +impl AlbumWidget { + pub fn new() -> Self { + display_add_css_provider(resource!("/components/album.css")); + glib::Object::new() + } + + pub fn for_model(album_model: &AlbumModel, worker: Worker) -> Self { + let _self = Self::new(); + _self.bind(album_model, worker); + _self + } + + fn set_loaded(&self) { + self.add_css_class("container--loaded"); + } + + fn set_image(&self, pixbuf: Option<&gdk_pixbuf::Pixbuf>) { + self.imp().cover_image.set_from_pixbuf(pixbuf); + } + + fn bind(&self, album_model: &AlbumModel, worker: Worker) { + let widget = self.imp(); + widget.cover_image.set_overflow(gtk::Overflow::Hidden); + + if let Some(cover_art) = album_model.cover() { + let _self = self.downgrade(); + worker.send_local_task(async move { + if let Some(_self) = _self.upgrade() { + let loader = ImageLoader::new(); + let result = loader.load_remote(&cover_art, "jpg", 200, 200).await; + _self.set_image(result.as_ref()); + _self.set_loaded(); + } + }); + } else { + self.set_loaded(); + } + + album_model + .bind_property("album", &*widget.album_label, "label") + .flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE) + .build(); + + album_model + .bind_property("artist", &*widget.artist_label, "label") + .flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE) + .build(); + + if album_model.year() > 0 { + album_model + .bind_property("year", &*widget.year_label, "label") + .flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE) + .build(); + } else { + widget.year_label.set_visible(false); + } + } + + pub fn connect_album_pressed(&self, f: F) { + self.imp() + .cover_btn + .connect_clicked(clone!(@weak self as _self => move |_| { + f(&_self); + })); + } +} diff --git a/src/app/components/album/mod.rs b/src/app/components/album/mod.rs new file mode 100644 index 0000000..59a8a08 --- /dev/null +++ b/src/app/components/album/mod.rs @@ -0,0 +1,3 @@ +#[allow(clippy::module_inception)] +mod album; +pub use album::AlbumWidget; diff --git a/src/app/components/artist/artist.blp b/src/app/components/artist/artist.blp new file mode 100644 index 0000000..9f75990 --- /dev/null +++ b/src/app/components/artist/artist.blp @@ -0,0 +1,31 @@ +using Gtk 4.0; +using Adw 1; + +template $ArtistWidget : Box { + orientation: vertical; + + Button avatar_btn { + vexpand: true; + width-request: 150; + height-request: 150; + receives-default: true; + halign: center; + valign: center; + has-frame: false; + + Adw.Avatar avatar { + halign: center; + valign: center; + show-initials: true; + size: 150; + } + + styles [ + "circular", + ] + } + + Label artist { + label: "Artist Name"; + } +} diff --git a/src/app/components/artist/mod.rs b/src/app/components/artist/mod.rs new file mode 100644 index 0000000..31e0c4c --- /dev/null +++ b/src/app/components/artist/mod.rs @@ -0,0 +1,95 @@ +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; + +use crate::app::loader::ImageLoader; +use crate::app::models::ArtistModel; +use crate::app::Worker; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/artist.ui")] + pub struct ArtistWidget { + #[template_child] + pub artist: TemplateChild, + + #[template_child] + pub avatar_btn: TemplateChild, + + #[template_child] + pub avatar: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for ArtistWidget { + const NAME: &'static str = "ArtistWidget"; + type Type = super::ArtistWidget; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for ArtistWidget {} + impl WidgetImpl for ArtistWidget {} + impl BoxImpl for ArtistWidget {} +} + +glib::wrapper! { + pub struct ArtistWidget(ObjectSubclass) @extends gtk::Widget, gtk::Box; +} + +impl Default for ArtistWidget { + fn default() -> Self { + Self::new() + } +} + +impl ArtistWidget { + pub fn new() -> Self { + glib::Object::new() + } + + pub fn for_model(model: &ArtistModel, worker: Worker) -> Self { + let _self = Self::new(); + _self.bind(model, worker); + _self + } + + pub fn connect_artist_pressed(&self, f: F) { + self.imp() + .avatar_btn + .connect_clicked(clone!(@weak self as _self => move |_| { + f(&_self); + })); + } + + fn bind(&self, model: &ArtistModel, worker: Worker) { + let widget = self.imp(); + + if let Some(url) = model.image() { + let avatar = widget.avatar.downgrade(); + worker.send_local_task(async move { + if let Some(avatar) = avatar.upgrade() { + let loader = ImageLoader::new(); + let pixbuf = loader.load_remote(&url, "jpg", 200, 200).await; + let texture = pixbuf.as_ref().map(gdk::Texture::for_pixbuf); + avatar.set_custom_image(texture.as_ref()); + } + }); + } + + model + .bind_property("artist", &*widget.artist, "label") + .flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE) + .build(); + } +} diff --git a/src/app/components/artist_details/artist_details.blp b/src/app/components/artist_details/artist_details.blp new file mode 100644 index 0000000..44446d9 --- /dev/null +++ b/src/app/components/artist_details/artist_details.blp @@ -0,0 +1,63 @@ +using Gtk 4.0; + +template $ArtistDetailsWidget : Box { + ScrolledWindow scrolled_window { + hscrollbar-policy: never; + hexpand: true; + vexpand: true; + Box { + margin-start: 8; + margin-end: 8; + margin-top: 8; + margin-bottom: 8; + orientation: vertical; + spacing: 16; + + Box { + orientation: vertical; + + Label { + halign: start; + margin-start: 8; + margin-end: 8; + + /* Translators: Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. */ + + label: _("Top tracks"); + + styles [ + "title-4", + ] + } + + ListView top_tracks { + } + } + + Expander { + margin-top: 8; + margin-bottom: 8; + expanded: true; + + FlowBox artist_releases { + height-request: 100; + hexpand: true; + min-children-per-line: 1; + selection-mode: none; + activate-on-single-click: false; + } + + [label] + Label { + /* Translators: Title of the sections that contains all releases from an artist (both singles and albums). */ + + label: _("Releases"); + } + } + } + } + + styles [ + "artist", + ] +} diff --git a/src/app/components/artist_details/artist_details.css b/src/app/components/artist_details/artist_details.css new file mode 100644 index 0000000..ef5f0fa --- /dev/null +++ b/src/app/components/artist_details/artist_details.css @@ -0,0 +1,18 @@ +listview.artist__top-tracks { + padding: 8px; + border-radius: 8px; + margin: 8px; +} + +listview.artist__top-tracks row { + border-radius: 4px; +} + +.artist { + transition: opacity .3s ease; + opacity: 0; +} + +.artist__loaded { + opacity: 1; +} diff --git a/src/app/components/artist_details/artist_details.rs b/src/app/components/artist_details/artist_details.rs new file mode 100644 index 0000000..317201a --- /dev/null +++ b/src/app/components/artist_details/artist_details.rs @@ -0,0 +1,168 @@ +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; +use std::rc::Rc; + +use crate::app::components::{ + display_add_css_provider, AlbumWidget, Component, EventListener, Playlist, +}; +use crate::app::{models::*, ListStore}; +use crate::app::{AppEvent, BrowserEvent, Worker}; + +use super::ArtistDetailsModel; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/artist_details.ui")] + pub struct ArtistDetailsWidget { + #[template_child] + pub scrolled_window: TemplateChild, + + #[template_child] + pub top_tracks: TemplateChild, + + #[template_child] + pub artist_releases: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for ArtistDetailsWidget { + const NAME: &'static str = "ArtistDetailsWidget"; + type Type = super::ArtistDetailsWidget; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for ArtistDetailsWidget {} + impl WidgetImpl for ArtistDetailsWidget {} + impl BoxImpl for ArtistDetailsWidget {} +} + +glib::wrapper! { + pub struct ArtistDetailsWidget(ObjectSubclass) @extends gtk::Widget, gtk::Box; +} + +impl ArtistDetailsWidget { + fn new() -> Self { + display_add_css_provider(resource!("/components/artist_details.css")); + glib::Object::new() + } + + fn top_tracks_widget(&self) -> >k::ListView { + self.imp().top_tracks.as_ref() + } + + fn set_loaded(&self) { + self.add_css_class("artist__loaded"); + } + + fn connect_bottom_edge(&self, f: F) + where + F: Fn() + 'static, + { + self.imp() + .scrolled_window + .connect_edge_reached(move |_, pos| { + if let gtk::PositionType::Bottom = pos { + f() + } + }); + } + + fn bind_artist_releases( + &self, + worker: Worker, + store: &ListStore, + on_album_pressed: F, + ) where + F: Fn(String) + Clone + 'static, + { + self.imp() + .artist_releases + .bind_model(Some(store.unsafe_store()), move |item| { + let item = item.downcast_ref::().unwrap(); + let child = gtk::FlowBoxChild::new(); + let album = AlbumWidget::for_model(item, worker.clone()); + let f = on_album_pressed.clone(); + album.connect_album_pressed(clone!(@weak item => move |_| { + f(item.uri()); + })); + child.set_child(Some(&album)); + child.upcast::() + }); + } +} + +pub struct ArtistDetails { + model: Rc, + widget: ArtistDetailsWidget, + children: Vec>, +} + +impl ArtistDetails { + pub fn new(model: Rc, worker: Worker) -> Self { + model.load_artist_details(model.id.clone()); + + let widget = ArtistDetailsWidget::new(); + + widget.connect_bottom_edge(clone!(@weak model => move || { + model.load_more(); + })); + + if let Some(store) = model.get_list_store() { + widget.bind_artist_releases( + worker.clone(), + &store, + clone!(@weak model => move |id| { + model.open_album(id); + }), + ); + } + + let playlist = Box::new(Playlist::new( + widget.top_tracks_widget().clone(), + Rc::clone(&model), + worker, + )); + + Self { + model, + widget, + children: vec![playlist], + } + } +} + +impl Component for ArtistDetails { + fn get_root_widget(&self) -> >k::Widget { + self.widget.upcast_ref() + } + + fn get_children(&mut self) -> Option<&mut Vec>> { + Some(&mut self.children) + } +} + +impl EventListener for ArtistDetails { + fn on_event(&mut self, event: &AppEvent) { + match event { + AppEvent::BrowserEvent(BrowserEvent::ArtistDetailsUpdated(id)) + if id == &self.model.id => + { + self.widget.set_loaded(); + } + _ => {} + } + self.broadcast_event(event); + } +} diff --git a/src/app/components/artist_details/artist_details_model.rs b/src/app/components/artist_details/artist_details_model.rs new file mode 100644 index 0000000..94f6782 --- /dev/null +++ b/src/app/components/artist_details/artist_details_model.rs @@ -0,0 +1,187 @@ +use gio::prelude::*; +use gio::SimpleActionGroup; +use std::ops::Deref; +use std::rc::Rc; + +use crate::api::SpotifyApiError; +use crate::app::components::SimpleHeaderBarModel; +use crate::app::components::{labels, PlaylistModel}; +use crate::app::models::*; +use crate::app::state::SelectionContext; +use crate::app::state::{ + BrowserAction, BrowserEvent, PlaybackAction, SelectionAction, SelectionState, +}; +use crate::app::{ActionDispatcher, AppAction, AppEvent, AppModel, ListStore}; + +pub struct ArtistDetailsModel { + pub id: String, + app_model: Rc, + dispatcher: Box, +} + +impl ArtistDetailsModel { + pub fn new(id: String, app_model: Rc, dispatcher: Box) -> Self { + Self { + id, + app_model, + dispatcher, + } + } + + pub fn get_artist_name(&self) -> Option + '_> { + self.app_model + .map_state_opt(|s| s.browser.artist_state(&self.id)?.artist.as_ref()) + } + + pub fn get_list_store(&self) -> Option> + '_> { + self.app_model + .map_state_opt(|s| Some(&s.browser.artist_state(&self.id)?.albums)) + } + + pub fn load_artist_details(&self, id: String) { + let api = self.app_model.get_spotify(); + self.dispatcher + .call_spotify_and_dispatch(move || async move { + let artist = api.get_artist(&id).await; + match artist { + Ok(artist) => Ok(BrowserAction::SetArtistDetails(Box::new(artist)).into()), + Err(SpotifyApiError::BadStatus(400, _)) + | Err(SpotifyApiError::BadStatus(404, _)) => { + Ok(BrowserAction::NavigationPop.into()) + } + Err(e) => Err(e), + } + }); + } + + pub fn open_album(&self, id: String) { + self.dispatcher.dispatch(AppAction::ViewAlbum(id)); + } + + pub fn load_more(&self) -> Option<()> { + let api = self.app_model.get_spotify(); + let state = self.app_model.get_state(); + let next_page = &state.browser.artist_state(&self.id)?.next_page; + + let id = next_page.data.clone(); + let batch_size = next_page.batch_size; + let offset = next_page.next_offset?; + + self.dispatcher + .call_spotify_and_dispatch(move || async move { + api.get_artist_albums(&id, offset, batch_size) + .await + .map(|albums| BrowserAction::AppendArtistReleases(id, albums).into()) + }); + + Some(()) + } +} + +impl PlaylistModel for ArtistDetailsModel { + fn song_list_model(&self) -> SongListModel { + self.app_model + .get_state() + .browser + .artist_state(&self.id) + .expect("illegal attempt to read artist_state") + .top_tracks + .clone() + } + + fn is_paused(&self) -> bool { + !self.app_model.get_state().playback.is_playing() + } + + fn current_song_id(&self) -> Option { + self.app_model.get_state().playback.current_song_id() + } + + fn play_song_at(&self, _pos: usize, id: &str) { + let tracks: Vec = self.song_list_model().collect(); + self.dispatcher + .dispatch(PlaybackAction::LoadSongs(tracks).into()); + self.dispatcher + .dispatch(PlaybackAction::Load(id.to_string()).into()); + } + + fn actions_for(&self, id: &str) -> Option { + let song = self.song_list_model().get(id)?; + let song = song.description(); + + let group = SimpleActionGroup::new(); + + for view_artist in song.make_artist_actions(self.dispatcher.box_clone(), None) { + group.add_action(&view_artist); + } + group.add_action(&song.make_album_action(self.dispatcher.box_clone(), None)); + group.add_action(&song.make_link_action(None)); + group.add_action(&song.make_queue_action(self.dispatcher.box_clone(), None)); + + Some(group.upcast()) + } + + fn menu_for(&self, id: &str) -> Option { + let song = self.song_list_model().get(id)?; + let song = song.description(); + + let menu = gio::Menu::new(); + menu.append(Some(&*labels::VIEW_ALBUM), Some("song.view_album")); + for artist in song.artists.iter().filter(|a| self.id != a.id) { + menu.append( + Some(&labels::more_from_label(&artist.name)), + Some(&format!("song.view_artist_{}", artist.id)), + ); + } + + menu.append(Some(&*labels::COPY_LINK), Some("song.copy_link")); + menu.append(Some(&*labels::ADD_TO_QUEUE), Some("song.queue")); + Some(menu.upcast()) + } + + fn select_song(&self, id: &str) { + let song = self.song_list_model().get(id); + if let Some(song) = song { + self.dispatcher + .dispatch(SelectionAction::Select(vec![song.into_description()]).into()); + } + } + + fn deselect_song(&self, id: &str) { + self.dispatcher + .dispatch(SelectionAction::Deselect(vec![id.to_string()]).into()); + } + + fn enable_selection(&self) -> bool { + self.dispatcher + .dispatch(AppAction::EnableSelection(SelectionContext::Default)); + true + } + + fn selection(&self) -> Option + '_>> { + Some(Box::new(self.app_model.map_state(|s| &s.selection))) + } +} + +impl SimpleHeaderBarModel for ArtistDetailsModel { + fn title(&self) -> Option { + Some(self.get_artist_name()?.clone()) + } + + fn title_updated(&self, event: &AppEvent) -> bool { + matches!( + event, + AppEvent::BrowserEvent(BrowserEvent::ArtistDetailsUpdated(_)) + ) + } + + fn selection_context(&self) -> Option { + Some(SelectionContext::Default) + } + + fn select_all(&self) { + let songs: Vec = self.song_list_model().collect(); + self.dispatcher + .dispatch(SelectionAction::Select(songs).into()); + } +} diff --git a/src/app/components/artist_details/mod.rs b/src/app/components/artist_details/mod.rs new file mode 100644 index 0000000..ec36bfd --- /dev/null +++ b/src/app/components/artist_details/mod.rs @@ -0,0 +1,6 @@ +#[allow(clippy::module_inception)] +mod artist_details; +pub use artist_details::*; + +mod artist_details_model; +pub use artist_details_model::*; diff --git a/src/app/components/details/album_header.blp b/src/app/components/details/album_header.blp new file mode 100644 index 0000000..20f3af3 --- /dev/null +++ b/src/app/components/details/album_header.blp @@ -0,0 +1,136 @@ +using Gtk 4.0; + +template $AlbumHeaderWidget : Box { + valign: start; + vexpand: false; + margin-start: 6; + margin-end: 6; + margin-bottom: 6; + + Overlay album_overlay { + overflow: hidden; + halign: center; + margin-top: 18; + margin-bottom: 6; + margin-start: 6; + + Image album_art { + width-request: 160; + height-request: 160; + icon-name: "emblem-music-symbolic"; + } + + [overlay] + Button info_button { + icon-name: "preferences-system-details-symbolic"; + halign: end; + valign: end; + margin-start: 6; + margin-end: 6; + margin-top: 6; + margin-bottom: 6; + tooltip-text: "Album Info"; + + styles [ + "circular", + "osd", + ] + } + + styles [ + "card", + ] + } + + Box album_info { + hexpand: true; + valign: center; + orientation: vertical; + spacing: 6; + margin-start: 18; + + Label album_label { + xalign: 0; + halign: start; + label: "Album"; + wrap: true; + ellipsize: end; + max-width-chars: 50; + lines: 4; + + styles [ + "title-1", + ] + } + + LinkButton artist_button { + receives-default: true; + halign: start; + valign: center; + has-frame: false; + + Label artist_button_label { + hexpand: true; + vexpand: true; + label: "Artist"; + ellipsize: middle; + } + + styles [ + "title-4", + ] + } + + Label year_label { + xalign: 0; + halign: start; + label: "Year"; + ellipsize: end; + max-width-chars: 50; + lines: 1; + sensitive: false; + + styles [ + "body", + ] + } + } + + Box button_box { + orientation: horizontal; + valign: center; + + margin-end: 6; + spacing: 8; + + Button play_button { + receives-default: true; + halign: center; + valign: center; + tooltip-text: "Play"; + icon-name: "media-playback-start-symbolic"; + + styles [ + "circular", + "play__button", + ] + } + + Button like_button { + receives-default: true; + halign: center; + valign: center; + tooltip-text: "Add to Library"; + + styles [ + "circular", + "like__button", + ] + } + } + + + styles [ + "album__header", + ] +} diff --git a/src/app/components/details/album_header.css b/src/app/components/details/album_header.css new file mode 100644 index 0000000..f5a8528 --- /dev/null +++ b/src/app/components/details/album_header.css @@ -0,0 +1,33 @@ +.album__header .title-4 label { + color: @window_fg_color; + font-weight: bold; + text-decoration: none; +} + + +.album__header .title-4:hover { + border-radius: 6px; + background-image: image(alpha(currentColor, 0.08)); +} + +clamp.details__clamp { + background-color: @view_bg_color; + box-shadow: inset 0px -1px 0px @borders; +} + +headerbar.details__headerbar { + transition: background-color .3s ease; +} + +headerbar.flat.details__headerbar windowtitle { + opacity: 0; +} + +headerbar.details__headerbar windowtitle { + transition: opacity .3s ease; + opacity: 1; +} + +.details__headerbar.flat { + background-color: @view_bg_color; +} diff --git a/src/app/components/details/album_header.rs b/src/app/components/details/album_header.rs new file mode 100644 index 0000000..af6f8dd --- /dev/null +++ b/src/app/components/details/album_header.rs @@ -0,0 +1,166 @@ +use crate::app::components::display_add_css_provider; +use gettextrs::gettext; +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::{glib, CompositeTemplate}; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/album_header.ui")] + pub struct AlbumHeaderWidget { + #[template_child] + pub album_overlay: TemplateChild, + + #[template_child] + pub album_label: TemplateChild, + + #[template_child] + pub album_art: TemplateChild, + + #[template_child] + pub button_box: TemplateChild, + + #[template_child] + pub like_button: TemplateChild, + + #[template_child] + pub play_button: TemplateChild, + + #[template_child] + pub info_button: TemplateChild, + + #[template_child] + pub album_info: TemplateChild, + + #[template_child] + pub artist_button: TemplateChild, + + #[template_child] + pub artist_button_label: TemplateChild, + + #[template_child] + pub year_label: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for AlbumHeaderWidget { + const NAME: &'static str = "AlbumHeaderWidget"; + type Type = super::AlbumHeaderWidget; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + display_add_css_provider(resource!("/components/album_header.css")); + obj.init_template(); + } + } + + impl ObjectImpl for AlbumHeaderWidget {} + impl WidgetImpl for AlbumHeaderWidget {} + impl BoxImpl for AlbumHeaderWidget {} +} + +glib::wrapper! { + pub struct AlbumHeaderWidget(ObjectSubclass) @extends gtk::Widget, gtk::Box; +} + +impl Default for AlbumHeaderWidget { + fn default() -> Self { + Self::new() + } +} + +impl AlbumHeaderWidget { + pub fn new() -> Self { + glib::Object::new() + } + + pub fn connect_play(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().play_button.connect_clicked(move |_| f()); + } + + pub fn connect_liked(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().like_button.connect_clicked(move |_| f()); + } + + pub fn connect_info(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().info_button.connect_clicked(move |_| f()); + } + + pub fn connect_artist_clicked(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().artist_button.connect_activate_link(move |_| { + f(); + glib::signal::Inhibit(true) + }); + } + + pub fn set_liked(&self, is_liked: bool) { + self.imp().like_button.set_icon_name(if is_liked { + "starred-symbolic" + } else { + "non-starred-symbolic" + }); + } + + pub fn set_playing(&self, is_playing: bool) { + let playback_icon = if is_playing { + "media-playback-pause-symbolic" + } else { + "media-playback-start-symbolic" + }; + + let translated_tooltip = if is_playing { + gettext("Pause") + } else { + gettext("Play") + }; + let tooltip_text = Some(translated_tooltip.as_str()); + + self.imp().play_button.set_icon_name(playback_icon); + self.imp().play_button.set_tooltip_text(tooltip_text); + } + + pub fn set_artwork(&self, art: &gdk_pixbuf::Pixbuf) { + self.imp().album_art.set_from_pixbuf(Some(art)); + } + + pub fn set_album_and_artist_and_year(&self, album: &str, artist: &str, year: Option) { + let widget = self.imp(); + widget.album_label.set_label(album); + widget.artist_button_label.set_label(artist); + match year { + Some(year) => widget.year_label.set_label(&year.to_string()), + None => widget.year_label.set_visible(false), + } + } + + pub fn set_centered(&self) { + let widget = self.imp(); + widget.album_label.set_halign(gtk::Align::Center); + widget.album_label.set_justify(gtk::Justification::Center); + widget.artist_button.set_halign(gtk::Align::Center); + widget.year_label.set_halign(gtk::Align::Center); + widget.button_box.set_halign(gtk::Align::Center); + widget.album_overlay.set_margin_start(0); + widget.button_box.set_margin_end(0); + widget.album_info.set_margin_start(0); + } +} diff --git a/src/app/components/details/details.blp b/src/app/components/details/details.blp new file mode 100644 index 0000000..1450a49 --- /dev/null +++ b/src/app/components/details/details.blp @@ -0,0 +1,55 @@ +using Gtk 4.0; +using Adw 1; + +template $AlbumDetailsWidget : Adw.Bin { + Box { + orientation: vertical; + vexpand: true; + hexpand: true; + + $HeaderBarWidget headerbar { + } + + $ScrollingHeaderWidget scrolling_header { + [header] + WindowHandle { + Adw.Clamp { + maximum-size: 900; + + Adw.Squeezer { + switch-threshold-policy: natural; + valign: center; + homogeneous: false; + transition-type: crossfade; + + $AlbumHeaderWidget header_widget { + } + + $AlbumHeaderWidget header_mobile { + orientation: "vertical"; + spacing: "12"; + } + } + + styles [ + "details__clamp", + ] + } + } + + Adw.ClampScrollable { + maximum-size: 900; + + ListView album_tracks { + styles [ + "album__tracks", + ] + } + } + + styles [ + "container", + ] + } + } +} diff --git a/src/app/components/details/details.rs b/src/app/components/details/details.rs new file mode 100644 index 0000000..8bbc93f --- /dev/null +++ b/src/app/components/details/details.rs @@ -0,0 +1,335 @@ +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; +use std::rc::Rc; + +use super::album_header::AlbumHeaderWidget; +use super::release_details::ReleaseDetailsWindow; +use super::DetailsModel; + +use crate::app::components::{ + Component, EventListener, HeaderBarComponent, HeaderBarWidget, Playlist, ScrollingHeaderWidget, +}; +use crate::app::dispatch::Worker; +use crate::app::loader::ImageLoader; +use crate::app::state::PlaybackEvent; +use crate::app::{AppEvent, BrowserEvent}; + +mod imp { + + use libadwaita::subclass::prelude::BinImpl; + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/details.ui")] + pub struct AlbumDetailsWidget { + #[template_child] + pub scrolling_header: TemplateChild, + + #[template_child] + pub headerbar: TemplateChild, + + #[template_child] + pub header_widget: TemplateChild, + + #[template_child] + pub header_mobile: TemplateChild, + + #[template_child] + pub album_tracks: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for AlbumDetailsWidget { + const NAME: &'static str = "AlbumDetailsWidget"; + type Type = super::AlbumDetailsWidget; + type ParentType = libadwaita::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for AlbumDetailsWidget { + fn constructed(&self) { + self.parent_constructed(); + self.header_mobile.set_centered(); + self.headerbar.add_classes(&["details__headerbar"]); + } + } + + impl WidgetImpl for AlbumDetailsWidget {} + impl BinImpl for AlbumDetailsWidget {} +} + +glib::wrapper! { + pub struct AlbumDetailsWidget(ObjectSubclass) @extends gtk::Widget, libadwaita::Bin; +} + +impl AlbumDetailsWidget { + fn new() -> Self { + glib::Object::new() + } + + fn set_header_visible(&self, visible: bool) { + let widget = self.imp(); + widget.headerbar.set_title_visible(true); + if visible { + widget.headerbar.add_classes(&["flat"]); + } else { + widget.headerbar.remove_classes(&["flat"]); + } + } + + fn connect_header(&self) { + self.set_header_visible(false); + self.imp().scrolling_header.connect_header_visibility( + clone!(@weak self as _self => move |visible| { + _self.set_header_visible(visible); + }), + ); + } + + fn connect_bottom_edge(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().scrolling_header.connect_bottom_edge(f); + } + + fn headerbar_widget(&self) -> &HeaderBarWidget { + self.imp().headerbar.as_ref() + } + + fn album_tracks_widget(&self) -> >k::ListView { + self.imp().album_tracks.as_ref() + } + + fn set_loaded(&self) { + self.imp() + .scrolling_header + .add_css_class("container--loaded"); + } + + fn connect_liked(&self, f: F) + where + F: Fn() + Clone + 'static, + { + self.imp().header_widget.connect_liked(f.clone()); + self.imp().header_mobile.connect_liked(f); + } + + fn connect_play(&self, f: F) + where + F: Fn() + Clone + 'static, + { + self.imp().header_widget.connect_play(f.clone()); + self.imp().header_mobile.connect_play(f); + } + + fn connect_info(&self, f: F) + where + F: Fn() + Clone + 'static, + { + self.imp().header_widget.connect_info(f.clone()); + self.imp().header_mobile.connect_info(f); + } + + fn set_liked(&self, is_liked: bool) { + self.imp().header_widget.set_liked(is_liked); + self.imp().header_mobile.set_liked(is_liked); + } + + fn set_playing(&self, is_playing: bool) { + self.imp().header_widget.set_playing(is_playing); + self.imp().header_mobile.set_playing(is_playing); + } + + fn set_album_and_artist_and_year(&self, album: &str, artist: &str, year: Option) { + self.imp() + .header_widget + .set_album_and_artist_and_year(album, artist, year); + self.imp() + .header_mobile + .set_album_and_artist_and_year(album, artist, year); + self.imp().headerbar.set_title_and_subtitle(album, artist); + } + + fn set_artwork(&self, art: &gdk_pixbuf::Pixbuf) { + self.imp().header_widget.set_artwork(art); + self.imp().header_mobile.set_artwork(art); + } + + fn connect_artist_clicked(&self, f: F) + where + F: Fn() + Clone + 'static, + { + self.imp().header_widget.connect_artist_clicked(f.clone()); + self.imp().header_mobile.connect_artist_clicked(f); + } +} + +pub struct Details { + model: Rc, + worker: Worker, + widget: AlbumDetailsWidget, + modal: ReleaseDetailsWindow, + children: Vec>, +} + +impl Details { + pub fn new(model: Rc, worker: Worker, leaflet: &libadwaita::Leaflet) -> Self { + if model.get_album_info().is_none() { + model.load_album_info(); + } + + let widget = AlbumDetailsWidget::new(); + + let playlist = Box::new(Playlist::new( + widget.album_tracks_widget().clone(), + model.clone(), + worker.clone(), + )); + + let headerbar_widget = widget.headerbar_widget(); + headerbar_widget.bind_to_leaflet(leaflet); + let headerbar = Box::new(HeaderBarComponent::new( + headerbar_widget.clone(), + model.to_headerbar_model(), + )); + + let modal = ReleaseDetailsWindow::new(); + + widget.connect_liked(clone!(@weak model => move || model.toggle_save_album())); + + widget.connect_play(clone!(@weak model => move || model.toggle_play_album())); + + widget.connect_header(); + + widget.connect_bottom_edge(clone!(@weak model => move || { + model.load_more(); + })); + + widget.connect_info(clone!(@weak modal, @weak widget => move || { + let modal = modal.upcast_ref::(); + modal.set_modal(true); + modal.set_transient_for( + widget + .root() + .and_then(|r| r.downcast::().ok()) + .as_ref(), + ); + modal.set_visible(true); + })); + + Self { + model, + worker, + widget, + modal, + children: vec![playlist, headerbar], + } + } + + fn update_liked(&self) { + if let Some(info) = self.model.get_album_info() { + let is_liked = info.description.is_liked; + self.widget.set_liked(is_liked); + self.widget.set_liked(is_liked); + } + } + + fn update_playing(&self, is_playing: bool) { + if !self.model.album_is_playing() || !self.model.is_playing() { + self.widget.set_playing(false); + return; + } + self.widget.set_playing(is_playing); + } + + fn update_details(&mut self) { + if let Some(album) = self.model.get_album_info() { + let details = &album.release_details; + let album = &album.description; + + self.widget.set_liked(album.is_liked); + + self.widget.set_album_and_artist_and_year( + &album.title[..], + &album.artists_name(), + album.year(), + ); + + self.widget.connect_artist_clicked( + clone!(@weak self.model as model => move || model.view_artist()), + ); + + self.modal.set_details( + &album.title, + &album.artists_name(), + &details.label, + album.release_date.as_ref().unwrap(), + details.total_tracks, + &details.copyright_text, + ); + + if let Some(art) = album.art.clone() { + let widget = self.widget.downgrade(); + + self.worker.send_local_task(async move { + let pixbuf = ImageLoader::new() + .load_remote(&art[..], "jpg", 320, 320) + .await; + if let (Some(widget), Some(ref pixbuf)) = (widget.upgrade(), pixbuf) { + widget.set_artwork(pixbuf); + widget.set_loaded(); + } + }); + } else { + self.widget.set_loaded(); + } + } + } +} + +impl Component for Details { + fn get_root_widget(&self) -> >k::Widget { + self.widget.upcast_ref() + } + + fn get_children(&mut self) -> Option<&mut Vec>> { + Some(&mut self.children) + } +} + +impl EventListener for Details { + fn on_event(&mut self, event: &AppEvent) { + match event { + AppEvent::BrowserEvent(BrowserEvent::AlbumDetailsLoaded(id)) + if id == &self.model.id => + { + self.update_details(); + self.update_playing(true); + } + AppEvent::BrowserEvent(BrowserEvent::AlbumSaved(id)) + | AppEvent::BrowserEvent(BrowserEvent::AlbumUnsaved(id)) + if id == &self.model.id => + { + self.update_liked(); + } + AppEvent::PlaybackEvent(PlaybackEvent::PlaybackPaused) => { + self.update_playing(false); + } + AppEvent::PlaybackEvent(PlaybackEvent::PlaybackResumed) => { + self.update_playing(true); + } + _ => {} + } + self.broadcast_event(event); + } +} diff --git a/src/app/components/details/details_model.rs b/src/app/components/details/details_model.rs new file mode 100644 index 0000000..ec6bb57 --- /dev/null +++ b/src/app/components/details/details_model.rs @@ -0,0 +1,272 @@ +use gio::prelude::*; +use gio::SimpleActionGroup; +use std::cell::Ref; +use std::ops::Deref; +use std::rc::Rc; + +use crate::api::SpotifyApiError; +use crate::app::components::labels; +use crate::app::components::HeaderBarModel; +use crate::app::components::PlaylistModel; +use crate::app::components::SimpleHeaderBarModel; +use crate::app::components::SimpleHeaderBarModelWrapper; +use crate::app::dispatch::ActionDispatcher; +use crate::app::models::*; +use crate::app::state::SelectionContext; +use crate::app::state::{BrowserAction, PlaybackAction, SelectionAction, SelectionState}; +use crate::app::{AppAction, AppEvent, AppModel, AppState, BatchQuery, SongsSource}; + +pub struct DetailsModel { + pub id: String, + app_model: Rc, + dispatcher: Box, +} + +impl DetailsModel { + pub fn new(id: String, app_model: Rc, dispatcher: Box) -> Self { + Self { + id, + app_model, + dispatcher, + } + } + + fn state(&self) -> Ref<'_, AppState> { + self.app_model.get_state() + } + + pub fn get_album_info(&self) -> Option + '_> { + self.app_model + .map_state_opt(|s| s.browser.details_state(&self.id)?.content.as_ref()) + } + + pub fn get_album_description(&self) -> Option + '_> { + self.app_model.map_state_opt(|s| { + Some( + &s.browser + .details_state(&self.id)? + .content + .as_ref()? + .description, + ) + }) + } + + pub fn load_album_info(&self) { + let id = self.id.clone(); + let api = self.app_model.get_spotify(); + self.dispatcher + .call_spotify_and_dispatch(move || async move { + let album = api.get_album(&id).await; + match album { + Ok(album) => Ok(BrowserAction::SetAlbumDetails(Box::new(album)).into()), + Err(SpotifyApiError::BadStatus(400, _)) + | Err(SpotifyApiError::BadStatus(404, _)) => { + Ok(BrowserAction::NavigationPop.into()) + } + Err(e) => Err(e), + } + }); + } + + pub fn view_artist(&self) { + if let Some(album) = self.get_album_description() { + let artist = &album.artists.first().unwrap().id; + self.dispatcher + .dispatch(AppAction::ViewArtist(artist.to_owned())); + } + } + + pub fn toggle_save_album(&self) { + if let Some(album) = self.get_album_description() { + let id = album.id.clone(); + let is_liked = album.is_liked; + + let api = self.app_model.get_spotify(); + + self.dispatcher + .call_spotify_and_dispatch(move || async move { + if !is_liked { + api.save_album(&id) + .await + .map(|album| BrowserAction::SaveAlbum(Box::new(album)).into()) + } else { + api.remove_saved_album(&id) + .await + .map(|_| BrowserAction::UnsaveAlbum(id).into()) + } + }); + } + } + + pub fn is_playing(&self) -> bool { + self.state().playback.is_playing() + } + + pub fn album_is_playing(&self) -> bool { + matches!( + self.app_model.get_state().playback.current_source(), + Some(SongsSource::Album(ref id)) if id == &self.id) + } + + pub fn toggle_play_album(&self) { + if let Some(album) = self.get_album_description() { + if !self.album_is_playing() { + if self.state().playback.is_shuffled() { + self.dispatcher + .dispatch(AppAction::PlaybackAction(PlaybackAction::ToggleShuffle)); + } + let id_of_first_song = album.songs.songs[0].id.as_str(); + self.play_song_at(0, id_of_first_song); + return; + } + if self.state().playback.is_playing() { + self.dispatcher + .dispatch(AppAction::PlaybackAction(PlaybackAction::Pause)); + } else { + self.dispatcher + .dispatch(AppAction::PlaybackAction(PlaybackAction::Play)); + } + } + } + + pub fn load_more(&self) -> Option<()> { + let last_batch = self.song_list_model().last_batch()?; + let query = BatchQuery { + source: SongsSource::Album(self.id.clone()), + batch: last_batch, + }; + + let id = self.id.clone(); + let next_query = query.next()?; + let loader = self.app_model.get_batch_loader(); + + self.dispatcher.dispatch_async(Box::pin(async move { + loader + .query(next_query, |_s, song_batch| { + BrowserAction::AppendAlbumTracks(id, Box::new(song_batch)).into() + }) + .await + })); + + Some(()) + } + + pub fn to_headerbar_model(self: &Rc) -> Rc { + Rc::new(SimpleHeaderBarModelWrapper::new( + self.clone(), + self.app_model.clone(), + self.dispatcher.box_clone(), + )) + } +} + +impl PlaylistModel for DetailsModel { + fn song_list_model(&self) -> SongListModel { + self.app_model + .get_state() + .browser + .details_state(&self.id) + .expect("illegal attempt to read details_state") + .songs + .clone() + } + + fn is_paused(&self) -> bool { + !self.app_model.get_state().playback.is_playing() + } + + fn show_song_covers(&self) -> bool { + false + } + + fn select_song(&self, id: &str) { + let songs = self.song_list_model(); + if let Some(song) = songs.get(id) { + self.dispatcher + .dispatch(SelectionAction::Select(vec![song.description().clone()]).into()); + } + } + + fn deselect_song(&self, id: &str) { + self.dispatcher + .dispatch(SelectionAction::Deselect(vec![id.to_string()]).into()); + } + + fn enable_selection(&self) -> bool { + self.dispatcher + .dispatch(AppAction::EnableSelection(SelectionContext::Default)); + true + } + + fn selection(&self) -> Option + '_>> { + Some(Box::new(self.app_model.map_state(|s| &s.selection))) + } + + fn current_song_id(&self) -> Option { + self.state().playback.current_song_id() + } + + fn play_song_at(&self, pos: usize, id: &str) { + let source = SongsSource::Album(self.id.clone()); + let batch = self.song_list_model().song_batch_for(pos); + if let Some(batch) = batch { + self.dispatcher + .dispatch(PlaybackAction::LoadPagedSongs(source, batch).into()); + self.dispatcher + .dispatch(PlaybackAction::Load(id.to_string()).into()); + } + } + + fn actions_for(&self, id: &str) -> Option { + let song = self.song_list_model().get(id)?; + let song = song.description(); + + let group = SimpleActionGroup::new(); + + for view_artist in song.make_artist_actions(self.dispatcher.box_clone(), None) { + group.add_action(&view_artist); + } + group.add_action(&song.make_link_action(None)); + group.add_action(&song.make_queue_action(self.dispatcher.box_clone(), None)); + + Some(group.upcast()) + } + + fn menu_for(&self, id: &str) -> Option { + let song = self.song_list_model().get(id)?; + let song = song.description(); + + let menu = gio::Menu::new(); + for artist in song.artists.iter() { + menu.append( + Some(&labels::more_from_label(&artist.name)), + Some(&format!("song.view_artist_{}", artist.id)), + ); + } + + menu.append(Some(&*labels::COPY_LINK), Some("song.copy_link")); + menu.append(Some(&*labels::ADD_TO_QUEUE), Some("song.queue")); + Some(menu.upcast()) + } +} + +impl SimpleHeaderBarModel for DetailsModel { + fn title(&self) -> Option { + None + } + + fn title_updated(&self, _: &AppEvent) -> bool { + false + } + + fn selection_context(&self) -> Option { + Some(SelectionContext::Default) + } + + fn select_all(&self) { + let songs: Vec = self.song_list_model().collect(); + self.dispatcher + .dispatch(SelectionAction::Select(songs).into()); + } +} diff --git a/src/app/components/details/mod.rs b/src/app/components/details/mod.rs new file mode 100644 index 0000000..00ced26 --- /dev/null +++ b/src/app/components/details/mod.rs @@ -0,0 +1,8 @@ +mod album_header; +#[allow(clippy::module_inception)] +mod details; +mod details_model; +mod release_details; + +pub use details::Details; +pub use details_model::DetailsModel; diff --git a/src/app/components/details/release_details.blp b/src/app/components/details/release_details.blp new file mode 100644 index 0000000..b5b2994 --- /dev/null +++ b/src/app/components/details/release_details.blp @@ -0,0 +1,82 @@ +using Gtk 4.0; +using Adw 1; + +template $ReleaseDetailsWindow : Adw.Window { + modal: true; + hide-on-close: true; + default-width: 360; + + Box { + orientation: vertical; + + Adw.HeaderBar { + show-end-title-buttons: true; + + [title] + Adw.WindowTitle album_artist { + } + + styles [ + "flat", + ] + } + + ListBox { + margin-start: 6; + margin-end: 6; + margin-top: 6; + margin-bottom: 6; + valign: start; + selection-mode: none; + show-separators: true; + overflow: hidden; + + styles [ + "card", + ] + + Adw.ActionRow { + /* Translators: This refers to a music label */ + + title: _("Label"); + + [suffix] + Label label { + label: "Label"; + } + } + + Adw.ActionRow { + /* Translators: This refers to a release date */ + + title: _("Released"); + + [suffix] + Label release { + label: "Released"; + } + } + + Adw.ActionRow { + /* Translators: This refers to a number of tracks */ + + title: _("Tracks"); + + [suffix] + Label tracks { + label: "Tracks"; + } + } + + Adw.ActionRow { + title: _("Copyright"); + + [suffix] + Label copyright { + label: "Copyright"; + ellipsize: middle; + } + } + } + } +} diff --git a/src/app/components/details/release_details.rs b/src/app/components/details/release_details.rs new file mode 100644 index 0000000..7ecbada --- /dev/null +++ b/src/app/components/details/release_details.rs @@ -0,0 +1,92 @@ +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; +use libadwaita::subclass::prelude::*; + +use crate::app::components::labels; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/release_details.ui")] + pub struct ReleaseDetailsWindow { + #[template_child] + pub album_artist: TemplateChild, + + #[template_child] + pub label: TemplateChild, + + #[template_child] + pub release: TemplateChild, + + #[template_child] + pub tracks: TemplateChild, + + #[template_child] + pub copyright: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for ReleaseDetailsWindow { + const NAME: &'static str = "ReleaseDetailsWindow"; + type Type = super::ReleaseDetailsWindow; + type ParentType = libadwaita::Window; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for ReleaseDetailsWindow { + fn constructed(&self) { + self.parent_constructed(); + } + } + + impl WidgetImpl for ReleaseDetailsWindow {} + impl AdwWindowImpl for ReleaseDetailsWindow {} + impl WindowImpl for ReleaseDetailsWindow {} +} + +glib::wrapper! { + pub struct ReleaseDetailsWindow(ObjectSubclass) @extends gtk::Widget, libadwaita::Window, libadwaita::PreferencesWindow; +} + +impl Default for ReleaseDetailsWindow { + fn default() -> Self { + Self::new() + } +} + +impl ReleaseDetailsWindow { + pub fn new() -> Self { + glib::Object::new() + } + + #[allow(clippy::too_many_arguments)] + pub fn set_details( + &self, + album: &str, + artist: &str, + label: &str, + release_date: &str, + track_count: usize, + copyright: &str, + ) { + let widget = self.imp(); + + widget + .album_artist + .set_title(&labels::album_by_artist_label(album, artist)); + + widget.label.set_text(label); + widget.release.set_text(release_date); + widget.tracks.set_text(&track_count.to_string()); + widget.copyright.set_text(copyright); + } +} diff --git a/src/app/components/device_selector/component.rs b/src/app/components/device_selector/component.rs new file mode 100644 index 0000000..3321c92 --- /dev/null +++ b/src/app/components/device_selector/component.rs @@ -0,0 +1,100 @@ +use std::ops::Deref; +use std::rc::Rc; + +use glib::Cast; + +use crate::app::components::{Component, EventListener}; +use crate::app::models::ConnectDevice; +use crate::app::state::{Device, LoginEvent, PlaybackAction, PlaybackEvent}; +use crate::app::{ActionDispatcher, AppEvent, AppModel}; + +use super::widget::DeviceSelectorWidget; + +pub struct DeviceSelectorModel { + app_model: Rc, + dispatcher: Box, +} + +impl DeviceSelectorModel { + pub fn new(app_model: Rc, dispatcher: Box) -> Self { + Self { + app_model, + dispatcher, + } + } + + pub fn refresh_available_devices(&self) { + let api = self.app_model.get_spotify(); + + self.dispatcher + .call_spotify_and_dispatch(move || async move { + api.list_available_devices() + .await + .map(|devices| PlaybackAction::SetAvailableDevices(devices).into()) + }); + } + + pub fn get_available_devices(&self) -> impl Deref> + '_ { + self.app_model.map_state(|s| s.playback.available_devices()) + } + + pub fn get_current_device(&self) -> impl Deref + '_ { + self.app_model.map_state(|s| s.playback.current_device()) + } + + pub fn set_current_device(&self, id: Option) { + let devices = self.get_available_devices(); + let connect_device = id + .and_then(|id| devices.iter().find(|&d| d.id == id)) + .cloned(); + let device = connect_device.map(Device::Connect).unwrap_or(Device::Local); + self.dispatcher + .dispatch(PlaybackAction::SwitchDevice(device).into()); + } +} + +pub struct DeviceSelector { + widget: DeviceSelectorWidget, + model: Rc, +} + +impl DeviceSelector { + pub fn new(widget: DeviceSelectorWidget, model: DeviceSelectorModel) -> Self { + let model = Rc::new(model); + + widget.connect_refresh(clone!(@weak model => move || { + model.refresh_available_devices(); + })); + + widget.connect_switch_device(clone!(@weak model => move |id| { + model.set_current_device(id); + })); + + Self { widget, model } + } +} + +impl Component for DeviceSelector { + fn get_root_widget(&self) -> >k::Widget { + self.widget.upcast_ref() + } +} + +impl EventListener for DeviceSelector { + fn on_event(&mut self, event: &AppEvent) { + match event { + AppEvent::LoginEvent(LoginEvent::LoginCompleted(_)) => { + self.model.refresh_available_devices(); + } + AppEvent::PlaybackEvent(PlaybackEvent::AvailableDevicesChanged) => { + self.widget + .update_devices_list(&self.model.get_available_devices()); + } + AppEvent::PlaybackEvent(PlaybackEvent::SwitchedDevice(_)) => { + self.widget + .set_current_device(&self.model.get_current_device()); + } + _ => (), + } + } +} diff --git a/src/app/components/device_selector/device_selector.blp b/src/app/components/device_selector/device_selector.blp new file mode 100644 index 0000000..d220772 --- /dev/null +++ b/src/app/components/device_selector/device_selector.blp @@ -0,0 +1,44 @@ +using Gtk 4.0; +using Adw 1; + +template $DeviceSelectorWidget : Button { + Adw.ButtonContent button_content { + halign: center; + hexpand: false; + icon-name: "audio-x-generic-symbolic"; + label: _("This device"); + } +} + +menu menu { + section { + label: _("Playing on"); + + item { + custom: "custom_content"; + } + } + + section { + item { + label: _("Refresh devices"); + action: "devices.refresh"; + } + } +} + +PopoverMenu popover { +} + +Box custom_content { + orientation: vertical; + + CheckButton this_device_button { + label: _("This device"); + sensitive: false; + } + + Box devices { + orientation: vertical; + } +} diff --git a/src/app/components/device_selector/mod.rs b/src/app/components/device_selector/mod.rs new file mode 100644 index 0000000..84b3256 --- /dev/null +++ b/src/app/components/device_selector/mod.rs @@ -0,0 +1,11 @@ +use glib::StaticType; + +mod component; +pub use component::*; + +mod widget; +pub use widget::*; + +pub fn expose_widgets() { + widget::DeviceSelectorWidget::static_type(); +} diff --git a/src/app/components/device_selector/widget.rs b/src/app/components/device_selector/widget.rs new file mode 100644 index 0000000..8867f72 --- /dev/null +++ b/src/app/components/device_selector/widget.rs @@ -0,0 +1,169 @@ +use crate::app::models::{ConnectDevice, ConnectDeviceKind}; +use crate::app::state::Device; +use gettextrs::gettext; +use gio::{Action, SimpleAction, SimpleActionGroup}; +use glib::FromVariant; +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; + +const ACTIONS: &str = "devices"; +const CONNECT_ACTION: &str = "connect"; +const REFRESH_ACTION: &str = "refresh"; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/device_selector.ui")] + pub struct DeviceSelectorWidget { + #[template_child] + pub button_content: TemplateChild, + + #[template_child] + pub popover: TemplateChild, + + #[template_child] + pub custom_content: TemplateChild, + + #[template_child] + pub devices: TemplateChild, + + #[template_child] + pub this_device_button: TemplateChild, + + #[template_child] + pub menu: TemplateChild, + + pub action_group: SimpleActionGroup, + } + + #[glib::object_subclass] + impl ObjectSubclass for DeviceSelectorWidget { + const NAME: &'static str = "DeviceSelectorWidget"; + type Type = super::DeviceSelectorWidget; + type ParentType = gtk::Button; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for DeviceSelectorWidget { + fn constructed(&self) { + self.parent_constructed(); + + let popover: >k::PopoverMenu = &self.popover; + popover.set_menu_model(Some(&*self.menu)); + popover.add_child(&*self.custom_content, "custom_content"); + popover.set_parent(&*self.obj()); + popover.set_autohide(true); + + let this_device = &*self.this_device_button; + this_device.set_action_name(Some(&format!("{}.{}", ACTIONS, CONNECT_ACTION))); + this_device.set_action_target_value(Some(&Option::::None.to_variant())); + + self.obj() + .insert_action_group(ACTIONS, Some(&self.action_group)); + self.obj() + .connect_clicked(clone!(@weak popover => move |_| { + popover.set_visible(true); + popover.present(); + popover.grab_focus(); + })); + } + } + + impl WidgetImpl for DeviceSelectorWidget {} + impl ButtonImpl for DeviceSelectorWidget {} +} + +glib::wrapper! { + pub struct DeviceSelectorWidget(ObjectSubclass) @extends gtk::Widget, gtk::Button; +} + +impl DeviceSelectorWidget { + fn action(&self, name: &str) -> Option { + self.imp().action_group.lookup_action(name) + } + + pub fn connect_refresh(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().action_group.add_action(&{ + let refresh = SimpleAction::new(REFRESH_ACTION, None); + refresh.connect_activate(move |_, _| f()); + refresh + }); + } + + pub fn connect_switch_device(&self, f: F) + where + F: Fn(Option) + 'static, + { + self.imp().action_group.add_action(&{ + let connect = SimpleAction::new_stateful( + CONNECT_ACTION, + Some(Option::::static_variant_type().as_ref()), + Option::::None.to_variant(), + ); + connect.connect_activate(move |action, device_id| { + if let Some(device_id) = device_id { + action.change_state(device_id); + f(Option::::from_variant(device_id).unwrap()); + } + }); + connect + }); + } + + pub fn set_current_device(&self, device: &Device) { + if let Some(action) = self.action(CONNECT_ACTION) { + let device_id = match device { + Device::Local => None, + Device::Connect(connect) => Some(&connect.id), + }; + action.change_state(&device_id.to_variant()); + } + let label = match device { + Device::Local => gettext("This device"), + Device::Connect(connect) => connect.label.clone(), + }; + let icon = match device { + Device::Local => "audio-x-generic-symbolic", + Device::Connect(connect) => match connect.kind { + ConnectDeviceKind::Phone => "phone-symbolic", + ConnectDeviceKind::Computer => "computer-symbolic", + ConnectDeviceKind::Speaker => "audio-speakers-symbolic", + ConnectDeviceKind::Other => "audio-x-generic-symbolic", + }, + }; + self.imp().button_content.set_label(&label); + self.imp().button_content.set_icon_name(icon); + } + + pub fn update_devices_list(&self, devices: &[ConnectDevice]) { + let widget = self.imp(); + widget.this_device_button.set_sensitive(!devices.is_empty()); + + while let Some(child) = widget.devices.upcast_ref::().first_child() { + widget.devices.remove(&child); + } + + for device in devices { + let check = gtk::CheckButton::builder() + .action_name(format!("{}.{}", ACTIONS, CONNECT_ACTION)) + .action_target(&Some(&device.id).to_variant()) + .group(&*widget.this_device_button) + .label(&device.label) + .build(); + widget.devices.append(&check); + } + } +} diff --git a/src/app/components/headerbar/component.rs b/src/app/components/headerbar/component.rs new file mode 100644 index 0000000..ed17fd6 --- /dev/null +++ b/src/app/components/headerbar/component.rs @@ -0,0 +1,296 @@ +use std::rc::Rc; + +use glib::Cast; +use gtk::prelude::*; + +use crate::app::{ + components::{Component, EventListener, ListenerComponent}, + state::{SelectionContext, SelectionEvent}, + ActionDispatcher, AppAction, AppEvent, AppModel, BrowserAction, BrowserEvent, +}; + +use super::widget::HeaderBarWidget; + +pub trait HeaderBarModel { + fn title(&self) -> Option; + fn title_updated(&self, event: &AppEvent) -> bool; + fn go_back(&self); + fn can_go_back(&self) -> bool; + fn selection_context(&self) -> Option; + fn can_select_all(&self) -> bool; + fn start_selection(&self); + fn select_all(&self); + fn cancel_selection(&self); + fn selected_count(&self) -> usize; +} + +pub struct DefaultHeaderBarModel { + title: Option, + selection_context: Option, + app_model: Rc, + dispatcher: Box, +} + +impl DefaultHeaderBarModel { + pub fn new( + title: Option, + selection_context: Option, + app_model: Rc, + dispatcher: Box, + ) -> Self { + Self { + title, + selection_context, + app_model, + dispatcher, + } + } +} + +impl HeaderBarModel for DefaultHeaderBarModel { + fn title(&self) -> Option { + self.title.clone() + } + + fn title_updated(&self, _: &AppEvent) -> bool { + false + } + + fn go_back(&self) { + self.dispatcher + .dispatch(BrowserAction::NavigationPop.into()) + } + + fn can_go_back(&self) -> bool { + self.app_model.get_state().browser.can_pop() + } + + fn selection_context(&self) -> Option { + self.selection_context.clone() + } + + fn can_select_all(&self) -> bool { + false + } + + fn start_selection(&self) { + if let Some(context) = self.selection_context.as_ref() { + self.dispatcher + .dispatch(AppAction::EnableSelection(context.clone())) + } + } + + fn select_all(&self) {} + + fn cancel_selection(&self) { + self.dispatcher.dispatch(AppAction::CancelSelection) + } + + fn selected_count(&self) -> usize { + self.app_model.get_state().selection.count() + } +} + +pub trait SimpleHeaderBarModel { + fn title(&self) -> Option; + fn title_updated(&self, event: &AppEvent) -> bool; + fn selection_context(&self) -> Option; + fn select_all(&self); +} + +pub struct SimpleHeaderBarModelWrapper { + wrapped_model: Rc, + app_model: Rc, + dispatcher: Box, +} + +impl SimpleHeaderBarModelWrapper { + pub fn new( + wrapped_model: Rc, + app_model: Rc, + dispatcher: Box, + ) -> Self { + Self { + wrapped_model, + app_model, + dispatcher, + } + } +} + +impl HeaderBarModel for SimpleHeaderBarModelWrapper +where + M: SimpleHeaderBarModel + 'static, +{ + fn title(&self) -> Option { + self.wrapped_model.title() + } + + fn title_updated(&self, event: &AppEvent) -> bool { + self.wrapped_model.title_updated(event) + } + + fn go_back(&self) { + self.dispatcher + .dispatch(BrowserAction::NavigationPop.into()) + } + + fn can_go_back(&self) -> bool { + self.app_model.get_state().browser.can_pop() + } + + fn selection_context(&self) -> Option { + self.wrapped_model.selection_context() + } + + fn can_select_all(&self) -> bool { + true + } + + fn start_selection(&self) { + if let Some(context) = self.wrapped_model.selection_context() { + self.dispatcher + .dispatch(AppAction::EnableSelection(context)); + } + } + + fn select_all(&self) { + self.wrapped_model.select_all() + } + + fn cancel_selection(&self) { + self.dispatcher.dispatch(AppAction::CancelSelection) + } + + fn selected_count(&self) -> usize { + self.app_model.get_state().selection.count() + } +} + +mod common { + + use super::*; + + pub fn update_for_event(event: &AppEvent, widget: &HeaderBarWidget, model: &Rc) + where + Model: HeaderBarModel + 'static, + { + match event { + AppEvent::SelectionEvent(SelectionEvent::SelectionModeChanged(active)) => { + widget.set_selection_active(*active); + } + AppEvent::SelectionEvent(SelectionEvent::SelectionChanged) => { + widget.set_selection_count(model.selected_count()); + } + AppEvent::BrowserEvent(BrowserEvent::NavigationPushed(_)) + | AppEvent::BrowserEvent(BrowserEvent::NavigationPoppedTo(_)) + | AppEvent::BrowserEvent(BrowserEvent::NavigationPopped) + | AppEvent::BrowserEvent(BrowserEvent::NavigationHidden(_)) => { + model.cancel_selection(); + widget.set_can_go_back(model.can_go_back()); + } + event if model.title_updated(event) => { + widget.set_title(model.title().as_ref().map(|s| &s[..])); + } + _ => {} + } + } + + pub fn bind_headerbar(widget: &HeaderBarWidget, model: &Rc) + where + Model: HeaderBarModel + 'static, + { + widget.connect_selection_start(clone!(@weak model => move || model.start_selection())); + widget.connect_select_all(clone!(@weak model => move || model.select_all())); + widget.connect_selection_cancel(clone!(@weak model => move || model.cancel_selection())); + widget.connect_go_back(clone!(@weak model => move || model.go_back())); + + widget.set_title(model.title().as_ref().map(|s| &s[..])); + widget.set_selection_possible(model.selection_context().is_some()); + widget.set_select_all_possible(model.can_select_all()); + widget.set_can_go_back(model.can_go_back()); + } +} + +pub struct HeaderBarComponent { + widget: HeaderBarWidget, + model: Rc, +} + +impl HeaderBarComponent +where + Model: HeaderBarModel + 'static, +{ + pub fn new(widget: HeaderBarWidget, model: Rc) -> Self { + common::bind_headerbar(&widget, &model); + Self { widget, model } + } +} + +impl EventListener for HeaderBarComponent +where + Model: HeaderBarModel + 'static, +{ + fn on_event(&mut self, event: &AppEvent) { + common::update_for_event(event, &self.widget, &self.model); + } +} + +// wrapper version ("Screen") +pub struct StandardScreen { + root: gtk::Widget, + widget: HeaderBarWidget, + model: Rc, + children: Vec>, +} + +impl StandardScreen +where + Model: HeaderBarModel + 'static, +{ + pub fn new( + wrapped: impl ListenerComponent + 'static, + leaflet: &libadwaita::Leaflet, + model: Rc, + ) -> Self { + let widget = HeaderBarWidget::new(); + common::bind_headerbar(&widget, &model); + widget.bind_to_leaflet(leaflet); + + let root = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + root.append(&widget); + root.append(wrapped.get_root_widget()); + + Self { + root: root.upcast(), + widget, + model, + children: vec![Box::new(wrapped)], + } + } +} + +impl Component for StandardScreen +where + Model: HeaderBarModel + 'static, +{ + fn get_root_widget(&self) -> >k::Widget { + &self.root + } + + fn get_children(&mut self) -> Option<&mut Vec>> { + Some(&mut self.children) + } +} + +impl EventListener for StandardScreen +where + Model: HeaderBarModel + 'static, +{ + fn on_event(&mut self, event: &AppEvent) { + common::update_for_event(event, &self.widget, &self.model); + self.broadcast_event(event); + } +} diff --git a/src/app/components/headerbar/headerbar.blp b/src/app/components/headerbar/headerbar.blp new file mode 100644 index 0000000..ae28dc0 --- /dev/null +++ b/src/app/components/headerbar/headerbar.blp @@ -0,0 +1,67 @@ +using Gtk 4.0; +using Adw 1; + +template $HeaderBarWidget : Adw.Bin { + [root] + Overlay overlay { + hexpand: true; + + Adw.HeaderBar main_header { + show-end-title-buttons: true; + + Button go_back { + receives-default: true; + halign: start; + valign: center; + icon-name: "go-previous-symbolic"; + has-frame: false; + } + + [title] + Adw.WindowTitle title { + visible: true; + title: "Spot"; + } + + [end] + Button start_selection { + icon-name: "object-select-symbolic"; + } + } + + [overlay] + Adw.HeaderBar selection_header { + show-end-title-buttons: false; + show-start-title-buttons: false; + visible: false; + + styles [ + "selection-mode", + ] + + Button cancel { + receives-default: true; + halign: start; + valign: center; + + /* Translators: Button label. Exits selection mode. */ + + label: _("Cancel"); + } + + [title] + Adw.WindowTitle selection_title { + title: ""; + } + + [end] + Button select_all { + valign: center; + + /* Translators: Button label. Selects all visible songs. */ + + label: _("Select all"); + } + } + } +} diff --git a/src/app/components/headerbar/mod.rs b/src/app/components/headerbar/mod.rs new file mode 100644 index 0000000..dc91647 --- /dev/null +++ b/src/app/components/headerbar/mod.rs @@ -0,0 +1,11 @@ +mod widget; +pub use widget::*; + +mod component; +pub use component::*; + +use glib::prelude::*; + +pub fn expose_widgets() { + widget::HeaderBarWidget::static_type(); +} diff --git a/src/app/components/headerbar/widget.rs b/src/app/components/headerbar/widget.rs new file mode 100644 index 0000000..d44d84f --- /dev/null +++ b/src/app/components/headerbar/widget.rs @@ -0,0 +1,189 @@ +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; +use libadwaita::subclass::prelude::BinImpl; + +use crate::app::components::labels; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/headerbar.ui")] + pub struct HeaderBarWidget { + #[template_child] + pub main_header: TemplateChild, + + #[template_child] + pub selection_header: TemplateChild, + + #[template_child] + pub go_back: TemplateChild, + + #[template_child] + pub title: TemplateChild, + + #[template_child] + pub selection_title: TemplateChild, + + #[template_child] + pub start_selection: TemplateChild, + + #[template_child] + pub select_all: TemplateChild, + + #[template_child] + pub cancel: TemplateChild, + + #[template_child] + pub overlay: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for HeaderBarWidget { + const NAME: &'static str = "HeaderBarWidget"; + type Type = super::HeaderBarWidget; + type ParentType = libadwaita::Bin; + type Interfaces = (gtk::Buildable,); + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for HeaderBarWidget {} + + impl BuildableImpl for HeaderBarWidget { + fn add_child(&self, builder: >k::Builder, child: &glib::Object, type_: Option<&str>) { + if Some("root") == type_ { + self.parent_add_child(builder, child, type_); + } else { + self.main_header + .set_title_widget(child.downcast_ref::()); + } + } + } + + impl WidgetImpl for HeaderBarWidget {} + impl BinImpl for HeaderBarWidget {} + impl WindowImpl for HeaderBarWidget {} +} + +glib::wrapper! { + pub struct HeaderBarWidget(ObjectSubclass) @extends gtk::Widget, libadwaita::Bin; +} + +impl Default for HeaderBarWidget { + fn default() -> Self { + Self::new() + } +} + +impl HeaderBarWidget { + pub fn new() -> Self { + glib::Object::new() + } + + pub fn connect_selection_start(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().start_selection.connect_clicked(move |_| f()); + } + + pub fn connect_select_all(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().select_all.connect_clicked(move |_| f()); + } + + pub fn connect_selection_cancel(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().cancel.connect_clicked(move |_| f()); + } + + pub fn connect_go_back(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().go_back.connect_clicked(move |_| f()); + } + + pub fn bind_to_leaflet(&self, leaflet: &libadwaita::Leaflet) { + leaflet + .bind_property( + "folded", + &*self.imp().main_header, + "show-start-title-buttons", + ) + .build(); + leaflet.notify("folded"); + } + + pub fn set_can_go_back(&self, can_go_back: bool) { + self.imp().go_back.set_visible(can_go_back); + } + + pub fn set_selection_possible(&self, possible: bool) { + self.imp().start_selection.set_visible(possible); + } + + pub fn set_select_all_possible(&self, possible: bool) { + self.imp().select_all.set_visible(possible); + } + + pub fn set_selection_active(&self, active: bool) { + if active { + self.imp() + .selection_title + .set_title(&labels::n_songs_selected_label(0)); + self.imp().selection_title.set_visible(true); + self.imp().selection_header.set_visible(true); + } else { + self.imp().selection_title.set_visible(false); + self.imp().selection_header.set_visible(false); + } + } + + pub fn set_selection_count(&self, count: usize) { + self.imp() + .selection_title + .set_title(&labels::n_songs_selected_label(count)); + } + + pub fn add_classes(&self, classes: &[&str]) { + for &class in classes { + self.imp().main_header.add_css_class(class); + } + } + + pub fn remove_classes(&self, classes: &[&str]) { + for &class in classes { + self.imp().main_header.remove_css_class(class); + } + } + + pub fn set_title_visible(&self, visible: bool) { + self.imp().title.set_visible(visible); + } + + pub fn set_title_and_subtitle(&self, title: &str, subtitle: &str) { + self.imp().title.set_title(title); + self.imp().title.set_subtitle(subtitle); + } + + pub fn set_title(&self, title: Option<&str>) { + self.imp().title.set_visible(title.is_some()); + if let Some(title) = title { + self.imp().title.set_title(title); + } + } +} diff --git a/src/app/components/labels.rs b/src/app/components/labels.rs new file mode 100644 index 0000000..3bb216f --- /dev/null +++ b/src/app/components/labels.rs @@ -0,0 +1,55 @@ +use gettextrs::*; + +lazy_static! { + // translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. + pub static ref VIEW_ALBUM: String = gettext("View album"); + + // translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. + pub static ref COPY_LINK: String = gettext("Copy link"); + + // translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. + pub static ref ADD_TO_QUEUE: String = gettext("Add to queue"); + + // translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. + pub static ref REMOVE_FROM_QUEUE: String = gettext("Remove from queue"); +} + +pub fn add_to_playlist_label(playlist: &str) -> String { + // this is just to fool xgettext, it doesn't like macros (or rust for that matter) :( + if cfg!(debug_assertions) { + // translators: This is part of a larger text that says "Add to ". This text should be as short as possible. + gettext("Add to {}"); + } + gettext!("Add to {}", playlist) +} + +pub fn n_songs_selected_label(n: usize) -> String { + // this is just to fool xgettext, it doesn't like macros (or rust for that matter) :( + if cfg!(debug_assertions) { + // translators: This shows up when in selection mode. This text should be as short as possible. + ngettext("{} song selected", "{} songs selected", n as u32); + } + ngettext!("{} song selected", "{} songs selected", n as u32, n) +} + +pub fn more_from_label(artist: &str) -> String { + // this is just to fool xgettext, it doesn't like macros (or rust for that matter) :( + if cfg!(debug_assertions) { + // translators: This is part of a contextual menu attached to a single track; the full text is "More from ". + gettext("More from {}"); + } + gettext!("More from {}", glib::markup_escape_text(artist)) +} + +pub fn album_by_artist_label(album: &str, artist: &str) -> String { + // this is just to fool xgettext, it doesn't like macros (or rust for that matter) :( + if cfg!(debug_assertions) { + // translators: This is part of a larger label that reads " by " + gettext("{} by {}"); + } + gettext!( + "{} by {}", + glib::markup_escape_text(album), + glib::markup_escape_text(artist) + ) +} diff --git a/src/app/components/library/library.blp b/src/app/components/library/library.blp new file mode 100644 index 0000000..5e3d2a7 --- /dev/null +++ b/src/app/components/library/library.blp @@ -0,0 +1,35 @@ +using Gtk 4.0; +using Adw 1; + +template $LibraryWidget : Box { + ScrolledWindow scrolled_window { + hexpand: true; + vexpand: true; + vscrollbar-policy: always; + min-content-width: 250; + Overlay overlay { + FlowBox flowbox { + margin-start: 6; + margin-end: 6; + margin-top: 6; + margin-bottom: 6; + min-children-per-line: 1; + selection-mode: none; + activate-on-single-click: false; + } + + [overlay] + Adw.StatusPage status_page { + /* Translators: A title that is shown when the user has not saved any albums. */ + + title: _("You have no saved albums."); + + /* Translators: A description of what happens when the user has saved albums. */ + + description: _("Your library will be shown here."); + icon-name: "emblem-music-symbolic"; + visible: true; + } + } + } +} diff --git a/src/app/components/library/library.rs b/src/app/components/library/library.rs new file mode 100644 index 0000000..d7adbf3 --- /dev/null +++ b/src/app/components/library/library.rs @@ -0,0 +1,158 @@ +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; +use std::rc::Rc; + +use super::LibraryModel; +use crate::app::components::utils::wrap_flowbox_item; +use crate::app::components::{AlbumWidget, Component, EventListener}; +use crate::app::dispatch::Worker; +use crate::app::models::AlbumModel; +use crate::app::state::LoginEvent; +use crate::app::{AppEvent, BrowserEvent, ListStore}; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/library.ui")] + pub struct LibraryWidget { + #[template_child] + pub scrolled_window: TemplateChild, + + #[template_child] + pub flowbox: TemplateChild, + + #[template_child] + pub status_page: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for LibraryWidget { + const NAME: &'static str = "LibraryWidget"; + type Type = super::LibraryWidget; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for LibraryWidget {} + impl WidgetImpl for LibraryWidget {} + impl BoxImpl for LibraryWidget {} +} + +glib::wrapper! { + pub struct LibraryWidget(ObjectSubclass) @extends gtk::Widget, gtk::Box; +} + +impl Default for LibraryWidget { + fn default() -> Self { + Self::new() + } +} + +impl LibraryWidget { + pub fn new() -> Self { + glib::Object::new() + } + + fn connect_bottom_edge(&self, f: F) + where + F: Fn() + 'static, + { + self.imp() + .scrolled_window + .connect_edge_reached(move |_, pos| { + if let gtk::PositionType::Bottom = pos { + f() + } + }); + } + + fn bind_albums(&self, worker: Worker, store: &ListStore, on_album_pressed: F) + where + F: Fn(String) + Clone + 'static, + { + self.imp() + .flowbox + .bind_model(Some(store.unsafe_store()), move |item| { + wrap_flowbox_item(item, |album_model| { + let f = on_album_pressed.clone(); + let album = AlbumWidget::for_model(album_model, worker.clone()); + album.connect_album_pressed(clone!(@weak album_model => move |_| { + f(album_model.uri()); + })); + album + }) + }); + } + + pub fn status_page(&self) -> &libadwaita::StatusPage { + &self.imp().status_page + } +} + +pub struct Library { + widget: LibraryWidget, + worker: Worker, + model: Rc, +} + +impl Library { + pub fn new(worker: Worker, model: LibraryModel) -> Self { + let model = Rc::new(model); + let widget = LibraryWidget::new(); + widget.connect_bottom_edge(clone!(@weak model => move || { + model.load_more_albums(); + })); + + Self { + widget, + worker, + model, + } + } + + fn bind_flowbox(&self) { + self.widget.bind_albums( + self.worker.clone(), + &self.model.get_list_store().unwrap(), + clone!(@weak self.model as model => move |id| { + model.open_album(id); + }), + ); + } +} + +impl EventListener for Library { + fn on_event(&mut self, event: &AppEvent) { + match event { + AppEvent::Started => { + let _ = self.model.refresh_saved_albums(); + self.bind_flowbox(); + } + AppEvent::LoginEvent(LoginEvent::LoginCompleted(_)) => { + let _ = self.model.refresh_saved_albums(); + } + AppEvent::BrowserEvent(BrowserEvent::LibraryUpdated) => { + self.widget + .status_page() + .set_visible(!self.model.has_albums()); + } + _ => {} + } + } +} + +impl Component for Library { + fn get_root_widget(&self) -> >k::Widget { + self.widget.as_ref() + } +} diff --git a/src/app/components/library/library_model.rs b/src/app/components/library/library_model.rs new file mode 100644 index 0000000..de1fda8 --- /dev/null +++ b/src/app/components/library/library_model.rs @@ -0,0 +1,70 @@ +use std::cell::Ref; +use std::ops::Deref; +use std::rc::Rc; + +use crate::app::models::*; +use crate::app::state::HomeState; +use crate::app::{ActionDispatcher, AppAction, AppModel, BrowserAction, ListStore}; + +pub struct LibraryModel { + app_model: Rc, + dispatcher: Box, +} + +impl LibraryModel { + pub fn new(app_model: Rc, dispatcher: Box) -> Self { + Self { + app_model, + dispatcher, + } + } + + fn state(&self) -> Option> { + self.app_model.map_state_opt(|s| s.browser.home_state()) + } + + pub fn get_list_store(&self) -> Option> + '_> { + Some(Ref::map(self.state()?, |s| &s.albums)) + } + + pub fn refresh_saved_albums(&self) -> Option<()> { + let api = self.app_model.get_spotify(); + let batch_size = self.state()?.next_albums_page.batch_size; + + self.dispatcher + .call_spotify_and_dispatch(move || async move { + api.get_saved_albums(0, batch_size) + .await + .map(|albums| BrowserAction::SetLibraryContent(albums).into()) + }); + + Some(()) + } + + pub fn has_albums(&self) -> bool { + self.get_list_store() + .map(|list| list.len() > 0) + .unwrap_or(false) + } + + pub fn load_more_albums(&self) -> Option<()> { + let api = self.app_model.get_spotify(); + + let next_page = &self.state()?.next_albums_page; + let batch_size = next_page.batch_size; + let offset = next_page.next_offset?; + + self.dispatcher + .call_spotify_and_dispatch(move || async move { + api.get_saved_albums(offset, batch_size) + .await + .map(|albums| BrowserAction::AppendLibraryContent(albums).into()) + }); + + Some(()) + } + + pub fn open_album(&self, album_id: String) { + self.dispatcher.dispatch(AppAction::ViewAlbum(album_id)); + } +} diff --git a/src/app/components/library/mod.rs b/src/app/components/library/mod.rs new file mode 100644 index 0000000..ad8bb31 --- /dev/null +++ b/src/app/components/library/mod.rs @@ -0,0 +1,6 @@ +#[allow(clippy::module_inception)] +mod library; +mod library_model; + +pub use library::*; +pub use library_model::*; diff --git a/src/app/components/login/login.blp b/src/app/components/login/login.blp new file mode 100644 index 0000000..c4a5a9a --- /dev/null +++ b/src/app/components/login/login.blp @@ -0,0 +1,108 @@ +using Gtk 4.0; +using Adw 1; + + +template $LoginWindow : Adw.Window { + default-width: 360; + default-height: 100; + + Box { + hexpand: true; + margin-bottom: 24; + orientation: vertical; + + Adw.HeaderBar { + [title] + Label {} + + styles ["flat"] + } + + WindowHandle { + + Adw.Clamp { + maximum-size: 360; + tightening-threshold: 280; + + Box { + hexpand: true; + orientation: vertical; + margin-start: 16; + margin-end: 16; + spacing: 24; + + Image { + icon-name: "dev.alextren.Spot"; + pixel-size: 128; + margin-bottom: 20; + } + + Box{ + hexpand: true; + orientation: vertical; + spacing: 4; + + Label { + label: _("Welcome to Spot"); + halign: center; + styles ["title-1"] + } + + Label { + /* Translators: Login window title, must mention Premium (a premium account is required). */ + label: _("Log in with your Spotify Account. A Spotify Premium subscription is required to use the app."); + wrap: true; + wrap-mode: word; + halign: center; + justify: center; + styles ["body"] + } + } + + ListBox { + styles ["boxed-list"] + + Adw.EntryRow username { + /* Translators: Placeholder for the username field */ + title: _("Username or Email"); + } + + Adw.PasswordEntryRow password { + /* Translators: Placeholder for the password field */ + title: _("Password"); + } + } + + Revealer auth_error_container { + vexpand: true; + transition-type: slide_up; + + Label { + /* Translators: This error is shown when authentication fails. */ + label: _("Incorrect login credentials."); + halign: center; + justify: center; + wrap: true; + wrap-mode: word; + styles ["error"] + } + } + + Button login_button { + /* Translators: Log in button label */ + label: _("Log in"); + halign: center; + styles ["pill", "suggested-action"] + } + + Button login_with_spotify_button { + /* Translators: Log in button label */ + label: _("Log in with Spotify"); + halign: center; + styles ["pill", "suggested-action"] + } + } + } + } + } +} diff --git a/src/app/components/login/login.rs b/src/app/components/login/login.rs new file mode 100644 index 0000000..95901c8 --- /dev/null +++ b/src/app/components/login/login.rs @@ -0,0 +1,247 @@ +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; +use std::rc::Rc; + +use crate::app::components::EventListener; +use crate::app::credentials::Credentials; +use crate::app::state::{LoginCompletedEvent, LoginEvent}; +use crate::app::AppEvent; + +use super::LoginModel; +mod imp { + + use libadwaita::subclass::prelude::AdwWindowImpl; + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/login.ui")] + pub struct LoginWindow { + #[template_child] + pub username: TemplateChild, + + #[template_child] + pub password: TemplateChild, + + #[template_child] + pub login_button: TemplateChild, + + #[template_child] + pub login_with_spotify_button: TemplateChild, + + #[template_child] + pub auth_error_container: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for LoginWindow { + const NAME: &'static str = "LoginWindow"; + type Type = super::LoginWindow; + type ParentType = libadwaita::Window; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for LoginWindow {} + impl WidgetImpl for LoginWindow {} + impl AdwWindowImpl for LoginWindow {} + impl WindowImpl for LoginWindow {} +} + +glib::wrapper! { + pub struct LoginWindow(ObjectSubclass) @extends gtk::Widget, libadwaita::Window; +} + +impl Default for LoginWindow { + fn default() -> Self { + Self::new() + } +} + +impl LoginWindow { + pub fn new() -> Self { + glib::Object::new() + } + + fn connect_close(&self, on_close: F) + where + F: Fn() + 'static, + { + let window = self.upcast_ref::(); + window.connect_close_request(move |_| { + on_close(); + gtk::Inhibit(false) + }); + } + + fn connect_submit(&self, on_submit: SubmitFn) + where + SubmitFn: Fn(&str, &str) + Clone + 'static, + { + let on_submit_clone = on_submit.clone(); + let controller = gtk::EventControllerKey::new(); + controller.set_propagation_phase(gtk::PropagationPhase::Capture); + controller.connect_key_pressed( + clone!(@weak self as _self => @default-return gtk::Inhibit(false), move |_, key, _, _| { + if key == gdk::Key::Return { + _self.submit(&on_submit_clone); + gtk::Inhibit(true) + } else { + gtk::Inhibit(false) + } + }), + ); + self.add_controller(controller); + + self.imp() + .login_button + .connect_clicked(clone!(@weak self as _self => move |_| { + _self.submit(&on_submit); + })); + } + + fn connect_login_oauth_spotify(&self, on_login_with_spotify_button: F) + where + F: Fn() + 'static, + { + self.imp().login_with_spotify_button.connect_clicked( + clone!(@weak self as _self => move |_| { + _self.login_with_spotify(&on_login_with_spotify_button); + }), + ); + } + + fn show_auth_error(&self, shown: bool) { + let error_class = "error"; + let widget = self.imp(); + if shown { + widget.username.add_css_class(error_class); + widget.password.add_css_class(error_class); + } else { + widget.username.remove_css_class(error_class); + widget.password.remove_css_class(error_class); + } + widget.auth_error_container.set_reveal_child(shown); + } + + fn submit(&self, on_submit: &SubmitFn) + where + SubmitFn: Fn(&str, &str), + { + let widget = self.imp(); + + self.show_auth_error(false); + + let username_text = widget.username.text(); + let password_text = widget.password.text(); + + if username_text.is_empty() { + widget.username.grab_focus(); + } else if password_text.is_empty() { + widget.password.grab_focus(); + } else { + on_submit(username_text.as_str(), password_text.as_str()); + } + } + + fn login_with_spotify(&self, on_login_with_spotify: &F) + where + F: Fn(), + { + self.show_auth_error(false); + on_login_with_spotify() + } +} + +pub struct Login { + parent: gtk::Window, + login_window: LoginWindow, + model: Rc, +} + +impl Login { + pub fn new(parent: gtk::Window, model: LoginModel) -> Self { + let model = Rc::new(model); + + let login_window = LoginWindow::new(); + + login_window.connect_close(clone!(@weak parent => move || { + if let Some(app) = parent.application().as_ref() { + app.quit(); + } + })); + + login_window.connect_submit(clone!(@weak model => move |username, password| { + model.login(username.to_string(), password.to_string()); + })); + + login_window.connect_login_oauth_spotify(clone!(@weak model => move || { + model.login_with_spotify(); + })); + + Self { + parent, + login_window, + model, + } + } + + fn window(&self) -> &libadwaita::Window { + self.login_window.upcast_ref::() + } + + fn show_self(&self) { + self.window().set_transient_for(Some(&self.parent)); + self.window().set_modal(true); + self.window().set_visible(true); + } + + fn hide_and_save_creds(&self, credentials: Credentials) { + self.window().set_visible(false); + self.model.save_for_autologin(credentials); + } + + fn reveal_error(&self) { + self.login_window.show_auth_error(true); + } +} + +impl EventListener for Login { + fn on_event(&mut self, event: &AppEvent) { + info!("received login event {:?}", event); + match event { + AppEvent::LoginEvent(LoginEvent::LoginCompleted(LoginCompletedEvent::Password( + creds, + ))) => { + self.hide_and_save_creds(creds.clone()); + } + AppEvent::LoginEvent(LoginEvent::LoginCompleted(LoginCompletedEvent::Token(token))) => { + self.hide_and_save_creds(token.clone()); + } + AppEvent::LoginEvent(LoginEvent::LoginFailed) => { + self.model.clear_saved_credentials(); + self.reveal_error(); + } + AppEvent::Started => { + self.model.try_autologin(); + } + AppEvent::LoginEvent(LoginEvent::LogoutCompleted | LoginEvent::LoginShown) => { + self.show_self(); + } + AppEvent::LoginEvent(LoginEvent::RefreshTokenCompleted { + token, + token_expiry_time, + }) => { + self.model.save_token(token.clone(), *token_expiry_time); + } + _ => {} + } + } +} diff --git a/src/app/components/login/login_model.rs b/src/app/components/login/login_model.rs new file mode 100644 index 0000000..2567541 --- /dev/null +++ b/src/app/components/login/login_model.rs @@ -0,0 +1,82 @@ +use std::time::SystemTime; + +use gettextrs::*; + +use crate::app::credentials::Credentials; +use crate::app::state::{LoginAction, TryLoginAction}; +use crate::app::{ActionDispatcher, AppAction, Worker}; + +pub struct LoginModel { + dispatcher: Box, + worker: Worker, +} + +impl LoginModel { + pub fn new(dispatcher: Box, worker: Worker) -> Self { + Self { dispatcher, worker } + } + + pub fn try_autologin(&self) { + self.dispatcher.dispatch_async(Box::pin(async { + let action = match Credentials::retrieve().await { + Ok(creds) => LoginAction::TryLogin(if creds.token_expired() { + TryLoginAction::Password { + username: creds.username, + password: creds.password, + } + } else { + TryLoginAction::Token { + username: creds.username, + token: creds.token, + } + }), + Err(err) => { + warn!("Could not retrieve credentials: {}", err); + LoginAction::ShowLogin + } + }; + Some(action.into()) + })); + } + + pub fn clear_saved_credentials(&self) { + self.worker.send_task(async { + let _ = Credentials::logout().await; + }); + } + + pub fn save_token(&self, token: String, token_expiry_time: SystemTime) { + self.worker.send_task(async move { + if let Ok(mut credentials) = Credentials::retrieve().await { + credentials.token = token; + credentials.token_expiry_time = Some(token_expiry_time); + if let Err(err) = credentials.save().await { + warn!("Could not save credentials: {}", err); + } + } + }); + } + + pub fn save_for_autologin(&self, credentials: Credentials) { + self.dispatcher.dispatch_async(Box::pin(async move { + let Err(err) = credentials.save().await else { + return None; + }; + warn!("Could not save credentials: {}", err); + Some(AppAction::ShowNotification(gettext( + // translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store). + "Could not save password. Make sure the session keyring is unlocked.", + ))) + })); + } + + pub fn login(&self, username: String, password: String) { + self.dispatcher + .dispatch(LoginAction::TryLogin(TryLoginAction::Password { username, password }).into()) + } + + pub fn login_with_spotify(&self) { + self.dispatcher + .dispatch(LoginAction::TryLogin(TryLoginAction::OAuthSpotify {}).into()) + } +} diff --git a/src/app/components/login/mod.rs b/src/app/components/login/mod.rs new file mode 100644 index 0000000..66a4bb2 --- /dev/null +++ b/src/app/components/login/mod.rs @@ -0,0 +1,6 @@ +#[allow(clippy::module_inception)] +mod login; +mod login_model; + +pub use login::*; +pub use login_model::*; diff --git a/src/app/components/mod.rs b/src/app/components/mod.rs new file mode 100644 index 0000000..175341c --- /dev/null +++ b/src/app/components/mod.rs @@ -0,0 +1,183 @@ +#[macro_export] +macro_rules! resource { + ($resource:expr) => { + concat!("/dev/alextren/Spot", $resource) + }; +} + +use gettextrs::*; +use std::cell::RefCell; +use std::collections::HashSet; +use std::future::Future; + +use crate::api::SpotifyApiError; +use crate::app::{state::LoginAction, ActionDispatcher, AppAction, AppEvent}; + +mod navigation; +pub use navigation::*; + +mod playback; +pub use playback::*; + +mod playlist; +pub use playlist::*; + +mod login; +pub use login::*; + +mod settings; +pub use settings::*; + +mod player_notifier; +pub use player_notifier::PlayerNotifier; + +mod library; +pub use library::*; + +mod details; +pub use details::*; + +mod search; +pub use search::*; + +mod album; +use album::*; + +mod artist; +use artist::*; + +mod artist_details; +pub use artist_details::*; + +mod user_details; +pub use user_details::*; + +mod now_playing; +pub use now_playing::*; + +mod device_selector; +pub use device_selector::*; + +mod saved_tracks; +pub use saved_tracks::*; + +mod user_menu; +pub use user_menu::*; + +mod notification; +pub use notification::*; + +mod saved_playlists; +pub use saved_playlists::*; + +mod playlist_details; +pub use playlist_details::*; + +mod window; +pub use window::*; + +mod selection; +pub use selection::*; + +mod headerbar; +pub use headerbar::*; + +mod scrolling_header; +pub use scrolling_header::*; + +pub mod utils; + +pub mod labels; + +pub mod sidebar; + +// without this the builder doesn't seen to know about the custom widgets +pub fn expose_custom_widgets() { + playback::expose_widgets(); + selection::expose_widgets(); + headerbar::expose_widgets(); + device_selector::expose_widgets(); + playlist_details::expose_widgets(); + scrolling_header::expose_widgets(); +} + +impl dyn ActionDispatcher { + fn call_spotify_and_dispatch(&self, call: C) + where + C: 'static + Send + Clone + FnOnce() -> F, + F: Send + Future>, + { + self.call_spotify_and_dispatch_many(move || async { call().await.map(|a| vec![a]) }) + } + + fn call_spotify_and_dispatch_many(&self, call: C) + where + C: 'static + Send + Clone + FnOnce() -> F, + F: Send + Future, SpotifyApiError>>, + { + self.dispatch_many_async(Box::pin(async move { + let first_call = call.clone(); + let result = first_call().await; + match result { + Ok(actions) => actions, + Err(SpotifyApiError::NoToken) => vec![], + Err(SpotifyApiError::InvalidToken) => { + let mut retried = call().await.unwrap_or_else(|_| Vec::new()); + retried.insert(0, LoginAction::RefreshToken.into()); + retried + } + Err(err) => { + error!("Spotify API error: {}", err); + vec![AppAction::ShowNotification(gettext( + // translators: This notification is the default message for unhandled errors. Logs refer to console output. + "An error occured. Check logs for details!", + ))] + } + } + })) + } +} + +thread_local!(static CSS_ADDED: RefCell> = RefCell::new(HashSet::new())); + +pub fn display_add_css_provider(resource: &'static str) { + CSS_ADDED.with(|set| { + if set.borrow().contains(resource) { + return; + } + + set.borrow_mut().insert(resource); + + let provider = gtk::CssProvider::new(); + provider.load_from_resource(resource); + + gtk::style_context_add_provider_for_display( + &gdk::Display::default().unwrap(), + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + }); +} + +pub trait EventListener { + fn on_event(&mut self, _: &AppEvent) {} +} + +pub trait Component { + fn get_root_widget(&self) -> >k::Widget; + + fn get_children(&mut self) -> Option<&mut Vec>> { + None + } + + fn broadcast_event(&mut self, event: &AppEvent) { + if let Some(children) = self.get_children() { + for child in children.iter_mut() { + child.on_event(event); + } + } + } +} + +pub trait ListenerComponent: Component + EventListener {} +impl ListenerComponent for T where T: Component + EventListener {} diff --git a/src/app/components/navigation/factory.rs b/src/app/components/navigation/factory.rs new file mode 100644 index 0000000..c1196f0 --- /dev/null +++ b/src/app/components/navigation/factory.rs @@ -0,0 +1,149 @@ +use std::rc::Rc; + +use crate::app::components::sidebar::{Sidebar, SidebarModel}; +use crate::app::components::*; +use crate::app::state::SelectionContext; +use crate::app::{ActionDispatcher, AppModel, Worker}; + +pub struct ScreenFactory { + app_model: Rc, + dispatcher: Box, + worker: Worker, + leaflet: libadwaita::Leaflet, +} + +impl ScreenFactory { + pub fn new( + app_model: Rc, + dispatcher: Box, + worker: Worker, + leaflet: libadwaita::Leaflet, + ) -> Self { + Self { + app_model, + dispatcher, + worker, + leaflet, + } + } + + pub fn make_library(&self) -> impl ListenerComponent { + let model = LibraryModel::new(Rc::clone(&self.app_model), self.dispatcher.box_clone()); + let screen_model = DefaultHeaderBarModel::new( + Some(gettext("Library")), + None, + Rc::clone(&self.app_model), + self.dispatcher.box_clone(), + ); + StandardScreen::new( + Library::new(self.worker.clone(), model), + &self.leaflet, + Rc::new(screen_model), + ) + } + + pub fn make_sidebar(&self, listbox: gtk::ListBox) -> impl ListenerComponent { + let model = SidebarModel::new(Rc::clone(&self.app_model), self.dispatcher.box_clone()); + Sidebar::new(listbox, Rc::new(model)) + } + + pub fn make_saved_playlists(&self) -> impl ListenerComponent { + let model = + SavedPlaylistsModel::new(Rc::clone(&self.app_model), self.dispatcher.box_clone()); + let screen_model = DefaultHeaderBarModel::new( + Some(gettext("Playlists")), + None, + Rc::clone(&self.app_model), + self.dispatcher.box_clone(), + ); + StandardScreen::new( + SavedPlaylists::new(self.worker.clone(), model), + &self.leaflet, + Rc::new(screen_model), + ) + } + + pub fn make_now_playing(&self) -> impl ListenerComponent { + let model = Rc::new(NowPlayingModel::new( + Rc::clone(&self.app_model), + self.dispatcher.box_clone(), + )); + NowPlaying::new(model, self.worker.clone(), &self.leaflet) + } + + pub fn make_saved_tracks(&self) -> impl ListenerComponent { + let screen_model = DefaultHeaderBarModel::new( + Some(gettext("Saved tracks")), + Some(SelectionContext::SavedTracks), + Rc::clone(&self.app_model), + self.dispatcher.box_clone(), + ); + let model = Rc::new(SavedTracksModel::new( + Rc::clone(&self.app_model), + self.dispatcher.box_clone(), + )); + StandardScreen::new( + SavedTracks::new(model, self.worker.clone()), + &self.leaflet, + Rc::new(screen_model), + ) + } + + pub fn make_album_details(&self, id: String) -> impl ListenerComponent { + let model = Rc::new(DetailsModel::new( + id, + Rc::clone(&self.app_model), + self.dispatcher.box_clone(), + )); + Details::new(model, self.worker.clone(), &self.leaflet) + } + + pub fn make_search_results(&self) -> impl ListenerComponent { + let model = + SearchResultsModel::new(Rc::clone(&self.app_model), self.dispatcher.box_clone()); + SearchResults::new(model, self.worker.clone(), &self.leaflet) + } + + pub fn make_artist_details(&self, id: String) -> impl ListenerComponent { + let model = Rc::new(ArtistDetailsModel::new( + id, + Rc::clone(&self.app_model), + self.dispatcher.box_clone(), + )); + let screen_model = SimpleHeaderBarModelWrapper::new( + Rc::clone(&model), + Rc::clone(&self.app_model), + self.dispatcher.box_clone(), + ); + StandardScreen::new( + ArtistDetails::new(model, self.worker.clone()), + &self.leaflet, + Rc::new(screen_model), + ) + } + + pub fn make_playlist_details(&self, id: String) -> impl ListenerComponent { + let model = Rc::new(PlaylistDetailsModel::new( + id, + Rc::clone(&self.app_model), + self.dispatcher.box_clone(), + )); + PlaylistDetails::new(model, self.worker.clone()) + } + + pub fn make_user_details(&self, id: String) -> impl ListenerComponent { + let screen_model = DefaultHeaderBarModel::new( + None, + None, + Rc::clone(&self.app_model), + self.dispatcher.box_clone(), + ); + let model = + UserDetailsModel::new(id, Rc::clone(&self.app_model), self.dispatcher.box_clone()); + StandardScreen::new( + UserDetails::new(model, self.worker.clone()), + &self.leaflet, + Rc::new(screen_model), + ) + } +} diff --git a/src/app/components/navigation/home.rs b/src/app/components/navigation/home.rs new file mode 100644 index 0000000..1c2e62f --- /dev/null +++ b/src/app/components/navigation/home.rs @@ -0,0 +1,88 @@ +use gtk::prelude::*; + +use crate::app::components::sidebar::SidebarDestination; +use crate::app::components::{Component, EventListener, ScreenFactory}; +use crate::app::{AppEvent, BrowserEvent}; + +pub struct HomePane { + stack: gtk::Stack, + components: Vec>, +} + +impl HomePane { + pub fn new(listbox: gtk::ListBox, screen_factory: &ScreenFactory) -> Self { + let library = screen_factory.make_library(); + let saved_playlists = screen_factory.make_saved_playlists(); + let saved_tracks = screen_factory.make_saved_tracks(); + let now_playing = screen_factory.make_now_playing(); + let sidebar = screen_factory.make_sidebar(listbox); + + let stack = gtk::Stack::new(); + stack.set_transition_type(gtk::StackTransitionType::Crossfade); + + let dest = SidebarDestination::Library; + stack.add_titled( + library.get_root_widget(), + Option::from(dest.id()), + &dest.title(), + ); + + let dest = SidebarDestination::SavedTracks; + stack.add_titled( + saved_tracks.get_root_widget(), + Option::from(dest.id()), + &dest.title(), + ); + + let dest = SidebarDestination::SavedPlaylists; + stack.add_titled( + saved_playlists.get_root_widget(), + Option::from(dest.id()), + &dest.title(), + ); + + let dest = SidebarDestination::NowPlaying; + stack.add_titled( + now_playing.get_root_widget(), + Option::from(dest.id()), + &dest.title(), + ); + + Self { + stack, + components: vec![ + Box::new(sidebar), + Box::new(library), + Box::new(saved_playlists), + Box::new(saved_tracks), + Box::new(now_playing), + ], + } + } +} + +impl Component for HomePane { + fn get_root_widget(&self) -> >k::Widget { + self.stack.upcast_ref() + } + + fn get_children(&mut self) -> Option<&mut Vec>> { + Some(&mut self.components) + } +} + +impl EventListener for HomePane { + fn on_event(&mut self, event: &AppEvent) { + match event { + AppEvent::NowPlayingShown => { + self.stack + .set_visible_child_name(SidebarDestination::NowPlaying.id()); + } + AppEvent::BrowserEvent(BrowserEvent::HomeVisiblePageChanged(page)) => { + self.stack.set_visible_child_name(page); + } + _ => {} + } + self.broadcast_event(event); + } +} diff --git a/src/app/components/navigation/mod.rs b/src/app/components/navigation/mod.rs new file mode 100644 index 0000000..b6d9d54 --- /dev/null +++ b/src/app/components/navigation/mod.rs @@ -0,0 +1,11 @@ +#[allow(clippy::module_inception)] +mod navigation; +pub use navigation::*; + +mod navigation_model; +pub use navigation_model::*; + +mod home; + +mod factory; +pub use factory::*; diff --git a/src/app/components/navigation/navigation.rs b/src/app/components/navigation/navigation.rs new file mode 100644 index 0000000..deff472 --- /dev/null +++ b/src/app/components/navigation/navigation.rs @@ -0,0 +1,147 @@ +use gtk::traits::WidgetExt; +use libadwaita::NavigationDirection; +use std::rc::Rc; + +use crate::app::components::{EventListener, ListenerComponent}; +use crate::app::state::ScreenName; +use crate::app::{AppEvent, BrowserEvent}; + +use super::{factory::ScreenFactory, home::HomePane, NavigationModel}; + +pub struct Navigation { + model: Rc, + leaflet: libadwaita::Leaflet, + navigation_stack: gtk::Stack, + home_listbox: gtk::ListBox, + screen_factory: ScreenFactory, + children: Vec>, +} + +impl Navigation { + pub fn new( + model: NavigationModel, + leaflet: libadwaita::Leaflet, + navigation_stack: gtk::Stack, + home_listbox: gtk::ListBox, + screen_factory: ScreenFactory, + ) -> Self { + let model = Rc::new(model); + + leaflet.connect_folded_notify( + clone!(@weak model => move |leaflet| { + let is_main = leaflet.visible_child_name().map(|s| s.as_str() == "main").unwrap_or(false); + let folded = leaflet.is_folded(); + model.set_nav_hidden(folded && is_main); + }) + ); + + leaflet.connect_visible_child_name_notify( + clone!(@weak model => move |leaflet| { + let is_main = leaflet.visible_child_name().map(|s| s.as_str() == "main").unwrap_or(false); + let folded = leaflet.is_folded(); + model.set_nav_hidden(folded && is_main); + }) + ); + + Self { + model, + leaflet, + navigation_stack, + home_listbox, + screen_factory, + children: vec![], + } + } + + fn make_home(&self) -> Box { + Box::new(HomePane::new( + self.home_listbox.clone(), + &self.screen_factory, + )) + } + + fn show_navigation(&self) { + self.leaflet.navigate(NavigationDirection::Back); + } + + fn push_screen(&mut self, name: &ScreenName) { + let component: Box = match name { + ScreenName::Home => self.make_home(), + ScreenName::AlbumDetails(id) => { + Box::new(self.screen_factory.make_album_details(id.to_owned())) + } + ScreenName::Search => Box::new(self.screen_factory.make_search_results()), + ScreenName::Artist(id) => { + Box::new(self.screen_factory.make_artist_details(id.to_owned())) + } + ScreenName::PlaylistDetails(id) => { + Box::new(self.screen_factory.make_playlist_details(id.to_owned())) + } + ScreenName::User(id) => Box::new(self.screen_factory.make_user_details(id.to_owned())), + }; + + let widget = component.get_root_widget().clone(); + self.children.push(component); + + self.leaflet.navigate(NavigationDirection::Forward); + self.navigation_stack + .add_named(&widget, Some(name.identifier().as_ref())); + self.navigation_stack + .set_visible_child_name(name.identifier().as_ref()); + + glib::source::idle_add_local_once(move || { + widget.grab_focus(); + }); + } + + fn pop(&mut self) { + let children = &mut self.children; + let popped = children.pop(); + + let name = self.model.visible_child_name(); + self.navigation_stack + .set_visible_child_name(name.identifier().as_ref()); + + if let Some(child) = popped { + self.navigation_stack.remove(child.get_root_widget()); + } + } + + fn pop_to(&mut self, screen: &ScreenName) { + self.navigation_stack + .set_visible_child_name(screen.identifier().as_ref()); + let remainder = self.children.split_off(self.model.children_count()); + for widget in remainder { + self.navigation_stack.remove(widget.get_root_widget()); + } + } +} + +impl EventListener for Navigation { + fn on_event(&mut self, event: &AppEvent) { + match event { + AppEvent::Started => { + self.push_screen(&ScreenName::Home); + } + AppEvent::BrowserEvent(BrowserEvent::NavigationPushed(name)) => { + self.push_screen(name); + } + AppEvent::BrowserEvent(BrowserEvent::NavigationHidden(false)) => { + self.show_navigation(); + } + AppEvent::BrowserEvent(BrowserEvent::NavigationPopped) => { + self.pop(); + } + AppEvent::BrowserEvent(BrowserEvent::NavigationPoppedTo(name)) => { + self.pop_to(name); + } + AppEvent::BrowserEvent(BrowserEvent::HomeVisiblePageChanged(_)) => { + self.leaflet.navigate(NavigationDirection::Forward); + } + _ => {} + }; + for child in self.children.iter_mut() { + child.on_event(event); + } + } +} diff --git a/src/app/components/navigation/navigation_model.rs b/src/app/components/navigation/navigation_model.rs new file mode 100644 index 0000000..0051787 --- /dev/null +++ b/src/app/components/navigation/navigation_model.rs @@ -0,0 +1,31 @@ +use crate::app::state::ScreenName; +use crate::app::{ActionDispatcher, AppModel, BrowserAction}; +use std::ops::Deref; +use std::rc::Rc; + +pub struct NavigationModel { + app_model: Rc, + dispatcher: Box, +} + +impl NavigationModel { + pub fn new(app_model: Rc, dispatcher: Box) -> Self { + Self { + app_model, + dispatcher, + } + } + + pub fn visible_child_name(&self) -> impl Deref + '_ { + self.app_model.map_state(|s| s.browser.current_screen()) + } + + pub fn set_nav_hidden(&self, hidden: bool) { + self.dispatcher + .dispatch(BrowserAction::SetNavigationHidden(hidden).into()); + } + + pub fn children_count(&self) -> usize { + self.app_model.get_state().browser.count() + } +} diff --git a/src/app/components/notification/mod.rs b/src/app/components/notification/mod.rs new file mode 100644 index 0000000..a0f67bb --- /dev/null +++ b/src/app/components/notification/mod.rs @@ -0,0 +1,47 @@ +use crate::app::components::EventListener; +use crate::app::AppEvent; +use gettextrs::*; +use glib::ToVariant; + +pub struct Notification { + toast_overlay: libadwaita::ToastOverlay, +} + +impl Notification { + pub fn new(toast_overlay: libadwaita::ToastOverlay) -> Self { + Self { toast_overlay } + } + + fn show(&self, content: &str) { + let toast = libadwaita::Toast::builder() + .title(content) + .timeout(4) + .build(); + self.toast_overlay.add_toast(toast); + } + + fn show_playlist_created(&self, id: &str) { + // translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. + let message = gettext("New playlist created."); + // translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. + let label = gettext("View"); + let toast = libadwaita::Toast::builder() + .title(message) + .timeout(4) + .action_name("app.open_playlist") + .button_label(label) + .action_target(&id.to_variant()) + .build(); + self.toast_overlay.add_toast(toast); + } +} + +impl EventListener for Notification { + fn on_event(&mut self, event: &AppEvent) { + if let AppEvent::NotificationShown(content) = event { + self.show(content) + } else if let AppEvent::PlaylistCreatedNotificationShown(id) = event { + self.show_playlist_created(id) + } + } +} diff --git a/src/app/components/now_playing/mod.rs b/src/app/components/now_playing/mod.rs new file mode 100644 index 0000000..d4472f4 --- /dev/null +++ b/src/app/components/now_playing/mod.rs @@ -0,0 +1,6 @@ +#[allow(clippy::module_inception)] +mod now_playing; +pub use now_playing::*; + +mod now_playing_model; +pub use now_playing_model::*; diff --git a/src/app/components/now_playing/now_playing.blp b/src/app/components/now_playing/now_playing.blp new file mode 100644 index 0000000..30b4c38 --- /dev/null +++ b/src/app/components/now_playing/now_playing.blp @@ -0,0 +1,23 @@ +using Gtk 4.0; +using Adw 1; + +template $NowPlayingWidget : Box { + orientation: vertical; + vexpand: true; + hexpand: true; + + $HeaderBarWidget headerbar { + $DeviceSelectorWidget device_selector {} + } + + ScrolledWindow scrolled_window { + vexpand: true; + + Adw.ClampScrollable { + maximum-size: 900; + + ListView song_list { + } + } + } +} diff --git a/src/app/components/now_playing/now_playing.rs b/src/app/components/now_playing/now_playing.rs new file mode 100644 index 0000000..9980e1a --- /dev/null +++ b/src/app/components/now_playing/now_playing.rs @@ -0,0 +1,151 @@ +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; +use std::rc::Rc; + +use super::NowPlayingModel; +use crate::app::components::{ + Component, DeviceSelector, DeviceSelectorWidget, EventListener, HeaderBarComponent, + HeaderBarWidget, Playlist, +}; +use crate::app::state::PlaybackEvent; +use crate::app::{AppEvent, Worker}; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/now_playing.ui")] + pub struct NowPlayingWidget { + #[template_child] + pub song_list: TemplateChild, + + #[template_child] + pub headerbar: TemplateChild, + + #[template_child] + pub device_selector: TemplateChild, + + #[template_child] + pub scrolled_window: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for NowPlayingWidget { + const NAME: &'static str = "NowPlayingWidget"; + type Type = super::NowPlayingWidget; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for NowPlayingWidget { + fn constructed(&self) { + self.parent_constructed(); + } + } + + impl WidgetImpl for NowPlayingWidget {} + impl BoxImpl for NowPlayingWidget {} +} + +glib::wrapper! { + pub struct NowPlayingWidget(ObjectSubclass) @extends gtk::Widget, gtk::Box; +} + +impl NowPlayingWidget { + fn new() -> Self { + glib::Object::new() + } + + fn connect_bottom_edge(&self, f: F) + where + F: Fn() + 'static, + { + self.imp() + .scrolled_window + .connect_edge_reached(move |_, pos| { + if let gtk::PositionType::Bottom = pos { + f() + } + }); + } + + fn song_list_widget(&self) -> >k::ListView { + self.imp().song_list.as_ref() + } + + fn headerbar_widget(&self) -> &HeaderBarWidget { + self.imp().headerbar.as_ref() + } + + fn device_selector_widget(&self) -> &DeviceSelectorWidget { + self.imp().device_selector.as_ref() + } +} + +pub struct NowPlaying { + widget: NowPlayingWidget, + model: Rc, + children: Vec>, +} + +impl NowPlaying { + pub fn new(model: Rc, worker: Worker, leaflet: &libadwaita::Leaflet) -> Self { + let widget = NowPlayingWidget::new(); + + widget.connect_bottom_edge(clone!(@weak model => move || { + model.load_more(); + })); + + let playlist = Box::new(Playlist::new( + widget.song_list_widget().clone(), + model.clone(), + worker, + )); + + let headerbar_widget = widget.headerbar_widget(); + headerbar_widget.bind_to_leaflet(leaflet); + let headerbar = Box::new(HeaderBarComponent::new( + headerbar_widget.clone(), + model.to_headerbar_model(), + )); + + let device_selector = Box::new(DeviceSelector::new( + widget.device_selector_widget().clone(), + model.device_selector_model(), + )); + + Self { + widget, + model, + children: vec![playlist, headerbar, device_selector], + } + } +} + +impl Component for NowPlaying { + fn get_root_widget(&self) -> >k::Widget { + self.widget.upcast_ref() + } + + fn get_children(&mut self) -> Option<&mut Vec>> { + Some(&mut self.children) + } +} + +impl EventListener for NowPlaying { + fn on_event(&mut self, event: &AppEvent) { + if let AppEvent::PlaybackEvent(PlaybackEvent::TrackChanged(_)) = event { + self.model.load_more(); + } + self.broadcast_event(event); + } +} diff --git a/src/app/components/now_playing/now_playing_model.rs b/src/app/components/now_playing/now_playing_model.rs new file mode 100644 index 0000000..c6a0139 --- /dev/null +++ b/src/app/components/now_playing/now_playing_model.rs @@ -0,0 +1,174 @@ +use gio::prelude::*; +use gio::SimpleActionGroup; +use std::ops::Deref; +use std::rc::Rc; + +use crate::app::components::{ + labels, DeviceSelectorModel, HeaderBarModel, PlaylistModel, SimpleHeaderBarModel, + SimpleHeaderBarModelWrapper, +}; +use crate::app::models::{SongDescription, SongListModel}; +use crate::app::state::Device; +use crate::app::state::{ + PlaybackAction, PlaybackState, SelectionAction, SelectionContext, SelectionState, +}; +use crate::app::{ActionDispatcher, AppAction, AppEvent, AppModel}; + +pub struct NowPlayingModel { + app_model: Rc, + dispatcher: Box, +} + +impl NowPlayingModel { + pub fn new(app_model: Rc, dispatcher: Box) -> Self { + Self { + app_model, + dispatcher, + } + } + + fn queue(&self) -> impl Deref + '_ { + self.app_model.map_state(|s| &s.playback) + } + + pub fn load_more(&self) -> Option<()> { + let queue = self.queue(); + let loader = self.app_model.get_batch_loader(); + let query = queue.next_query()?; + debug!("next_query = {:?}", &query); + + self.dispatcher.dispatch_async(Box::pin(async move { + loader + .query(query, |source, song_batch| { + PlaybackAction::LoadPagedSongs(source, song_batch).into() + }) + .await + })); + + Some(()) + } + + pub fn to_headerbar_model(self: &Rc) -> Rc { + Rc::new(SimpleHeaderBarModelWrapper::new( + self.clone(), + self.app_model.clone(), + self.dispatcher.box_clone(), + )) + } + + pub fn device_selector_model(&self) -> DeviceSelectorModel { + DeviceSelectorModel::new(self.app_model.clone(), self.dispatcher.box_clone()) + } + + fn current_selection_context(&self) -> SelectionContext { + let state = self.app_model.get_state(); + match state.playback.current_device() { + Device::Local => SelectionContext::Queue, + Device::Connect(_) => SelectionContext::ReadOnlyQueue, + } + } +} + +impl PlaylistModel for NowPlayingModel { + fn song_list_model(&self) -> SongListModel { + self.queue().songs().clone() + } + + fn is_paused(&self) -> bool { + !self.app_model.get_state().playback.is_playing() + } + + fn current_song_id(&self) -> Option { + self.queue().current_song_id() + } + + fn play_song_at(&self, _pos: usize, id: &str) { + self.dispatcher + .dispatch(PlaybackAction::Load(id.to_string()).into()); + } + + fn autoscroll_to_playing(&self) -> bool { + false // too buggy for now + } + + fn actions_for(&self, id: &str) -> Option { + let queue = self.queue(); + let song = queue.songs().get(id)?; + let song = song.description(); + let group = SimpleActionGroup::new(); + + for view_artist in song.make_artist_actions(self.dispatcher.box_clone(), None) { + group.add_action(&view_artist); + } + group.add_action(&song.make_album_action(self.dispatcher.box_clone(), None)); + group.add_action(&song.make_link_action(None)); + group.add_action(&song.make_dequeue_action(self.dispatcher.box_clone(), None)); + + Some(group.upcast()) + } + + fn menu_for(&self, id: &str) -> Option { + let queue = self.queue(); + let song = queue.songs().get(id)?; + let song = song.description(); + + let menu = gio::Menu::new(); + menu.append(Some(&*labels::VIEW_ALBUM), Some("song.view_album")); + for artist in song.artists.iter() { + menu.append( + Some(&labels::more_from_label(&artist.name)), + Some(&format!("song.view_artist_{}", artist.id)), + ); + } + + menu.append(Some(&*labels::COPY_LINK), Some("song.copy_link")); + menu.append(Some(&*labels::REMOVE_FROM_QUEUE), Some("song.dequeue")); + + Some(menu.upcast()) + } + + fn select_song(&self, id: &str) { + let queue = self.queue(); + if let Some(song) = queue.songs().get(id) { + let song = song.description().clone(); + self.dispatcher + .dispatch(SelectionAction::Select(vec![song]).into()); + } + } + + fn deselect_song(&self, id: &str) { + self.dispatcher + .dispatch(SelectionAction::Deselect(vec![id.to_string()]).into()); + } + + fn enable_selection(&self) -> bool { + self.dispatcher + .dispatch(AppAction::EnableSelection(self.current_selection_context())); + true + } + + fn selection(&self) -> Option + '_>> { + let selection = self.app_model.map_state(|s| &s.selection); + Some(Box::new(selection)) + } +} + +impl SimpleHeaderBarModel for NowPlayingModel { + fn title(&self) -> Option { + None + } + + fn title_updated(&self, _: &AppEvent) -> bool { + false + } + + fn selection_context(&self) -> Option { + Some(self.current_selection_context()) + } + + fn select_all(&self) { + let songs: Vec = self.queue().songs().collect(); + self.dispatcher + .dispatch(SelectionAction::Select(songs).into()); + } +} diff --git a/src/app/components/playback/component.rs b/src/app/components/playback/component.rs new file mode 100644 index 0000000..dbae5b4 --- /dev/null +++ b/src/app/components/playback/component.rs @@ -0,0 +1,162 @@ +use std::ops::Deref; +use std::rc::Rc; + +use crate::app::components::EventListener; +use crate::app::models::*; +use crate::app::state::{PlaybackAction, PlaybackEvent, ScreenName, SelectionEvent}; +use crate::app::{ + ActionDispatcher, AppAction, AppEvent, AppModel, AppState, BrowserAction, Worker, +}; + +use super::playback_widget::PlaybackWidget; + +pub struct PlaybackModel { + app_model: Rc, + dispatcher: Box, +} + +impl PlaybackModel { + pub fn new(app_model: Rc, dispatcher: Box) -> Self { + Self { + app_model, + dispatcher, + } + } + + fn state(&self) -> impl Deref + '_ { + self.app_model.get_state() + } + + fn go_home(&self) { + self.dispatcher.dispatch(AppAction::ViewNowPlaying); + self.dispatcher + .dispatch(BrowserAction::NavigationPopTo(ScreenName::Home).into()); + } + + fn is_playing(&self) -> bool { + self.state().playback.is_playing() + } + + fn is_shuffled(&self) -> bool { + self.state().playback.is_shuffled() + } + + fn current_song(&self) -> Option { + self.app_model.get_state().playback.current_song() + } + + fn play_next_song(&self) { + self.dispatcher.dispatch(PlaybackAction::Next.into()); + } + + fn play_prev_song(&self) { + self.dispatcher.dispatch(PlaybackAction::Previous.into()); + } + + fn toggle_playback(&self) { + self.dispatcher.dispatch(PlaybackAction::TogglePlay.into()); + } + + fn toggle_shuffle(&self) { + self.dispatcher + .dispatch(PlaybackAction::ToggleShuffle.into()); + } + + fn toggle_repeat(&self) { + self.dispatcher + .dispatch(PlaybackAction::ToggleRepeat.into()); + } + + fn seek_to(&self, position: u32) { + self.dispatcher + .dispatch(PlaybackAction::Seek(position).into()); + } +} + +pub struct PlaybackControl { + model: Rc, + widget: PlaybackWidget, + worker: Worker, +} + +impl PlaybackControl { + pub fn new(model: PlaybackModel, widget: PlaybackWidget, worker: Worker) -> Self { + let model = Rc::new(model); + + widget.connect_play_pause(clone!(@weak model => move || model.toggle_playback() )); + widget.connect_next(clone!(@weak model => move || model.play_next_song())); + widget.connect_prev(clone!(@weak model => move || model.play_prev_song())); + widget.connect_shuffle(clone!(@weak model => move || model.toggle_shuffle())); + widget.connect_repeat(clone!(@weak model => move || model.toggle_repeat())); + widget.connect_seek(clone!(@weak model => move |position| model.seek_to(position))); + widget.connect_now_playing_clicked(clone!(@weak model => move || model.go_home())); + + Self { + model, + widget, + worker, + } + } + + fn update_repeat(&self, mode: &RepeatMode) { + self.widget.set_repeat_mode(*mode); + } + + fn update_shuffled(&self) { + self.widget.set_shuffled(self.model.is_shuffled()); + } + + fn update_playing(&self) { + let is_playing = self.model.is_playing(); + self.widget.set_playing(is_playing); + } + + fn update_current_info(&self) { + if let Some(song) = self.model.current_song() { + self.widget + .set_title_and_artist(&song.title, &song.artists_name()); + self.widget.set_song_duration(Some(song.duration as f64)); + if let Some(url) = song.art { + self.widget.set_artwork_from_url(url, &self.worker); + } + } else { + self.widget.reset_info(); + } + } + + fn sync_seek(&self, pos: u32) { + self.widget.set_seek_position(pos as f64); + } +} + +impl EventListener for PlaybackControl { + fn on_event(&mut self, event: &AppEvent) { + match event { + AppEvent::PlaybackEvent(PlaybackEvent::PlaybackPaused) + | AppEvent::PlaybackEvent(PlaybackEvent::PlaybackResumed) => { + self.update_playing(); + } + AppEvent::PlaybackEvent(PlaybackEvent::RepeatModeChanged(mode)) => { + self.update_repeat(mode); + } + AppEvent::PlaybackEvent(PlaybackEvent::ShuffleChanged(_)) => { + self.update_shuffled(); + } + AppEvent::PlaybackEvent(PlaybackEvent::TrackChanged(_)) => { + self.update_current_info(); + } + AppEvent::PlaybackEvent(PlaybackEvent::PlaybackStopped) => { + self.update_playing(); + self.update_current_info(); + } + AppEvent::PlaybackEvent(PlaybackEvent::SeekSynced(pos)) + | AppEvent::PlaybackEvent(PlaybackEvent::TrackSeeked(pos)) => { + self.sync_seek(*pos); + } + AppEvent::SelectionEvent(SelectionEvent::SelectionModeChanged(active)) => { + self.widget.set_seekbar_visible(!active); + } + _ => {} + } + } +} diff --git a/src/app/components/playback/mod.rs b/src/app/components/playback/mod.rs new file mode 100644 index 0000000..2800d3f --- /dev/null +++ b/src/app/components/playback/mod.rs @@ -0,0 +1,11 @@ +mod component; +mod playback_controls; +mod playback_info; +mod playback_widget; +pub use component::*; + +use glib::prelude::*; + +pub fn expose_widgets() { + playback_widget::PlaybackWidget::static_type(); +} diff --git a/src/app/components/playback/playback.css b/src/app/components/playback/playback.css new file mode 100644 index 0000000..3446a42 --- /dev/null +++ b/src/app/components/playback/playback.css @@ -0,0 +1,31 @@ +.seek-bar { + padding: 0; + padding-bottom: 2px; + min-height: 1px; +} + +.seek-bar trough, .seek-bar highlight { + border-radius: 0; + border-left: none; + border-right: none; + min-height: 1px; + transition: min-height 100ms ease; +} + +.seek-bar--active trough, .seek-bar--active highlight { + min-height: 5px; +} + +.seek-bar--active:hover trough, .seek-bar--active:hover highlight { + min-height: 10px; +} + +.seek-bar highlight { + border-left: none; + border-right: none; +} + +.playback-button { + min-width: 40px; + min-height: 40px; +} diff --git a/src/app/components/playback/playback_controls.blp b/src/app/components/playback/playback_controls.blp new file mode 100644 index 0000000..4b00b50 --- /dev/null +++ b/src/app/components/playback/playback_controls.blp @@ -0,0 +1,57 @@ +using Gtk 4.0; + +template $PlaybackControlsWidget : Box { + halign: center; + hexpand: true; + spacing: 8; + homogeneous: true; + + ToggleButton shuffle { + receives-default: true; + halign: center; + valign: center; + has-frame: false; + icon-name: "media-playlist-shuffle-symbolic"; + tooltip-text: _("Shuffle"); + } + + Button prev { + receives-default: true; + halign: center; + valign: center; + has-frame: false; + icon-name: "media-skip-backward-symbolic"; + tooltip-text: _("Previous"); + } + + Button play_pause { + receives-default: true; + halign: center; + valign: center; + icon-name: "media-playback-start-symbolic"; + tooltip-text: "Play/Pause"; + + styles [ + "circular", + "playback-button", + ] + } + + Button next { + receives-default: true; + halign: center; + valign: center; + has-frame: false; + icon-name: "media-skip-forward-symbolic"; + tooltip-text: _("Next"); + } + + Button repeat { + receives-default: true; + halign: center; + valign: center; + has-frame: false; + icon-name: "media-playlist-consecutive-symbolic"; + tooltip-text: _("Repeat"); + } +} diff --git a/src/app/components/playback/playback_controls.rs b/src/app/components/playback/playback_controls.rs new file mode 100644 index 0000000..d656a60 --- /dev/null +++ b/src/app/components/playback/playback_controls.rs @@ -0,0 +1,123 @@ +use gettextrs::gettext; +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::{glib, CompositeTemplate}; + +use crate::app::models::RepeatMode; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/playback_controls.ui")] + pub struct PlaybackControlsWidget { + #[template_child] + pub play_pause: TemplateChild, + + #[template_child] + pub next: TemplateChild, + + #[template_child] + pub prev: TemplateChild, + + #[template_child] + pub shuffle: TemplateChild, + + #[template_child] + pub repeat: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for PlaybackControlsWidget { + const NAME: &'static str = "PlaybackControlsWidget"; + type Type = super::PlaybackControlsWidget; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for PlaybackControlsWidget {} + impl WidgetImpl for PlaybackControlsWidget {} + impl BoxImpl for PlaybackControlsWidget {} +} + +glib::wrapper! { + pub struct PlaybackControlsWidget(ObjectSubclass) @extends gtk::Widget, gtk::Box; +} + +impl PlaybackControlsWidget { + pub fn set_playing(&self, is_playing: bool) { + let playback_icon = if is_playing { + "media-playback-pause-symbolic" + } else { + "media-playback-start-symbolic" + }; + + let translated_tooltip = if is_playing { + gettext("Pause") + } else { + gettext("Play") + }; + let tooltip_text = Some(translated_tooltip.as_str()); + + let playback_control = self.imp(); + playback_control.play_pause.set_icon_name(playback_icon); + playback_control.play_pause.set_tooltip_text(tooltip_text); + } + + pub fn set_shuffled(&self, shuffled: bool) { + self.imp().shuffle.set_active(shuffled); + } + + pub fn set_repeat_mode(&self, mode: RepeatMode) { + let repeat_mode_icon = match mode { + RepeatMode::Song => "media-playlist-repeat-song-symbolic", + RepeatMode::Playlist => "media-playlist-repeat-symbolic", + RepeatMode::None => "media-playlist-consecutive-symbolic", + }; + + self.imp().repeat.set_icon_name(repeat_mode_icon); + } + + pub fn connect_play_pause(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().play_pause.connect_clicked(move |_| f()); + } + + pub fn connect_prev(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().prev.connect_clicked(move |_| f()); + } + + pub fn connect_next(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().next.connect_clicked(move |_| f()); + } + + pub fn connect_shuffle(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().shuffle.connect_clicked(move |_| f()); + } + + pub fn connect_repeat(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().repeat.connect_clicked(move |_| f()); + } +} diff --git a/src/app/components/playback/playback_info.blp b/src/app/components/playback/playback_info.blp new file mode 100644 index 0000000..5c4445c --- /dev/null +++ b/src/app/components/playback/playback_info.blp @@ -0,0 +1,43 @@ +using Gtk 4.0; + +template $PlaybackInfoWidget : Button { + receives-default: true; + halign: start; + valign: center; + has-frame: false; + + layout { + column-span: "1"; + column: "0"; + row: "0"; + } + + Box { + halign: center; + + Image playing_image { + width-request: 40; + height-request: 40; + icon-name: "emblem-music-symbolic"; + } + + Label current_song_info { + visible: false; + halign: start; + hexpand: true; + margin-start: 12; + margin-end: 12; + + /* Translators: Short text displayed instead of a song title when nothing plays */ + + label: _("No song playing"); + use-markup: true; + ellipsize: middle; + lines: 1; + } + } + + styles [ + "body", + ] +} diff --git a/src/app/components/playback/playback_info.rs b/src/app/components/playback/playback_info.rs new file mode 100644 index 0000000..f18e881 --- /dev/null +++ b/src/app/components/playback/playback_info.rs @@ -0,0 +1,74 @@ +use gettextrs::gettext; +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::{glib, CompositeTemplate}; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/playback_info.ui")] + pub struct PlaybackInfoWidget { + #[template_child] + pub playing_image: TemplateChild, + + #[template_child] + pub current_song_info: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for PlaybackInfoWidget { + const NAME: &'static str = "PlaybackInfoWidget"; + type Type = super::PlaybackInfoWidget; + type ParentType = gtk::Button; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for PlaybackInfoWidget {} + impl WidgetImpl for PlaybackInfoWidget {} + impl ButtonImpl for PlaybackInfoWidget {} +} + +glib::wrapper! { + pub struct PlaybackInfoWidget(ObjectSubclass) @extends gtk::Widget, gtk::Button; +} + +impl PlaybackInfoWidget { + pub fn set_title_and_artist(&self, title: &str, artist: &str) { + let widget = self.imp(); + let title = glib::markup_escape_text(title); + let artist = glib::markup_escape_text(artist); + let label = format!("{}\n{}", title.as_str(), artist.as_str()); + widget.current_song_info.set_label(&label[..]); + } + + pub fn reset_info(&self) { + let widget = self.imp(); + widget + .current_song_info + // translators: Short text displayed instead of a song title when nothing plays + .set_label(&gettext("No song playing")); + widget + .playing_image + .set_from_icon_name(Some("emblem-music-symbolic")); + widget + .playing_image + .set_from_icon_name(Some("emblem-music-symbolic")); + } + + pub fn set_info_visible(&self, visible: bool) { + self.imp().current_song_info.set_visible(visible); + } + + pub fn set_artwork(&self, art: &gdk_pixbuf::Pixbuf) { + self.imp().playing_image.set_from_pixbuf(Some(art)); + } +} diff --git a/src/app/components/playback/playback_widget.blp b/src/app/components/playback/playback_widget.blp new file mode 100644 index 0000000..d1a2fdd --- /dev/null +++ b/src/app/components/playback/playback_widget.blp @@ -0,0 +1,101 @@ +using Gtk 4.0; +using Adw 1; + +template $PlaybackWidget : Box { + orientation: vertical; + + Scale seek_bar { + show-fill-level: true; + restrict-to-fill-level: false; + fill-level: 0; + digits: 0; + value-pos: left; + + styles [ + "seek-bar", + ] + } + + Adw.Squeezer { + margin-top: 4; + margin-bottom: 4; + margin-start: 8; + margin-end: 8; + + Grid { + hexpand: true; + column-homogeneous: true; + + $PlaybackInfoWidget now_playing { + receives-default: "1"; + halign: "start"; + valign: "center"; + has-frame: "0"; + + layout { + column-span: "1"; + column: "0"; + row: "0"; + } + } + + $PlaybackControlsWidget controls { + layout { + column-span: "1"; + column: "1"; + row: "0"; + } + } + + Box { + margin-top: 4; + margin-bottom: 4; + margin-start: 4; + margin-end: 4; + + layout { + column-span: "1"; + column: "2"; + row: "0"; + } + + Label track_position { + sensitive: false; + label: "0∶00"; + halign: end; + hexpand: true; + + styles [ + "numeric", + ] + } + + Label track_duration { + sensitive: false; + label: " / 0∶00"; + halign: end; + + styles [ + "numeric", + ] + } + } + } + + Box { + halign: fill; + hexpand: true; + + $PlaybackInfoWidget now_playing_mobile { + receives-default: "1"; + halign: "start"; + valign: "center"; + has-frame: "0"; + } + + $PlaybackControlsWidget controls_mobile { + halign: "center"; + } + } + } +} diff --git a/src/app/components/playback/playback_widget.rs b/src/app/components/playback/playback_widget.rs new file mode 100644 index 0000000..984f4ed --- /dev/null +++ b/src/app/components/playback/playback_widget.rs @@ -0,0 +1,242 @@ +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::{glib, CompositeTemplate}; + +use crate::app::components::display_add_css_provider; +use crate::app::components::utils::{format_duration, Clock, Debouncer}; +use crate::app::loader::ImageLoader; +use crate::app::models::RepeatMode; +use crate::app::Worker; + +use super::playback_controls::PlaybackControlsWidget; +use super::playback_info::PlaybackInfoWidget; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/playback_widget.ui")] + pub struct PlaybackWidget { + #[template_child] + pub controls: TemplateChild, + + #[template_child] + pub controls_mobile: TemplateChild, + + #[template_child] + pub now_playing: TemplateChild, + + #[template_child] + pub now_playing_mobile: TemplateChild, + + #[template_child] + pub seek_bar: TemplateChild, + + #[template_child] + pub track_position: TemplateChild, + + #[template_child] + pub track_duration: TemplateChild, + + pub clock: Clock, + } + + #[glib::object_subclass] + impl ObjectSubclass for PlaybackWidget { + const NAME: &'static str = "PlaybackWidget"; + type Type = super::PlaybackWidget; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for PlaybackWidget { + fn constructed(&self) { + self.parent_constructed(); + self.now_playing_mobile.set_info_visible(false); + self.now_playing.set_info_visible(true); + display_add_css_provider(resource!("/components/playback.css")); + } + } + + impl WidgetImpl for PlaybackWidget {} + impl BoxImpl for PlaybackWidget {} +} + +glib::wrapper! { + pub struct PlaybackWidget(ObjectSubclass) @extends gtk::Widget, gtk::Box; +} + +impl PlaybackWidget { + pub fn set_title_and_artist(&self, title: &str, artist: &str) { + let widget = self.imp(); + widget.now_playing.set_title_and_artist(title, artist); + } + + pub fn reset_info(&self) { + let widget = self.imp(); + widget.now_playing.reset_info(); + widget.now_playing_mobile.reset_info(); + self.set_song_duration(None); + } + + fn set_artwork(&self, image: &gdk_pixbuf::Pixbuf) { + let widget = self.imp(); + widget.now_playing.set_artwork(image); + widget.now_playing_mobile.set_artwork(image); + } + + pub fn set_artwork_from_url(&self, url: String, worker: &Worker) { + let weak_self = self.downgrade(); + worker.send_local_task(async move { + let loader = ImageLoader::new(); + let result = loader.load_remote(&url, "jpg", 48, 48).await; + if let (Some(ref _self), Some(ref result)) = (weak_self.upgrade(), result) { + _self.set_artwork(result); + } + }); + } + + pub fn set_song_duration(&self, duration: Option) { + let widget = self.imp(); + let class = "seek-bar--active"; + if let Some(duration) = duration { + self.add_css_class(class); + widget.seek_bar.set_range(0.0, duration); + widget.seek_bar.set_value(0.0); + widget.track_position.set_text("0∶00"); + widget + .track_duration + .set_text(&format!(" / {}", format_duration(duration))); + widget.track_position.set_visible(true); + widget.track_duration.set_visible(true); + } else { + self.remove_css_class(class); + widget.seek_bar.set_range(0.0, 0.0); + widget.track_position.set_visible(false); + widget.track_duration.set_visible(false); + } + } + + pub fn set_seek_position(&self, pos: f64) { + let widget = self.imp(); + widget.seek_bar.set_value(pos); + widget.track_position.set_text(&format_duration(pos)); + } + + pub fn increment_seek_position(&self) { + let value = self.imp().seek_bar.value() + 1_000.0; + self.set_seek_position(value); + } + + pub fn connect_now_playing_clicked(&self, f: F) + where + F: Fn() + Clone + 'static, + { + let widget = self.imp(); + let f_clone = f.clone(); + widget.now_playing.connect_clicked(move |_| f_clone()); + widget.now_playing_mobile.connect_clicked(move |_| f()); + } + + pub fn connect_seek(&self, seek: Seek) + where + Seek: Fn(u32) + Clone + 'static, + { + let debouncer = Debouncer::new(); + let widget = self.imp(); + widget.seek_bar.set_increments(5_000.0, 10_000.0); + widget.seek_bar.connect_change_value( + clone!(@weak self as _self => @default-return glib::signal::Inhibit(false), move |_, _, requested| { + _self.imp() + .track_position + .set_text(&format_duration(requested)); + let seek = seek.clone(); + debouncer.debounce(200, move || seek(requested as u32)); + glib::signal::Inhibit(false) + }), + ); + } + + pub fn set_playing(&self, is_playing: bool) { + let widget = self.imp(); + widget.controls.set_playing(is_playing); + widget.controls_mobile.set_playing(is_playing); + if is_playing { + widget + .clock + .start(clone!(@weak self as _self => move || _self.increment_seek_position())); + } else { + widget.clock.stop(); + } + } + + pub fn set_repeat_mode(&self, mode: RepeatMode) { + let widget = self.imp(); + widget.controls.set_repeat_mode(mode); + widget.controls_mobile.set_repeat_mode(mode); + } + + pub fn set_shuffled(&self, shuffled: bool) { + let widget = self.imp(); + widget.controls.set_shuffled(shuffled); + widget.controls_mobile.set_shuffled(shuffled); + } + + pub fn set_seekbar_visible(&self, visible: bool) { + let widget = self.imp(); + widget.seek_bar.set_visible(visible); + } + + pub fn connect_play_pause(&self, f: F) + where + F: Fn() + Clone + 'static, + { + let widget = self.imp(); + widget.controls.connect_play_pause(f.clone()); + widget.controls_mobile.connect_play_pause(f); + } + + pub fn connect_prev(&self, f: F) + where + F: Fn() + Clone + 'static, + { + let widget = self.imp(); + widget.controls.connect_prev(f.clone()); + widget.controls_mobile.connect_prev(f); + } + + pub fn connect_next(&self, f: F) + where + F: Fn() + Clone + 'static, + { + let widget = self.imp(); + widget.controls.connect_next(f.clone()); + widget.controls_mobile.connect_next(f); + } + + pub fn connect_shuffle(&self, f: F) + where + F: Fn() + Clone + 'static, + { + let widget = self.imp(); + widget.controls.connect_shuffle(f.clone()); + widget.controls_mobile.connect_shuffle(f); + } + + pub fn connect_repeat(&self, f: F) + where + F: Fn() + Clone + 'static, + { + let widget = self.imp(); + widget.controls.connect_repeat(f.clone()); + widget.controls_mobile.connect_repeat(f); + } +} diff --git a/src/app/components/player_notifier.rs b/src/app/components/player_notifier.rs new file mode 100644 index 0000000..5fbf8ce --- /dev/null +++ b/src/app/components/player_notifier.rs @@ -0,0 +1,239 @@ +use std::ops::Deref; +use std::rc::Rc; + +use futures::channel::mpsc::UnboundedSender; +use librespot::core::spotify_id::{SpotifyId, SpotifyItemType}; + +use crate::app::components::EventListener; +use crate::app::state::{ + Device, LoginAction, LoginEvent, LoginStartedEvent, PlaybackEvent, SettingsEvent, +}; +use crate::app::{ActionDispatcher, AppAction, AppEvent, AppModel, SongsSource}; +use crate::connect::ConnectCommand; +use crate::player::Command; + +enum CurrentlyPlaying { + WithSource { + source: SongsSource, + offset: usize, + song: String, + }, + Songs { + songs: Vec, + offset: usize, + }, +} + +impl CurrentlyPlaying { + fn song_id(&self) -> &String { + match self { + Self::WithSource { song, .. } => song, + Self::Songs { songs, offset } => &songs[*offset], + } + } +} + +pub struct PlayerNotifier { + app_model: Rc, + dispatcher: Box, + command_sender: UnboundedSender, + connect_command_sender: UnboundedSender, +} + +impl PlayerNotifier { + pub fn new( + app_model: Rc, + dispatcher: Box, + command_sender: UnboundedSender, + connect_command_sender: UnboundedSender, + ) -> Self { + Self { + app_model, + dispatcher, + command_sender, + connect_command_sender, + } + } + + fn is_playing(&self) -> bool { + self.app_model.get_state().playback.is_playing() + } + + fn currently_playing(&self) -> Option { + let state = self.app_model.get_state(); + let song = state.playback.current_song_id()?; + let offset = state.playback.current_song_index()?; + let source = state.playback.current_source().cloned(); + let result = match source { + Some(source) if source.has_spotify_uri() => CurrentlyPlaying::WithSource { + source, + offset, + song, + }, + _ => CurrentlyPlaying::Songs { + songs: state.playback.songs().map_collect(|s| s.id), + offset, + }, + }; + Some(result) + } + + fn device(&self) -> impl Deref + '_ { + self.app_model.map_state(|s| s.playback.current_device()) + } + + fn notify_login(&self, event: &LoginEvent) { + info!("notify_login: {:?}", event); + let command = match event { + LoginEvent::LoginStarted(LoginStartedEvent::Password { username, password }) => { + Some(Command::PasswordLogin { + username: username.to_owned(), + password: password.to_owned(), + }) + } + LoginEvent::LoginStarted(LoginStartedEvent::Token { username, token }) => { + Some(Command::TokenLogin { + username: username.to_owned(), + token: token.to_owned(), + }) + } + LoginEvent::LoginStarted(LoginStartedEvent::OAuthSpotify {}) => { + Some(Command::OAuthLogin) + } + LoginEvent::FreshTokenRequested => Some(Command::RefreshToken), + LoginEvent::LogoutCompleted => Some(Command::Logout), + _ => None, + }; + + if let Some(command) = command { + self.send_command_to_local_player(command); + } + } + + fn notify_connect_player(&self, event: &PlaybackEvent) { + let event = event.clone(); + let currently_playing = self.currently_playing(); + let command = match event { + PlaybackEvent::TrackChanged(_) | PlaybackEvent::SourceChanged => { + match currently_playing { + Some(CurrentlyPlaying::WithSource { + source, + offset, + song, + }) => Some(ConnectCommand::PlayerLoadInContext { + source, + offset, + song, + }), + Some(CurrentlyPlaying::Songs { songs, offset }) => { + Some(ConnectCommand::PlayerLoad { songs, offset }) + } + None => None, + } + } + PlaybackEvent::TrackSeeked(position) => { + Some(ConnectCommand::PlayerSeek(position as usize)) + } + PlaybackEvent::PlaybackPaused => Some(ConnectCommand::PlayerPause), + PlaybackEvent::PlaybackResumed => Some(ConnectCommand::PlayerResume), + PlaybackEvent::VolumeSet(volume) => Some(ConnectCommand::PlayerSetVolume( + (volume * 100f64).trunc() as u8, + )), + PlaybackEvent::RepeatModeChanged(mode) => Some(ConnectCommand::PlayerRepeat(mode)), + PlaybackEvent::ShuffleChanged(shuffled) => { + Some(ConnectCommand::PlayerShuffle(shuffled)) + } + _ => None, + }; + + if let Some(command) = command { + self.send_command_to_connect_player(command); + } + } + + fn notify_local_player(&self, event: &PlaybackEvent) { + let command = match event { + PlaybackEvent::PlaybackPaused => Some(Command::PlayerPause), + PlaybackEvent::PlaybackResumed => Some(Command::PlayerResume), + PlaybackEvent::PlaybackStopped => Some(Command::PlayerStop), + PlaybackEvent::VolumeSet(volume) => Some(Command::PlayerSetVolume(*volume)), + PlaybackEvent::TrackChanged(id) => { + info!("track changed: {}", id); + SpotifyId::from_base62(id).ok().map(|mut track| { + track.item_type = SpotifyItemType::Track; + Command::PlayerLoad { + track, + resume: true, + } + }) + } + PlaybackEvent::SourceChanged => { + let resume = self.is_playing(); + self.currently_playing() + .and_then(|c| SpotifyId::from_base62(c.song_id()).ok()) + .map(|mut track| { + track.item_type = SpotifyItemType::Track; + Command::PlayerLoad { track, resume } + }) + } + PlaybackEvent::TrackSeeked(position) => Some(Command::PlayerSeek(*position)), + PlaybackEvent::Preload(id) => SpotifyId::from_base62(id) + .ok() + .map(|mut track| { + track.item_type = SpotifyItemType::Track; + track + }) + .map(Command::PlayerPreload), + _ => None, + }; + + if let Some(command) = command { + self.send_command_to_local_player(command); + } + } + + fn send_command_to_connect_player(&self, command: ConnectCommand) { + self.connect_command_sender.unbounded_send(command).unwrap(); + } + + fn send_command_to_local_player(&self, command: Command) { + let dispatcher = &self.dispatcher; + self.command_sender + .unbounded_send(command) + .unwrap_or_else(|_| { + dispatcher.dispatch(AppAction::LoginAction(LoginAction::SetLoginFailure)); + }); + } + + fn switch_device(&mut self, device: &Device) { + match device { + Device::Connect(device) => { + self.send_command_to_local_player(Command::PlayerStop); + self.send_command_to_connect_player(ConnectCommand::SetDevice(device.id.clone())); + self.notify_connect_player(&PlaybackEvent::SourceChanged); + } + Device::Local => { + self.send_command_to_connect_player(ConnectCommand::PlayerStop); + self.notify_local_player(&PlaybackEvent::SourceChanged); + } + } + } +} + +impl EventListener for PlayerNotifier { + fn on_event(&mut self, event: &AppEvent) { + let device = self.device().clone(); + match (device, event) { + (_, AppEvent::LoginEvent(event)) => self.notify_login(event), + (_, AppEvent::PlaybackEvent(PlaybackEvent::SwitchedDevice(d))) => self.switch_device(d), + (Device::Local, AppEvent::PlaybackEvent(event)) => self.notify_local_player(event), + (Device::Local, AppEvent::SettingsEvent(SettingsEvent::PlayerSettingsChanged)) => { + self.send_command_to_local_player(Command::ReloadSettings) + } + (Device::Connect(_), AppEvent::PlaybackEvent(event)) => { + self.notify_connect_player(event) + } + _ => {} + } + } +} diff --git a/src/app/components/playlist/mod.rs b/src/app/components/playlist/mod.rs new file mode 100644 index 0000000..e319aa0 --- /dev/null +++ b/src/app/components/playlist/mod.rs @@ -0,0 +1,8 @@ +#[allow(clippy::module_inception)] +mod playlist; +pub use playlist::*; + +mod song; +pub use song::*; + +mod song_actions; diff --git a/src/app/components/playlist/playback-indicator/playback-0-symbolic.svg b/src/app/components/playlist/playback-indicator/playback-0-symbolic.svg new file mode 100644 index 0000000..c80127a --- /dev/null +++ b/src/app/components/playlist/playback-indicator/playback-0-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/components/playlist/playback-indicator/playback-1-symbolic.svg b/src/app/components/playlist/playback-indicator/playback-1-symbolic.svg new file mode 100644 index 0000000..1cdba19 --- /dev/null +++ b/src/app/components/playlist/playback-indicator/playback-1-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/components/playlist/playback-indicator/playback-10-symbolic.svg b/src/app/components/playlist/playback-indicator/playback-10-symbolic.svg new file mode 100644 index 0000000..c37ead2 --- /dev/null +++ b/src/app/components/playlist/playback-indicator/playback-10-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/components/playlist/playback-indicator/playback-11-symbolic.svg b/src/app/components/playlist/playback-indicator/playback-11-symbolic.svg new file mode 100644 index 0000000..a3e0176 --- /dev/null +++ b/src/app/components/playlist/playback-indicator/playback-11-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/components/playlist/playback-indicator/playback-12-symbolic.svg b/src/app/components/playlist/playback-indicator/playback-12-symbolic.svg new file mode 100644 index 0000000..2f1c0c2 --- /dev/null +++ b/src/app/components/playlist/playback-indicator/playback-12-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/components/playlist/playback-indicator/playback-13-symbolic.svg b/src/app/components/playlist/playback-indicator/playback-13-symbolic.svg new file mode 100644 index 0000000..581321f --- /dev/null +++ b/src/app/components/playlist/playback-indicator/playback-13-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/components/playlist/playback-indicator/playback-14-symbolic.svg b/src/app/components/playlist/playback-indicator/playback-14-symbolic.svg new file mode 100644 index 0000000..dc7255f --- /dev/null +++ b/src/app/components/playlist/playback-indicator/playback-14-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/components/playlist/playback-indicator/playback-15-symbolic.svg b/src/app/components/playlist/playback-indicator/playback-15-symbolic.svg new file mode 100644 index 0000000..5425e75 --- /dev/null +++ b/src/app/components/playlist/playback-indicator/playback-15-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/components/playlist/playback-indicator/playback-16-symbolic.svg b/src/app/components/playlist/playback-indicator/playback-16-symbolic.svg new file mode 100644 index 0000000..aa07e2d --- /dev/null +++ b/src/app/components/playlist/playback-indicator/playback-16-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/components/playlist/playback-indicator/playback-2-symbolic.svg b/src/app/components/playlist/playback-indicator/playback-2-symbolic.svg new file mode 100644 index 0000000..743ad3d --- /dev/null +++ b/src/app/components/playlist/playback-indicator/playback-2-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/components/playlist/playback-indicator/playback-3-symbolic.svg b/src/app/components/playlist/playback-indicator/playback-3-symbolic.svg new file mode 100644 index 0000000..7a6bc91 --- /dev/null +++ b/src/app/components/playlist/playback-indicator/playback-3-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/components/playlist/playback-indicator/playback-4-symbolic.svg b/src/app/components/playlist/playback-indicator/playback-4-symbolic.svg new file mode 100644 index 0000000..a61b085 --- /dev/null +++ b/src/app/components/playlist/playback-indicator/playback-4-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/components/playlist/playback-indicator/playback-5-symbolic.svg b/src/app/components/playlist/playback-indicator/playback-5-symbolic.svg new file mode 100644 index 0000000..fbc8614 --- /dev/null +++ b/src/app/components/playlist/playback-indicator/playback-5-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/components/playlist/playback-indicator/playback-6-symbolic.svg b/src/app/components/playlist/playback-indicator/playback-6-symbolic.svg new file mode 100644 index 0000000..018ac0d --- /dev/null +++ b/src/app/components/playlist/playback-indicator/playback-6-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/components/playlist/playback-indicator/playback-7-symbolic.svg b/src/app/components/playlist/playback-indicator/playback-7-symbolic.svg new file mode 100644 index 0000000..54d1b35 --- /dev/null +++ b/src/app/components/playlist/playback-indicator/playback-7-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/components/playlist/playback-indicator/playback-8-symbolic.svg b/src/app/components/playlist/playback-indicator/playback-8-symbolic.svg new file mode 100644 index 0000000..e70c5d3 --- /dev/null +++ b/src/app/components/playlist/playback-indicator/playback-8-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/components/playlist/playback-indicator/playback-9-symbolic.svg b/src/app/components/playlist/playback-indicator/playback-9-symbolic.svg new file mode 100644 index 0000000..df12667 --- /dev/null +++ b/src/app/components/playlist/playback-indicator/playback-9-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/components/playlist/playback-indicator/playback-paused-symbolic.svg b/src/app/components/playlist/playback-indicator/playback-paused-symbolic.svg new file mode 100644 index 0000000..97da731 --- /dev/null +++ b/src/app/components/playlist/playback-indicator/playback-paused-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/components/playlist/playlist.rs b/src/app/components/playlist/playlist.rs new file mode 100644 index 0000000..691f783 --- /dev/null +++ b/src/app/components/playlist/playlist.rs @@ -0,0 +1,248 @@ +use gio::prelude::*; +use gtk::prelude::*; +use std::ops::Deref; +use std::rc::Rc; + +use crate::app::components::utils::{ancestor, AnimatorDefault}; +use crate::app::components::{Component, EventListener, SongWidget}; +use crate::app::models::{SongListModel, SongModel, SongState}; +use crate::app::state::{PlaybackEvent, SelectionEvent, SelectionState}; +use crate::app::{AppEvent, Worker}; + +pub trait PlaylistModel { + fn is_paused(&self) -> bool; + + fn song_list_model(&self) -> SongListModel; + + fn current_song_id(&self) -> Option; + + fn play_song_at(&self, pos: usize, id: &str); + + fn autoscroll_to_playing(&self) -> bool { + true + } + + fn show_song_covers(&self) -> bool { + true + } + + fn actions_for(&self, _id: &str) -> Option { + None + } + fn menu_for(&self, _id: &str) -> Option { + None + } + + fn select_song(&self, _id: &str) {} + fn deselect_song(&self, _id: &str) {} + fn enable_selection(&self) -> bool { + false + } + + fn selection(&self) -> Option + '_>> { + None + } + + fn is_selection_enabled(&self) -> bool { + self.selection() + .map(|s| s.is_selection_enabled()) + .unwrap_or(false) + } + + fn song_state(&self, id: &str) -> SongState { + let is_playing = self.current_song_id().map(|s| s.eq(id)).unwrap_or(false); + let is_selected = self + .selection() + .map(|s| s.is_song_selected(id)) + .unwrap_or(false); + SongState { + is_selected, + is_playing, + } + } + + fn toggle_select(&self, id: &str) { + if let Some(selection) = self.selection() { + if selection.is_song_selected(id) { + self.deselect_song(id); + } else { + self.select_song(id); + } + } + } +} + +pub struct Playlist { + animator: AnimatorDefault, + listview: gtk::ListView, + model: Rc, +} + +impl Playlist +where + Model: PlaylistModel + 'static, +{ + pub fn new(listview: gtk::ListView, model: Rc, worker: Worker) -> Self { + let list_model = model.song_list_model(); + let selection_model = gtk::NoSelection::new(Some(list_model.clone())); + let factory = gtk::SignalListItemFactory::new(); + + listview.add_css_class("playlist"); + listview.set_show_separators(true); + listview.set_valign(gtk::Align::Start); + + listview.set_factory(Some(&factory)); + listview.set_single_click_activate(true); + listview.set_model(Some(&selection_model)); + Self::set_paused(&listview, model.is_paused()); + Self::set_selection_active(&listview, model.is_selection_enabled()); + + factory.connect_setup(|_, item| { + let item = item.downcast_ref::().unwrap(); + item.set_child(Some(&SongWidget::new())); + }); + + factory.connect_bind(clone!(@weak model => move |_, item| { + let item = item.downcast_ref::().unwrap(); + let song_model = item.item().unwrap().downcast::().unwrap(); + song_model.set_state(model.song_state(&song_model.get_id())); + + let widget = item.child().unwrap().downcast::().unwrap(); + widget.bind(&song_model, worker.clone(), model.show_song_covers()); + + let id = &song_model.get_id(); + widget.set_actions(model.actions_for(id).as_ref()); + widget.set_menu(model.menu_for(id).as_ref()); + })); + + factory.connect_unbind(|_, item| { + let item = item.downcast_ref::().unwrap(); + let song_model = item.item().unwrap().downcast::().unwrap(); + song_model.unbind_all(); + }); + + listview.connect_activate(clone!(@weak list_model, @weak model => move |_, position| { + let song = list_model.index_continuous(position as usize).expect("attempt to access invalid index"); + let song = song.description(); + let selection_enabled = model.is_selection_enabled(); + if selection_enabled { + model.toggle_select(&song.id); + } else { + model.play_song_at(position as usize, &song.id); + } + })); + + let press_gesture = gtk::GestureLongPress::new(); + press_gesture.set_touch_only(false); + press_gesture.set_propagation_phase(gtk::PropagationPhase::Capture); + press_gesture.connect_pressed(clone!(@weak model => move |_, _, _| { + model.enable_selection(); + })); + listview.add_controller(press_gesture); + + Self { + animator: AnimatorDefault::ease_in_out_animator(), + listview, + model, + } + } + + fn autoscroll_to_playing(&self, index: usize) { + let len = self.model.song_list_model().partial_len() as f64; + let scrolled_window: Option = ancestor(&self.listview); + let adj = scrolled_window.map(|w| w.vadjustment()); + if let Some(adj) = adj { + let v = adj.value(); + let v2 = v + 0.9 * adj.page_size(); + let pos = (index as f64) * adj.upper() / len; + debug!("estimated pos: {}", pos); + debug!("current window: {} -- {}", v, v2); + if pos < v || pos > v2 { + self.animator.animate( + 20, + clone!(@weak adj => @default-return false, move |p| { + let v = adj.value(); + adj.set_value(v + p * (pos - v)); + true + }), + ); + } + } + } + + fn update_list(&self) { + let autoscroll_to_playing = self.model.autoscroll_to_playing(); + let is_selection_enabled = self.model.is_selection_enabled(); + + self.model.song_list_model().for_each(|i, model_song| { + let state = self.model.song_state(&model_song.get_id()); + model_song.set_state(state); + if state.is_playing && autoscroll_to_playing && !is_selection_enabled { + self.autoscroll_to_playing(i); + } + }); + } + + fn set_selection_active(listview: >k::ListView, active: bool) { + let class_name = "playlist--selectable"; + if active { + listview.add_css_class(class_name); + } else { + listview.remove_css_class(class_name); + } + } + + fn set_paused(listview: >k::ListView, paused: bool) { + let class_name = "playlist--paused"; + if paused { + listview.add_css_class(class_name); + } else { + listview.remove_css_class(class_name); + } + } +} + +impl SongModel { + fn set_state( + &self, + SongState { + is_playing, + is_selected, + }: SongState, + ) { + self.set_playing(is_playing); + self.set_selected(is_selected); + } +} + +impl EventListener for Playlist +where + Model: PlaylistModel + 'static, +{ + fn on_event(&mut self, event: &AppEvent) { + match event { + AppEvent::SelectionEvent(SelectionEvent::SelectionChanged) => { + self.update_list(); + } + AppEvent::PlaybackEvent(PlaybackEvent::TrackChanged(_)) => { + self.update_list(); + } + AppEvent::PlaybackEvent( + PlaybackEvent::PlaybackResumed | PlaybackEvent::PlaybackPaused, + ) => { + Self::set_paused(&self.listview, self.model.is_paused()); + } + AppEvent::SelectionEvent(SelectionEvent::SelectionModeChanged(_)) => { + Self::set_selection_active(&self.listview, self.model.is_selection_enabled()); + self.update_list(); + } + _ => {} + } + } +} + +impl Component for Playlist { + fn get_root_widget(&self) -> >k::Widget { + self.listview.upcast_ref() + } +} diff --git a/src/app/components/playlist/song.blp b/src/app/components/playlist/song.blp new file mode 100644 index 0000000..a065080 --- /dev/null +++ b/src/app/components/playlist/song.blp @@ -0,0 +1,143 @@ +using Gtk 4.0; + +template $SongWidget : Grid { + margin-start: 6; + margin-end: 6; + margin-top: 6; + margin-bottom: 6; + column-spacing: 6; + row-spacing: 0; + + Overlay { + layout { + row-span: "2"; + column: "0"; + row: "0"; + } + + Label song_index { + label: "1"; + sensitive: false; + halign: center; + + styles [ + "song__index", + "numeric", + ] + } + + [overlay] + Image song_cover { + pixel-size: 30; + overflow: hidden; + halign: center; + valign: center; + + styles [ + "song__cover", + ] + } + + [overlay] + Spinner song_icon { + halign: center; + valign: center; + + styles [ + "song__icon", + ] + } + + [overlay] + CheckButton song_checkbox { + halign: center; + valign: center; + + styles [ + "song__checkbox", + ] + } + } + + Label song_title { + label: "Title"; + ellipsize: middle; + max-width-chars: 50; + xalign: 0; + yalign: 1; + hexpand: true; + + layout { + column-span: "2"; + column: "1"; + row: "0"; + } + + styles [ + "title", + ] + } + + Label song_artist { + label: "Artist"; + ellipsize: middle; + max-width-chars: 35; + xalign: 0; + hexpand: true; + + layout { + column-span: "1"; + column: "1"; + row: "1"; + } + + styles [ + "subtitle", + ] + } + + Label song_length { + sensitive: false; + label: "0∶00"; + justify: right; + max-width-chars: 7; + xalign: 1; + hexpand: false; + + layout { + row-span: "2"; + column: "3"; + row: "0"; + } + + styles [ + "numeric", + ] + } + + MenuButton menu_btn { + focus-on-click: false; + receives-default: true; + icon-name: "view-more-symbolic"; + has-frame: false; + hexpand: false; + halign: end; + valign: center; + tooltip-text: "Menu"; + + layout { + row-span: "2"; + column: "4"; + row: "0"; + } + + styles [ + "circular", + "flat", + ] + } + + styles [ + "song", + ] +} diff --git a/src/app/components/playlist/song.css b/src/app/components/playlist/song.css new file mode 100644 index 0000000..639cf74 --- /dev/null +++ b/src/app/components/playlist/song.css @@ -0,0 +1,186 @@ +.playlist .song__index { + transition: opacity 150ms ease; + margin: 6px 12px; + padding: 0; + opacity: 1; + min-width: 1.5em; +} + +.song__cover { + border-radius: 6px; + border: 1px solid @card_shade_color; +} + +.album__tracks .song__cover { + opacity: 0; +} + + +/* playback indicator */ + +.song--playing .song__icon { + opacity: 1; + animation: playing 1s linear infinite; + color: @accent_bg_color; + min-width: 16px; + -gtk-icon-source: -gtk-icontheme("playback-0-symbolic"); +} + +@keyframes playing { + 0% { + -gtk-icon-source: -gtk-icontheme("playback-0-symbolic"); + } + + 6% { + -gtk-icon-source: -gtk-icontheme("playback-1-symbolic"); + } + + 12% { + -gtk-icon-source: -gtk-icontheme("playback-2-symbolic"); + } + + 18% { + -gtk-icon-source: -gtk-icontheme("playback-3-symbolic"); + } + + 24% { + -gtk-icon-source: -gtk-icontheme("playback-4-symbolic"); + } + + 30% { + -gtk-icon-source: -gtk-icontheme("playback-5-symbolic"); + } + + 36% { + -gtk-icon-source: -gtk-icontheme("playback-6-symbolic"); + } + + 42% { + -gtk-icon-source: -gtk-icontheme("playback-7-symbolic"); + } + + 49% { + -gtk-icon-source: -gtk-icontheme("playback-8-symbolic"); + } + + 54% { + -gtk-icon-source: -gtk-icontheme("playback-9-symbolic"); + } + + 60% { + -gtk-icon-source: -gtk-icontheme("playback-10-symbolic"); + } + + 66% { + -gtk-icon-source: -gtk-icontheme("playback-11-symbolic"); + } + + 72% { + -gtk-icon-source: -gtk-icontheme("playback-12-symbolic"); + } + + 79% { + -gtk-icon-source: -gtk-icontheme("playback-13-symbolic"); + } + + 85% { + -gtk-icon-source: -gtk-icontheme("playback-14-symbolic"); + } + + 90% { + -gtk-icon-source: -gtk-icontheme("playback-15-symbolic"); + } + + 96% { + -gtk-icon-source: -gtk-icontheme("playback-16-symbolic"); + } + + 100% { + -gtk-icon-source: -gtk-icontheme("playback-0-symbolic"); + } +} + +.playlist--paused .song--playing .song__icon { + animation: none; + -gtk-icon-source: -gtk-icontheme("playback-paused-symbolic"); +} + +.song__icon, +.song__checkbox, +.song--playing .song__index, +.song--playing .song__cover, +.playlist--selectable .song__index, +.playlist--selectable .song__cover, +.playlist--selectable .song__icon { + transition: opacity 150ms ease; + opacity: 0; +} + + +.playlist--selectable .song__checkbox, +.playlist--selectable .song__checkbox check { + opacity: 1; + filter: none; +} + + +row:hover .song__menu--enabled, .song__menu--enabled:checked { + opacity: 1; +} + + +/* Song Labels */ +.song--playing label.title { + font-weight: bold; +} + +/* "Context Menu" */ +.song__menu { + opacity: 0; +} + +.song__menu--enabled { + opacity: 0.2; +} + + +/* Song boxed list styling */ + +.playlist { + background: transparent; +} + +.playlist row { + background: @card_bg_color; + margin-left: 12px; + margin-right: 12px; + box-shadow: 1px 0px 3px rgba(0, 0, 0, 0.07), -1px 0px 3px rgba(0, 0, 0, 0.07); + transition: background-color 150ms ease; +} + +.playlist row:hover { + background-image: image(alpha(currentColor, 0.03)); +} + +.playlist row:active { + background-image: image(alpha(currentColor, 0.08)); +} + + +.playlist row:first-child { + margin-top: 12px; + border-radius: 12px 12px 0 0; +} + +.playlist row:last-child { + margin-bottom: 12px; + border-bottom-color: rgba(0, 0, 0, 0); + border-radius: 0 0 12px 12px; + box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.21); +} + +.playlist row:only-child { + margin-top: 12px; + margin-bottom: 12px; + border-radius: 12px 12px 12px 12px; +} \ No newline at end of file diff --git a/src/app/components/playlist/song.rs b/src/app/components/playlist/song.rs new file mode 100644 index 0000000..79b40b3 --- /dev/null +++ b/src/app/components/playlist/song.rs @@ -0,0 +1,189 @@ +use crate::app::components::display_add_css_provider; +use crate::app::loader::ImageLoader; +use crate::app::models::SongModel; +use crate::app::Worker; +use gio::MenuModel; +use glib::subclass::InitializingObject; + +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; + +mod imp { + + use super::*; + + const SONG_CLASS: &str = "song--playing"; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/song.ui")] + pub struct SongWidget { + #[template_child] + pub song_index: TemplateChild, + + #[template_child] + pub song_icon: TemplateChild, + + #[template_child] + pub song_checkbox: TemplateChild, + + #[template_child] + pub song_title: TemplateChild, + + #[template_child] + pub song_artist: TemplateChild, + + #[template_child] + pub song_length: TemplateChild, + + #[template_child] + pub menu_btn: TemplateChild, + + #[template_child] + pub song_cover: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for SongWidget { + const NAME: &'static str = "SongWidget"; + type Type = super::SongWidget; + type ParentType = gtk::Grid; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + lazy_static! { + static ref PROPERTIES: [glib::ParamSpec; 2] = [ + glib::ParamSpecBoolean::builder("playing").build(), + glib::ParamSpecBoolean::builder("selected").build() + ]; + } + + impl ObjectImpl for SongWidget { + fn properties() -> &'static [glib::ParamSpec] { + &*PROPERTIES + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + match pspec.name() { + "playing" => { + let is_playing = value + .get() + .expect("type conformity checked by `Object::set_property`"); + if is_playing { + self.obj().add_css_class(SONG_CLASS); + } else { + self.obj().remove_css_class(SONG_CLASS); + } + } + "selected" => { + let is_selected = value + .get() + .expect("type conformity checked by `Object::set_property`"); + self.song_checkbox.set_active(is_selected); + } + _ => unimplemented!(), + } + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "playing" => self.obj().has_css_class(SONG_CLASS).to_value(), + "selected" => self.song_checkbox.is_active().to_value(), + _ => unimplemented!(), + } + } + + fn constructed(&self) { + self.parent_constructed(); + self.song_checkbox.set_sensitive(false); + } + + fn dispose(&self) { + while let Some(child) = self.obj().first_child() { + child.unparent(); + } + } + } + + impl WidgetImpl for SongWidget {} + impl GridImpl for SongWidget {} +} + +glib::wrapper! { + pub struct SongWidget(ObjectSubclass) @extends gtk::Widget, gtk::Grid; +} + +impl Default for SongWidget { + fn default() -> Self { + Self::new() + } +} + +impl SongWidget { + pub fn new() -> Self { + display_add_css_provider(resource!("/components/song.css")); + glib::Object::new() + } + + pub fn set_actions(&self, actions: Option<&gio::ActionGroup>) { + self.insert_action_group("song", actions); + } + + pub fn set_menu(&self, menu: Option<&MenuModel>) { + if menu.is_some() { + let widget = self.imp(); + widget.menu_btn.set_menu_model(menu); + widget.menu_btn.add_css_class("song__menu--enabled"); + } + } + + fn set_show_cover(&self, show_cover: bool) { + let song_class = "song--cover"; + if show_cover { + self.add_css_class(song_class); + } else { + self.remove_css_class(song_class); + } + } + + fn set_image(&self, pixbuf: Option<&gdk_pixbuf::Pixbuf>) { + self.imp().song_cover.set_from_pixbuf(pixbuf); + } + + pub fn set_art(&self, model: &SongModel, worker: Worker) { + if let Some(url) = model.description().art.clone() { + let _self = self.downgrade(); + worker.send_local_task(async move { + if let Some(_self) = _self.upgrade() { + let loader = ImageLoader::new(); + let result = loader.load_remote(&url, "jpg", 100, 100).await; + _self.set_image(result.as_ref()); + } + }); + } + } + + pub fn bind(&self, model: &SongModel, worker: Worker, show_cover: bool) { + let widget = self.imp(); + + model.bind_title(&*widget.song_title, "label"); + model.bind_artist(&*widget.song_artist, "label"); + model.bind_duration(&*widget.song_length, "label"); + model.bind_playing(self, "playing"); + model.bind_selected(self, "selected"); + + self.set_show_cover(show_cover); + if show_cover { + self.set_art(model, worker); + } else { + model.bind_index(&*widget.song_index, "label"); + } + } +} diff --git a/src/app/components/playlist/song_actions.rs b/src/app/components/playlist/song_actions.rs new file mode 100644 index 0000000..8a35ac7 --- /dev/null +++ b/src/app/components/playlist/song_actions.rs @@ -0,0 +1,82 @@ +use gdk::prelude::*; +use gio::SimpleAction; + +use crate::app::models::SongDescription; +use crate::app::state::{AppAction, PlaybackAction}; +use crate::app::ActionDispatcher; + +impl SongDescription { + pub fn make_queue_action( + &self, + dispatcher: Box, + name: Option<&str>, + ) -> SimpleAction { + let queue = SimpleAction::new(name.unwrap_or("queue"), None); + let song = self.clone(); + queue.connect_activate(move |_, _| { + dispatcher.dispatch(PlaybackAction::Queue(vec![song.clone()]).into()); + }); + queue + } + + pub fn make_dequeue_action( + &self, + dispatcher: Box, + name: Option<&str>, + ) -> SimpleAction { + let dequeue = SimpleAction::new(name.unwrap_or("dequeue"), None); + let track_id = self.id.clone(); + dequeue.connect_activate(move |_, _| { + dispatcher.dispatch(PlaybackAction::Dequeue(track_id.clone()).into()); + }); + dequeue + } + + pub fn make_link_action(&self, name: Option<&str>) -> SimpleAction { + let track_id = self.id.clone(); + let copy_link = SimpleAction::new(name.unwrap_or("copy_link"), None); + copy_link.connect_activate(move |_, _| { + let link = format!("https://open.spotify.com/track/{track_id}"); + let clipboard = gdk::Display::default().unwrap().clipboard(); + clipboard + .set_content(Some(&gdk::ContentProvider::for_value(&link.to_value()))) + .expect("Failed to set clipboard content"); + }); + copy_link + } + + pub fn make_album_action( + &self, + dispatcher: Box, + name: Option<&str>, + ) -> SimpleAction { + let album_id = self.album.id.clone(); + let view_album = SimpleAction::new(name.unwrap_or("view_album"), None); + view_album.connect_activate(move |_, _| { + dispatcher.dispatch(AppAction::ViewAlbum(album_id.clone())); + }); + view_album + } + + pub fn make_artist_actions( + &self, + dispatcher: Box, + prefix: Option<&str>, + ) -> Vec { + self.artists + .iter() + .map(|artist| { + let id = artist.id.clone(); + let view_artist = SimpleAction::new( + &format!("{}_{}", prefix.unwrap_or("view_artist"), &id), + None, + ); + let dispatcher = dispatcher.box_clone(); + view_artist.connect_activate(move |_, _| { + dispatcher.dispatch(AppAction::ViewArtist(id.clone())); + }); + view_artist + }) + .collect() + } +} diff --git a/src/app/components/playlist_details/mod.rs b/src/app/components/playlist_details/mod.rs new file mode 100644 index 0000000..1bb90ba --- /dev/null +++ b/src/app/components/playlist_details/mod.rs @@ -0,0 +1,14 @@ +#[allow(clippy::module_inception)] +mod playlist_details; +mod playlist_details_model; +mod playlist_header; +mod playlist_headerbar; + +pub use playlist_details::*; +pub use playlist_details_model::*; + +use glib::StaticType; + +pub fn expose_widgets() { + playlist_headerbar::PlaylistHeaderBarWidget::static_type(); +} diff --git a/src/app/components/playlist_details/playlist_details.blp b/src/app/components/playlist_details/playlist_details.blp new file mode 100644 index 0000000..d30cb3f --- /dev/null +++ b/src/app/components/playlist_details/playlist_details.blp @@ -0,0 +1,52 @@ +using Gtk 4.0; +using Adw 1; + +template $PlaylistDetailsWidget : Adw.Bin { + Box { + orientation: vertical; + vexpand: true; + hexpand: true; + + $PlaylistHeaderBarWidget headerbar { + } + + $ScrollingHeaderWidget scrolling_header { + [header] + WindowHandle { + Adw.Clamp { + maximum-size: 900; + + Adw.Squeezer { + valign: center; + homogeneous: false; + transition-type: crossfade; + switch-threshold-policy: natural; + + $PlaylistHeaderWidget header_widget { + } + + $PlaylistHeaderWidget header_mobile { + orientation: "vertical"; + spacing: "12"; + } + } + + styles [ + "playlist_details__clamp", + ] + } + } + + Adw.ClampScrollable { + maximum-size: 900; + + ListView tracks { + } + } + + styles [ + "container", + ] + } + } +} diff --git a/src/app/components/playlist_details/playlist_details.rs b/src/app/components/playlist_details/playlist_details.rs new file mode 100644 index 0000000..0678986 --- /dev/null +++ b/src/app/components/playlist_details/playlist_details.rs @@ -0,0 +1,326 @@ +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; +use std::rc::Rc; + +use super::playlist_header::PlaylistHeaderWidget; +use super::playlist_headerbar::PlaylistHeaderBarWidget; +use super::PlaylistDetailsModel; + +use crate::app::components::{ + Component, EventListener, Playlist, PlaylistModel, ScrollingHeaderWidget, +}; +use crate::app::dispatch::Worker; +use crate::app::loader::ImageLoader; +use crate::app::state::{PlaybackEvent, SelectionEvent}; +use crate::app::{AppEvent, BrowserEvent}; +use libadwaita::subclass::prelude::BinImpl; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/playlist_details.ui")] + pub struct PlaylistDetailsWidget { + #[template_child] + pub headerbar: TemplateChild, + + #[template_child] + pub scrolling_header: TemplateChild, + + #[template_child] + pub header_widget: TemplateChild, + + #[template_child] + pub header_mobile: TemplateChild, + + #[template_child] + pub tracks: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for PlaylistDetailsWidget { + const NAME: &'static str = "PlaylistDetailsWidget"; + type Type = super::PlaylistDetailsWidget; + type ParentType = libadwaita::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for PlaylistDetailsWidget { + fn constructed(&self) { + self.parent_constructed(); + self.header_mobile.set_centered(); + self.header_widget.set_grows_automatically(); + self.header_widget + .entry() + .bind_property("text", self.header_mobile.entry(), "text") + .flags(glib::BindingFlags::BIDIRECTIONAL) + .build(); + } + } + + impl WidgetImpl for PlaylistDetailsWidget {} + impl BinImpl for PlaylistDetailsWidget {} +} + +glib::wrapper! { + pub struct PlaylistDetailsWidget(ObjectSubclass) @extends gtk::Widget, libadwaita::Bin; +} + +impl PlaylistDetailsWidget { + fn new() -> Self { + glib::Object::new() + } + + fn playlist_tracks_widget(&self) -> >k::ListView { + self.imp().tracks.as_ref() + } + + fn connect_bottom_edge(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().scrolling_header.connect_bottom_edge(f); + } + + fn set_header_visible(&self, visible: bool) { + let widget = self.imp(); + widget.headerbar.set_title_visible(true); + if visible { + widget.headerbar.add_classes(&["flat"]); + } else { + widget.headerbar.remove_classes(&["flat"]); + } + } + + fn connect_header(&self) { + self.set_header_visible(false); + self.imp().scrolling_header.connect_header_visibility( + clone!(@weak self as _self => move |visible| { + _self.set_header_visible(visible); + }), + ); + } + + fn set_loaded(&self) { + self.imp() + .scrolling_header + .add_css_class("container--loaded"); + } + + fn set_editing(&self, editing: bool) { + self.imp().header_widget.set_editing(editing); + self.imp().header_mobile.set_editing(editing); + self.imp().headerbar.set_editing(editing); + } + + fn set_editable(&self, editing: bool) { + self.imp().headerbar.set_editable(editing); + } + + fn set_info(&self, playlist: &str, owner: &str) { + self.imp().header_widget.set_info(playlist, owner); + self.imp().header_mobile.set_info(playlist, owner); + self.imp().headerbar.set_title(Some(playlist)); + } + + fn set_playing(&self, is_playing: bool) { + self.imp().header_widget.set_playing(is_playing); + self.imp().header_mobile.set_playing(is_playing); + } + + fn set_artwork(&self, art: &gdk_pixbuf::Pixbuf) { + self.imp().header_widget.set_artwork(art); + self.imp().header_mobile.set_artwork(art); + } + + fn connect_owner_clicked(&self, f: F) + where + F: Fn() + Clone + 'static, + { + self.imp().header_widget.connect_owner_clicked(f.clone()); + self.imp().header_mobile.connect_owner_clicked(f); + } + + pub fn connect_edit(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().headerbar.connect_edit(f); + } + + pub fn connect_cancel(&self, f: F) + where + F: Fn() + 'static, + { + self.imp() + .headerbar + .connect_cancel(clone!(@weak self as _self => move || { + _self.imp().header_widget.reset_playlist_name(); + _self.imp().header_mobile.reset_playlist_name(); + f(); + })); + } + + pub fn connect_play(&self, f: F) + where + F: Fn() + Clone + 'static, + { + self.imp().header_widget.connect_play(f.clone()); + self.imp().header_mobile.connect_play(f); + } + + pub fn connect_done(&self, f: F) + where + F: Fn(String) + 'static, + { + self.imp() + .headerbar + .connect_ok(clone!(@weak self as _self => move || { + let s = _self.imp().header_widget.get_edited_playlist_name(); + f(s); + })); + } + + pub fn connect_go_back(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().headerbar.connect_go_back(f); + } +} + +pub struct PlaylistDetails { + model: Rc, + worker: Worker, + widget: PlaylistDetailsWidget, + children: Vec>, +} + +impl PlaylistDetails { + pub fn new(model: Rc, worker: Worker) -> Self { + if model.get_playlist_info().is_none() { + model.load_playlist_info(); + } + + let widget = PlaylistDetailsWidget::new(); + let playlist = Box::new(Playlist::new( + widget.playlist_tracks_widget().clone(), + model.clone(), + worker.clone(), + )); + + widget.set_editable(model.is_playlist_editable()); + + widget.connect_header(); + + widget.connect_bottom_edge(clone!(@weak model => move || { + model.load_more_tracks(); + })); + + widget.connect_owner_clicked(clone!(@weak model => move || model.view_owner())); + + widget.connect_edit(clone!(@weak model => move || { + model.enable_selection(); + })); + + widget.connect_cancel(clone!(@weak model => move || model.disable_selection())); + widget.connect_done(clone!(@weak model => move |n| { + model.disable_selection(); + model.update_playlist_details(n); + })); + + widget.connect_play(clone!(@weak model => move || model.toggle_play_playlist())); + + widget.connect_go_back(clone!(@weak model => move || model.go_back())); + + Self { + model, + worker, + widget, + children: vec![playlist], + } + } + + fn update_details(&self) { + if let Some(info) = self.model.get_playlist_info() { + let title = &info.title[..]; + let owner = &info.owner.display_name[..]; + let art_url = info.art.as_ref(); + + self.widget.set_info(title, owner); + + if let Some(art_url) = art_url.cloned() { + let widget = self.widget.downgrade(); + self.worker.send_local_task(async move { + let pixbuf = ImageLoader::new() + .load_remote(&art_url[..], "jpg", 320, 320) + .await; + if let (Some(widget), Some(ref pixbuf)) = (widget.upgrade(), pixbuf) { + widget.set_artwork(pixbuf); + widget.set_loaded(); + } + }); + } else { + self.widget.set_loaded(); + } + } + } + + fn update_playing(&self, is_playing: bool) { + if !self.model.playlist_is_playing() || !self.model.is_playing() { + self.widget.set_playing(false); + return; + } + self.widget.set_playing(is_playing); + } + + fn set_editing(&self, editable: bool) { + if !self.model.is_playlist_editable() { + return; + } + self.widget.set_editing(editable); + } +} + +impl Component for PlaylistDetails { + fn get_root_widget(&self) -> >k::Widget { + self.widget.upcast_ref() + } + + fn get_children(&mut self) -> Option<&mut Vec>> { + Some(&mut self.children) + } +} + +impl EventListener for PlaylistDetails { + fn on_event(&mut self, event: &AppEvent) { + match event { + AppEvent::BrowserEvent(BrowserEvent::PlaylistDetailsLoaded(id)) + if id == &self.model.id => + { + self.update_details(); + self.update_playing(true); + } + AppEvent::SelectionEvent(SelectionEvent::SelectionModeChanged(editing)) => { + self.set_editing(*editing); + } + AppEvent::PlaybackEvent(PlaybackEvent::PlaybackPaused) => { + self.update_playing(false); + } + AppEvent::PlaybackEvent(PlaybackEvent::PlaybackResumed) => { + self.update_playing(true); + } + _ => {} + } + self.broadcast_event(event); + } +} diff --git a/src/app/components/playlist_details/playlist_details_model.rs b/src/app/components/playlist_details/playlist_details_model.rs new file mode 100644 index 0000000..231ced9 --- /dev/null +++ b/src/app/components/playlist_details/playlist_details_model.rs @@ -0,0 +1,242 @@ +use gio::prelude::*; +use gio::SimpleActionGroup; +use std::cell::Ref; +use std::ops::Deref; +use std::rc::Rc; + +use crate::api::SpotifyApiError; +use crate::app::components::{labels, PlaylistModel}; +use crate::app::models::*; +use crate::app::state::SelectionContext; +use crate::app::state::{BrowserAction, PlaybackAction, SelectionAction, SelectionState}; +use crate::app::AppState; +use crate::app::{ActionDispatcher, AppAction, AppModel, BatchQuery, SongsSource}; + +pub struct PlaylistDetailsModel { + pub id: String, + app_model: Rc, + dispatcher: Box, +} + +impl PlaylistDetailsModel { + pub fn new(id: String, app_model: Rc, dispatcher: Box) -> Self { + Self { + id, + app_model, + dispatcher, + } + } + + pub fn state(&self) -> Ref<'_, AppState> { + self.app_model.get_state() + } + + pub fn is_playlist_editable(&self) -> bool { + let state = self.app_model.get_state(); + state.logged_user.playlists.iter().any(|p| p.id == self.id) + } + + pub fn get_playlist_info(&self) -> Option + '_> { + self.app_model.map_state_opt(|s| { + s.browser + .playlist_details_state(&self.id)? + .playlist + .as_ref() + }) + } + + pub fn is_playing(&self) -> bool { + self.state().playback.is_playing() + } + + pub fn playlist_is_playing(&self) -> bool { + matches!( + self.app_model.get_state().playback.current_source(), + Some(SongsSource::Playlist(ref id)) if id == &self.id) + } + + pub fn toggle_play_playlist(&self) { + if let Some(playlist) = self.get_playlist_info() { + if !self.playlist_is_playing() { + if self.state().playback.is_shuffled() { + self.dispatcher + .dispatch(AppAction::PlaybackAction(PlaybackAction::ToggleShuffle)); + } + let id_of_first_song = playlist.songs.songs[0].id.as_str(); + self.play_song_at(0, id_of_first_song); + return; + } + if self.state().playback.is_playing() { + self.dispatcher + .dispatch(AppAction::PlaybackAction(PlaybackAction::Pause)); + } else { + self.dispatcher + .dispatch(AppAction::PlaybackAction(PlaybackAction::Play)); + } + } + } + + pub fn load_playlist_info(&self) { + let api = self.app_model.get_spotify(); + let id = self.id.clone(); + self.dispatcher + .call_spotify_and_dispatch(move || async move { + let playlist = api.get_playlist(&id).await; + match playlist { + Ok(playlist) => { + Ok(BrowserAction::SetPlaylistDetails(Box::new(playlist)).into()) + } + Err(SpotifyApiError::BadStatus(400, _)) + | Err(SpotifyApiError::BadStatus(404, _)) => { + Ok(BrowserAction::NavigationPop.into()) + } + Err(e) => Err(e), + } + }); + } + + pub fn load_more_tracks(&self) -> Option<()> { + let last_batch = self.song_list_model().last_batch()?; + let query = BatchQuery { + source: SongsSource::Playlist(self.id.clone()), + batch: last_batch, + }; + + let id = self.id.clone(); + let next_query = query.next()?; + debug!("next_query = {:?}", &next_query); + let loader = self.app_model.get_batch_loader(); + + self.dispatcher.dispatch_async(Box::pin(async move { + loader + .query(next_query, |_s, song_batch| { + BrowserAction::AppendPlaylistTracks(id, Box::new(song_batch)).into() + }) + .await + })); + + Some(()) + } + + pub fn update_playlist_details(&self, title: String) { + let api = self.app_model.get_spotify(); + let id = self.id.clone(); + self.dispatcher + .call_spotify_and_dispatch(move || async move { + let playlist = api.update_playlist_details(&id, title.clone()).await; + match playlist { + Ok(_) => Ok(AppAction::UpdatePlaylistName(PlaylistSummary { id, title })), + Err(e) => Err(e), + } + }); + } + + pub fn view_owner(&self) { + if let Some(playlist) = self.get_playlist_info() { + let owner = &playlist.owner.id; + self.dispatcher + .dispatch(AppAction::ViewUser(owner.to_owned())); + } + } + + pub fn disable_selection(&self) { + self.dispatcher.dispatch(AppAction::CancelSelection); + } + + pub fn go_back(&self) { + self.dispatcher + .dispatch(BrowserAction::NavigationPop.into()); + } +} + +impl PlaylistModel for PlaylistDetailsModel { + fn song_list_model(&self) -> SongListModel { + self.state() + .browser + .playlist_details_state(&self.id) + .expect("illegal attempt to read playlist_details_state") + .songs + .clone() + } + + fn is_paused(&self) -> bool { + !self.state().playback.is_playing() + } + + fn current_song_id(&self) -> Option { + self.state().playback.current_song_id() + } + + fn play_song_at(&self, pos: usize, id: &str) { + let source = SongsSource::Playlist(self.id.clone()); + let batch = self.song_list_model().song_batch_for(pos); + if let Some(batch) = batch { + self.dispatcher + .dispatch(PlaybackAction::LoadPagedSongs(source, batch).into()); + self.dispatcher + .dispatch(PlaybackAction::Load(id.to_string()).into()); + } + } + + fn actions_for(&self, id: &str) -> Option { + let song = self.song_list_model().get(id)?; + let song = song.description(); + + let group = SimpleActionGroup::new(); + + for view_artist in song.make_artist_actions(self.dispatcher.box_clone(), None) { + group.add_action(&view_artist); + } + group.add_action(&song.make_album_action(self.dispatcher.box_clone(), None)); + group.add_action(&song.make_link_action(None)); + group.add_action(&song.make_queue_action(self.dispatcher.box_clone(), None)); + + Some(group.upcast()) + } + + fn menu_for(&self, id: &str) -> Option { + let song = self.song_list_model().get(id)?; + let song = song.description(); + + let menu = gio::Menu::new(); + menu.append(Some(&*labels::VIEW_ALBUM), Some("song.view_album")); + for artist in song.artists.iter() { + menu.append( + Some(&labels::more_from_label(&artist.name)), + Some(&format!("song.view_artist_{}", artist.id)), + ); + } + + menu.append(Some(&*labels::COPY_LINK), Some("song.copy_link")); + menu.append(Some(&*labels::ADD_TO_QUEUE), Some("song.queue")); + + Some(menu.upcast()) + } + + fn select_song(&self, id: &str) { + let song = self.song_list_model().get(id); + if let Some(song) = song { + self.dispatcher + .dispatch(SelectionAction::Select(vec![song.into_description()]).into()); + } + } + + fn deselect_song(&self, id: &str) { + self.dispatcher + .dispatch(SelectionAction::Deselect(vec![id.to_string()]).into()); + } + + fn enable_selection(&self) -> bool { + self.dispatcher + .dispatch(AppAction::EnableSelection(if self.is_playlist_editable() { + SelectionContext::EditablePlaylist(self.id.clone()) + } else { + SelectionContext::Playlist + })); + true + } + + fn selection(&self) -> Option + '_>> { + Some(Box::new(self.app_model.map_state(|s| &s.selection))) + } +} diff --git a/src/app/components/playlist_details/playlist_header.blp b/src/app/components/playlist_details/playlist_header.blp new file mode 100644 index 0000000..4c89453 --- /dev/null +++ b/src/app/components/playlist_details/playlist_header.blp @@ -0,0 +1,84 @@ +using Gtk 4.0; + +template $PlaylistHeaderWidget : Box { + valign: start; + vexpand: false; + margin-start: 6; + margin-end: 6; + margin-bottom: 6; + + Box playlist_image_box { + overflow: hidden; + halign: center; + margin-top: 18; + margin-start: 6; + margin-bottom: 6; + + Image playlist_art { + width-request: 160; + height-request: 160; + icon-name: "emblem-music-symbolic"; + } + + styles [ + "card", + ] + } + + Box playlist_info { + hexpand: true; + valign: center; + orientation: vertical; + spacing: 6; + margin-start: 18; + + Entry playlist_label_entry { + hexpand: false; + halign: start; + editable: false; + can-focus: false; + placeholder-text: "Playlist Title"; + + styles [ + "title-1", + "playlist__title-entry", + "playlist__title-entry--ro", + ] + } + + LinkButton author_button { + receives-default: true; + halign: start; + valign: center; + has-frame: false; + + Label author_button_label { + hexpand: true; + vexpand: true; + label: "Artist"; + ellipsize: middle; + } + + styles [ + "title-4", + ] + } + } + Button play_button { + margin-end: 6; + receives-default: true; + halign: center; + valign: center; + tooltip-text: "Play"; + icon-name: "media-playback-start-symbolic"; + + styles [ + "circular", + "play__button", + ] + } + + styles [ + "playlist__header", + ] +} diff --git a/src/app/components/playlist_details/playlist_header.css b/src/app/components/playlist_details/playlist_header.css new file mode 100644 index 0000000..ecd533f --- /dev/null +++ b/src/app/components/playlist_details/playlist_header.css @@ -0,0 +1,32 @@ +.playlist__header .title-4 label { + color: @window_fg_color; + font-weight: bold; + text-decoration: none; +} + +.playlist__header .title-4:hover { + border-radius: 6px; + background-image: image(alpha(currentColor, 0.08)); +} + +clamp.playlist_details__clamp { + background-color: @view_bg_color; + box-shadow: inset 0px -1px 0px @borders; +} + +headerbar.playlist_details__headerbar { + transition: background-color .3s ease; +} + +headerbar.flat.playlist_details__headerbar windowtitle { + opacity: 0; +} + +headerbar.playlist_details__headerbar windowtitle { + transition: opacity .3s ease; + opacity: 1; +} + +.playlist_details__headerbar.flat { + background-color: @view_bg_color; +} diff --git a/src/app/components/playlist_details/playlist_header.rs b/src/app/components/playlist_details/playlist_header.rs new file mode 100644 index 0000000..4db7e79 --- /dev/null +++ b/src/app/components/playlist_details/playlist_header.rs @@ -0,0 +1,190 @@ +use crate::app::components::display_add_css_provider; +use gettextrs::gettext; +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::{glib, CompositeTemplate}; + +const CSS_RO_ENTRY: &str = "playlist__title-entry--ro"; + +mod imp { + + use glib::{ParamSpec, Properties}; + use std::cell::RefCell; + + use super::*; + + #[derive(Debug, Default, CompositeTemplate, Properties)] + #[template(resource = "/dev/alextren/Spot/components/playlist_header.ui")] + #[properties(wrapper_type = super::PlaylistHeaderWidget)] + pub struct PlaylistHeaderWidget { + #[template_child] + pub playlist_label_entry: TemplateChild, + + #[template_child] + pub playlist_image_box: TemplateChild, + + #[template_child] + pub playlist_art: TemplateChild, + + #[template_child] + pub playlist_info: TemplateChild, + + #[template_child] + pub author_button: TemplateChild, + + #[template_child] + pub author_button_label: TemplateChild, + + #[template_child] + pub play_button: TemplateChild, + + #[property(get, set, name = "original-entry-text")] + pub original_entry_text: RefCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for PlaylistHeaderWidget { + const NAME: &'static str = "PlaylistHeaderWidget"; + type Type = super::PlaylistHeaderWidget; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + display_add_css_provider(resource!("/components/playlist_header.css")); + obj.init_template(); + } + } + + impl ObjectImpl for PlaylistHeaderWidget { + fn properties() -> &'static [ParamSpec] { + Self::derived_properties() + } + + fn set_property(&self, id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + self.derived_set_property(id, value, pspec); + } + + fn property(&self, id: usize, pspec: &glib::ParamSpec) -> glib::Value { + self.derived_property(id, pspec) + } + + fn constructed(&self) { + self.parent_constructed(); + } + } + + impl WidgetImpl for PlaylistHeaderWidget {} + impl BoxImpl for PlaylistHeaderWidget {} +} + +glib::wrapper! { + pub struct PlaylistHeaderWidget(ObjectSubclass) @extends gtk::Widget, gtk::Box; +} + +impl Default for PlaylistHeaderWidget { + fn default() -> Self { + Self::new() + } +} + +impl PlaylistHeaderWidget { + pub fn new() -> Self { + glib::Object::new() + } + + pub fn connect_owner_clicked(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().author_button.connect_activate_link(move |_| { + f(); + glib::signal::Inhibit(true) + }); + } + + pub fn connect_play(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().play_button.connect_clicked(move |_| f()); + } + + pub fn reset_playlist_name(&self) { + self.imp() + .playlist_label_entry + .set_text(&self.original_entry_text()); + } + + pub fn get_edited_playlist_name(&self) -> String { + self.imp().playlist_label_entry.text().to_string() + } + + pub fn set_artwork(&self, art: &gdk_pixbuf::Pixbuf) { + self.imp().playlist_art.set_from_pixbuf(Some(art)); + } + + pub fn set_info(&self, playlist: &str, owner: &str) { + let widget = self.imp(); + self.set_original_entry_text(playlist); + widget.playlist_label_entry.set_text(playlist); + widget + .playlist_label_entry + .set_placeholder_text(Some(playlist)); + widget.author_button_label.set_label(owner); + } + + pub fn set_playing(&self, is_playing: bool) { + let playback_icon = if is_playing { + "media-playback-pause-symbolic" + } else { + "media-playback-start-symbolic" + }; + + let translated_tooltip = if is_playing { + gettext("Pause") + } else { + gettext("Play") + }; + let tooltip_text = Some(translated_tooltip.as_str()); + + self.imp().play_button.set_icon_name(playback_icon); + self.imp().play_button.set_tooltip_text(tooltip_text); + } + + pub fn set_centered(&self) { + let widget = self.imp(); + widget.playlist_info.set_halign(gtk::Align::Center); + widget.play_button.set_margin_end(0); + widget.playlist_info.set_margin_start(0); + widget.playlist_image_box.set_margin_start(0); + widget.playlist_label_entry.set_xalign(0.5); + widget.author_button.set_halign(gtk::Align::Center); + } + + pub fn set_editing(&self, editing: bool) { + let widget = self.imp(); + widget.playlist_label_entry.set_can_focus(editing); + widget.playlist_label_entry.set_editable(editing); + if editing { + widget.playlist_label_entry.remove_css_class(CSS_RO_ENTRY); + } else { + widget.playlist_label_entry.add_css_class(CSS_RO_ENTRY); + } + } + + pub fn entry(&self) -> >k::Entry { + self.imp().playlist_label_entry.as_ref() + } + + pub fn set_grows_automatically(&self) { + let entry: >k::Entry = &self.imp().playlist_label_entry; + entry + .bind_property("text", entry, "width-chars") + .transform_to(|_, text: &str| Some(text.len() as i32)) + .flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE) + .build(); + } +} diff --git a/src/app/components/playlist_details/playlist_headerbar.blp b/src/app/components/playlist_details/playlist_headerbar.blp new file mode 100644 index 0000000..9a94068 --- /dev/null +++ b/src/app/components/playlist_details/playlist_headerbar.blp @@ -0,0 +1,77 @@ +using Gtk 4.0; +using Adw 1; + +template $PlaylistHeaderBarWidget : Adw.Bin { + [root] + Overlay overlay { + hexpand: true; + + Adw.HeaderBar main_header { + show-end-title-buttons: true; + + Button go_back { + receives-default: true; + halign: start; + valign: center; + icon-name: "go-previous-symbolic"; + has-frame: false; + } + + [title] + Adw.WindowTitle title { + visible: false; + title: "Spot"; + } + + [end] + Button edit { + icon-name: "document-edit-symbolic"; + } + + styles [ + "playlist_details__headerbar", + ] + } + + [overlay] + Adw.HeaderBar edition_header { + show-end-title-buttons: false; + show-start-title-buttons: false; + visible: false; + + styles [ + "selection-mode", + ] + + Button cancel { + receives-default: true; + halign: start; + valign: center; + + /* Translators: Exit playlist edition */ + + label: _("Cancel"); + } + + [title] + Separator { + styles [ + "spacer", + ] + } + + [end] + Button ok { + valign: center; + + /* Translators: Finish playlist edition */ + + label: _("Done"); + + styles [ + "suggested-action", + ] + } + } + } +} diff --git a/src/app/components/playlist_details/playlist_headerbar.rs b/src/app/components/playlist_details/playlist_headerbar.rs new file mode 100644 index 0000000..9e6fd49 --- /dev/null +++ b/src/app/components/playlist_details/playlist_headerbar.rs @@ -0,0 +1,164 @@ +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; +use libadwaita::subclass::prelude::BinImpl; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/playlist_headerbar.ui")] + pub struct PlaylistHeaderBarWidget { + #[template_child] + pub main_header: TemplateChild, + + #[template_child] + pub edition_header: TemplateChild, + + #[template_child] + pub go_back: TemplateChild, + + #[template_child] + pub title: TemplateChild, + + #[template_child] + pub edit: TemplateChild, + + #[template_child] + pub ok: TemplateChild, + + #[template_child] + pub cancel: TemplateChild, + + #[template_child] + pub overlay: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for PlaylistHeaderBarWidget { + const NAME: &'static str = "PlaylistHeaderBarWidget"; + type Type = super::PlaylistHeaderBarWidget; + type ParentType = libadwaita::Bin; + type Interfaces = (gtk::Buildable,); + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for PlaylistHeaderBarWidget {} + + impl BuildableImpl for PlaylistHeaderBarWidget { + fn add_child(&self, builder: >k::Builder, child: &glib::Object, type_: Option<&str>) { + if Some("root") == type_ { + self.parent_add_child(builder, child, type_); + } else { + self.main_header + .set_title_widget(child.downcast_ref::()); + } + } + } + + impl WidgetImpl for PlaylistHeaderBarWidget {} + impl BinImpl for PlaylistHeaderBarWidget {} + impl WindowImpl for PlaylistHeaderBarWidget {} +} + +glib::wrapper! { + pub struct PlaylistHeaderBarWidget(ObjectSubclass) @extends gtk::Widget, libadwaita::Bin; +} + +impl Default for PlaylistHeaderBarWidget { + fn default() -> Self { + Self::new() + } +} + +impl PlaylistHeaderBarWidget { + pub fn new() -> Self { + glib::Object::new() + } + + pub fn connect_edit(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().edit.connect_clicked(move |_| f()); + } + + pub fn connect_ok(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().ok.connect_clicked(move |_| f()); + } + + pub fn connect_cancel(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().cancel.connect_clicked(move |_| f()); + } + + pub fn connect_go_back(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().go_back.connect_clicked(move |_| f()); + } + + pub fn bind_to_leaflet(&self, leaflet: &libadwaita::Leaflet) { + leaflet + .bind_property( + "folded", + &*self.imp().main_header, + "show-start-title-buttons", + ) + .build(); + leaflet.notify("folded"); + } + + pub fn set_can_go_back(&self, can_go_back: bool) { + self.imp().go_back.set_visible(can_go_back); + } + + pub fn set_editable(&self, editable: bool) { + self.imp().edit.set_visible(editable); + } + + pub fn set_editing(&self, editing: bool) { + if editing { + self.imp().edition_header.set_visible(true); + } else { + self.imp().edition_header.set_visible(false); + } + } + + pub fn add_classes(&self, classes: &[&str]) { + for &class in classes { + self.add_css_class(class); + } + } + + pub fn remove_classes(&self, classes: &[&str]) { + for &class in classes { + self.remove_css_class(class); + } + } + + pub fn set_title_visible(&self, visible: bool) { + self.imp().title.set_visible(visible); + } + + pub fn set_title(&self, title: Option<&str>) { + self.imp().title.set_visible(title.is_some()); + if let Some(title) = title { + self.imp().title.set_title(title); + } + } +} diff --git a/src/app/components/saved_playlists/mod.rs b/src/app/components/saved_playlists/mod.rs new file mode 100644 index 0000000..771e4e1 --- /dev/null +++ b/src/app/components/saved_playlists/mod.rs @@ -0,0 +1,6 @@ +#[allow(clippy::module_inception)] +mod saved_playlists; +mod saved_playlists_model; + +pub use saved_playlists::*; +pub use saved_playlists_model::*; diff --git a/src/app/components/saved_playlists/saved_playlists.blp b/src/app/components/saved_playlists/saved_playlists.blp new file mode 100644 index 0000000..8f6ba12 --- /dev/null +++ b/src/app/components/saved_playlists/saved_playlists.blp @@ -0,0 +1,36 @@ +using Gtk 4.0; +using Adw 1; + +template $SavedPlaylistsWidget : Box { + ScrolledWindow scrolled_window { + hexpand: true; + vexpand: true; + vscrollbar-policy: always; + min-content-width: 250; + + Overlay overlay { + FlowBox flowbox { + margin-start: 8; + margin-end: 8; + margin-top: 8; + margin-bottom: 8; + min-children-per-line: 1; + selection-mode: none; + activate-on-single-click: false; + } + + [overlay] + Adw.StatusPage status_page { + /* Translators: A title that is shown when the user has not saved any playlists. */ + + title: _("You have no saved playlists."); + + /* Translators: A description of what happens when the user has saved playlists. */ + + description: _("Your playlists will be shown here."); + icon-name: "emblem-music-symbolic"; + visible: true; + } + } + } +} diff --git a/src/app/components/saved_playlists/saved_playlists.rs b/src/app/components/saved_playlists/saved_playlists.rs new file mode 100644 index 0000000..fd4aa58 --- /dev/null +++ b/src/app/components/saved_playlists/saved_playlists.rs @@ -0,0 +1,160 @@ +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; +use std::rc::Rc; + +use super::SavedPlaylistsModel; +use crate::app::components::{AlbumWidget, Component, EventListener}; +use crate::app::dispatch::Worker; +use crate::app::models::AlbumModel; +use crate::app::state::LoginEvent; +use crate::app::{AppEvent, BrowserEvent, ListStore}; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/saved_playlists.ui")] + pub struct SavedPlaylistsWidget { + #[template_child] + pub scrolled_window: TemplateChild, + + #[template_child] + pub flowbox: TemplateChild, + #[template_child] + pub status_page: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for SavedPlaylistsWidget { + const NAME: &'static str = "SavedPlaylistsWidget"; + type Type = super::SavedPlaylistsWidget; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for SavedPlaylistsWidget {} + impl WidgetImpl for SavedPlaylistsWidget {} + impl BoxImpl for SavedPlaylistsWidget {} +} + +glib::wrapper! { + pub struct SavedPlaylistsWidget(ObjectSubclass) @extends gtk::Widget, gtk::Box; +} + +impl Default for SavedPlaylistsWidget { + fn default() -> Self { + Self::new() + } +} + +impl SavedPlaylistsWidget { + pub fn new() -> Self { + glib::Object::new() + } + + fn connect_bottom_edge(&self, f: F) + where + F: Fn() + 'static, + { + self.imp() + .scrolled_window + .connect_edge_reached(move |_, pos| { + if let gtk::PositionType::Bottom = pos { + f() + } + }); + } + + fn bind_albums(&self, worker: Worker, store: &ListStore, on_album_pressed: F) + where + F: Fn(String) + Clone + 'static, + { + self.imp() + .flowbox + .bind_model(Some(store.unsafe_store()), move |item| { + let album_model = item.downcast_ref::().unwrap(); + let child = gtk::FlowBoxChild::new(); + let album = AlbumWidget::for_model(album_model, worker.clone()); + + let f = on_album_pressed.clone(); + album.connect_album_pressed(clone!(@weak album_model => move |_| { + f(album_model.uri()); + })); + + child.set_child(Some(&album)); + child.upcast::() + }); + } + pub fn get_status_page(&self) -> &libadwaita::StatusPage { + &self.imp().status_page + } +} + +pub struct SavedPlaylists { + widget: SavedPlaylistsWidget, + worker: Worker, + model: Rc, +} + +impl SavedPlaylists { + pub fn new(worker: Worker, model: SavedPlaylistsModel) -> Self { + let model = Rc::new(model); + + let widget = SavedPlaylistsWidget::new(); + + widget.connect_bottom_edge(clone!(@weak model => move || { + model.load_more_playlists(); + })); + + Self { + widget, + worker, + model, + } + } + + fn bind_flowbox(&self) { + self.widget.bind_albums( + self.worker.clone(), + &self.model.get_list_store().unwrap(), + clone!(@weak self.model as model => move |id| { + model.open_playlist(id); + }), + ); + } +} + +impl EventListener for SavedPlaylists { + fn on_event(&mut self, event: &AppEvent) { + match event { + AppEvent::Started => { + let _ = self.model.refresh_saved_playlists(); + self.bind_flowbox(); + } + AppEvent::LoginEvent(LoginEvent::LoginCompleted(_)) => { + let _ = self.model.refresh_saved_playlists(); + } + AppEvent::BrowserEvent(BrowserEvent::SavedPlaylistsUpdated) => { + self.widget + .get_status_page() + .set_visible(!self.model.has_playlists()); + } + _ => {} + } + } +} + +impl Component for SavedPlaylists { + fn get_root_widget(&self) -> >k::Widget { + self.widget.as_ref() + } +} diff --git a/src/app/components/saved_playlists/saved_playlists_model.rs b/src/app/components/saved_playlists/saved_playlists_model.rs new file mode 100644 index 0000000..062aadf --- /dev/null +++ b/src/app/components/saved_playlists/saved_playlists_model.rs @@ -0,0 +1,70 @@ +use std::cell::Ref; +use std::ops::Deref; +use std::rc::Rc; + +use crate::app::models::*; +use crate::app::state::HomeState; +use crate::app::{ActionDispatcher, AppAction, AppModel, BrowserAction, ListStore}; + +pub struct SavedPlaylistsModel { + app_model: Rc, + dispatcher: Box, +} + +impl SavedPlaylistsModel { + pub fn new(app_model: Rc, dispatcher: Box) -> Self { + Self { + app_model, + dispatcher, + } + } + + fn state(&self) -> Option> { + self.app_model.map_state_opt(|s| s.browser.home_state()) + } + + pub fn get_list_store(&self) -> Option> + '_> { + Some(Ref::map(self.state()?, |s| &s.playlists)) + } + + pub fn refresh_saved_playlists(&self) -> Option<()> { + let api = self.app_model.get_spotify(); + let batch_size = self.state()?.next_playlists_page.batch_size; + + self.dispatcher + .call_spotify_and_dispatch(move || async move { + api.get_saved_playlists(0, batch_size) + .await + .map(|playlists| BrowserAction::SetPlaylistsContent(playlists).into()) + }); + + Some(()) + } + + pub fn has_playlists(&self) -> bool { + self.get_list_store() + .map(|list| list.len() > 0) + .unwrap_or(false) + } + + pub fn load_more_playlists(&self) -> Option<()> { + let api = self.app_model.get_spotify(); + + let next_page = &self.state()?.next_playlists_page; + let batch_size = next_page.batch_size; + let offset = next_page.next_offset?; + + self.dispatcher + .call_spotify_and_dispatch(move || async move { + api.get_saved_playlists(offset, batch_size) + .await + .map(|playlists| BrowserAction::AppendPlaylistsContent(playlists).into()) + }); + + Some(()) + } + + pub fn open_playlist(&self, id: String) { + self.dispatcher.dispatch(AppAction::ViewPlaylist(id)); + } +} diff --git a/src/app/components/saved_tracks/mod.rs b/src/app/components/saved_tracks/mod.rs new file mode 100644 index 0000000..7fb2813 --- /dev/null +++ b/src/app/components/saved_tracks/mod.rs @@ -0,0 +1,6 @@ +#[allow(clippy::module_inception)] +mod saved_tracks; +pub use saved_tracks::*; + +mod saved_tracks_model; +pub use saved_tracks_model::*; diff --git a/src/app/components/saved_tracks/saved_tracks.blp b/src/app/components/saved_tracks/saved_tracks.blp new file mode 100644 index 0000000..c597279 --- /dev/null +++ b/src/app/components/saved_tracks/saved_tracks.blp @@ -0,0 +1,15 @@ +using Gtk 4.0; +using Adw 1; + +template $SavedTracksWidget : Adw.Bin { + ScrolledWindow scrolled_window { + vexpand: true; + + Adw.ClampScrollable { + maximum-size: 900; + + ListView song_list { + } + } + } +} diff --git a/src/app/components/saved_tracks/saved_tracks.rs b/src/app/components/saved_tracks/saved_tracks.rs new file mode 100644 index 0000000..715ac28 --- /dev/null +++ b/src/app/components/saved_tracks/saved_tracks.rs @@ -0,0 +1,117 @@ +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; +use std::rc::Rc; + +use super::SavedTracksModel; +use crate::app::components::{Component, EventListener, Playlist}; +use crate::app::state::LoginEvent; +use crate::app::{AppEvent, Worker}; +use libadwaita::subclass::prelude::BinImpl; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/saved_tracks.ui")] + pub struct SavedTracksWidget { + #[template_child] + pub song_list: TemplateChild, + + #[template_child] + pub scrolled_window: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for SavedTracksWidget { + const NAME: &'static str = "SavedTracksWidget"; + type Type = super::SavedTracksWidget; + type ParentType = libadwaita::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for SavedTracksWidget {} + impl WidgetImpl for SavedTracksWidget {} + impl BinImpl for SavedTracksWidget {} +} + +glib::wrapper! { + pub struct SavedTracksWidget(ObjectSubclass) @extends gtk::Widget, libadwaita::Bin; +} + +impl SavedTracksWidget { + fn new() -> Self { + glib::Object::new() + } + + fn connect_bottom_edge(&self, f: F) + where + F: Fn() + 'static, + { + self.imp() + .scrolled_window + .connect_edge_reached(move |_, pos| { + if let gtk::PositionType::Bottom = pos { + f() + } + }); + } + + fn song_list_widget(&self) -> >k::ListView { + self.imp().song_list.as_ref() + } +} + +pub struct SavedTracks { + widget: SavedTracksWidget, + model: Rc, + children: Vec>, +} + +impl SavedTracks { + pub fn new(model: Rc, worker: Worker) -> Self { + let widget = SavedTracksWidget::new(); + + widget.connect_bottom_edge(clone!(@weak model => move || { + model.load_more(); + })); + + let playlist = Playlist::new(widget.song_list_widget().clone(), model.clone(), worker); + + Self { + widget, + model, + children: vec![Box::new(playlist)], + } + } +} + +impl Component for SavedTracks { + fn get_root_widget(&self) -> >k::Widget { + self.widget.upcast_ref() + } + + fn get_children(&mut self) -> Option<&mut Vec>> { + Some(&mut self.children) + } +} + +impl EventListener for SavedTracks { + fn on_event(&mut self, event: &AppEvent) { + match event { + AppEvent::Started | AppEvent::LoginEvent(LoginEvent::LoginCompleted(_)) => { + self.model.load_initial(); + } + _ => {} + } + self.broadcast_event(event); + } +} diff --git a/src/app/components/saved_tracks/saved_tracks_model.rs b/src/app/components/saved_tracks/saved_tracks_model.rs new file mode 100644 index 0000000..7d0b0a2 --- /dev/null +++ b/src/app/components/saved_tracks/saved_tracks_model.rs @@ -0,0 +1,147 @@ +use gio::prelude::*; +use gio::SimpleActionGroup; +use std::ops::Deref; +use std::rc::Rc; + +use crate::app::components::{labels, PlaylistModel}; +use crate::app::models::*; +use crate::app::state::SelectionContext; +use crate::app::state::{PlaybackAction, SelectionAction, SelectionState}; +use crate::app::{ActionDispatcher, AppAction, AppModel, BatchQuery, BrowserAction, SongsSource}; + +pub struct SavedTracksModel { + app_model: Rc, + dispatcher: Box, +} + +impl SavedTracksModel { + pub fn new(app_model: Rc, dispatcher: Box) -> Self { + Self { + app_model, + dispatcher, + } + } + + pub fn load_initial(&self) { + let loader = self.app_model.get_batch_loader(); + let query = BatchQuery { + source: SongsSource::SavedTracks, + batch: Batch::first_of_size(50), + }; + self.dispatcher.dispatch_async(Box::pin(async move { + loader + .query(query, |_s, song_batch| { + BrowserAction::SetSavedTracks(Box::new(song_batch)).into() + }) + .await + })); + } + + pub fn load_more(&self) -> Option<()> { + let loader = self.app_model.get_batch_loader(); + let last_batch = self.song_list_model().last_batch()?.next()?; + let query = BatchQuery { + source: SongsSource::SavedTracks, + batch: last_batch, + }; + self.dispatcher.dispatch_async(Box::pin(async move { + loader + .query(query, |_s, song_batch| { + BrowserAction::AppendSavedTracks(Box::new(song_batch)).into() + }) + .await + })); + Some(()) + } +} + +impl PlaylistModel for SavedTracksModel { + fn song_list_model(&self) -> SongListModel { + self.app_model + .get_state() + .browser + .home_state() + .expect("illegal attempt to read home_state") + .saved_tracks + .clone() + } + + fn is_paused(&self) -> bool { + !self.app_model.get_state().playback.is_playing() + } + + fn current_song_id(&self) -> Option { + self.app_model.get_state().playback.current_song_id() + } + + fn play_song_at(&self, pos: usize, id: &str) { + let source = SongsSource::SavedTracks; + let batch = self.song_list_model().song_batch_for(pos); + if let Some(batch) = batch { + self.dispatcher + .dispatch(PlaybackAction::LoadPagedSongs(source, batch).into()); + self.dispatcher + .dispatch(PlaybackAction::Load(id.to_string()).into()); + } + } + fn autoscroll_to_playing(&self) -> bool { + true + } + + fn actions_for(&self, id: &str) -> Option { + let song = self.song_list_model().get(id)?; + let song = song.description(); + + let group = SimpleActionGroup::new(); + + for view_artist in song.make_artist_actions(self.dispatcher.box_clone(), None) { + group.add_action(&view_artist); + } + group.add_action(&song.make_album_action(self.dispatcher.box_clone(), None)); + group.add_action(&song.make_link_action(None)); + + Some(group.upcast()) + } + + fn menu_for(&self, id: &str) -> Option { + let song = self.song_list_model().get(id)?; + let song = song.description(); + + let menu = gio::Menu::new(); + menu.append(Some(&*labels::VIEW_ALBUM), Some("song.view_album")); + for artist in song.artists.iter() { + menu.append( + Some(&labels::more_from_label(&artist.name)), + Some(&format!("song.view_artist_{}", artist.id)), + ); + } + + menu.append(Some(&*labels::COPY_LINK), Some("song.copy_link")); + + Some(menu.upcast()) + } + + fn select_song(&self, id: &str) { + let song = self.song_list_model().get(id); + if let Some(song) = song { + self.dispatcher + .dispatch(SelectionAction::Select(vec![song.description().clone()]).into()); + } + } + + fn deselect_song(&self, id: &str) { + self.dispatcher + .dispatch(SelectionAction::Deselect(vec![id.to_string()]).into()); + } + + fn enable_selection(&self) -> bool { + self.dispatcher + .dispatch(AppAction::EnableSelection(SelectionContext::SavedTracks)); + true + } + + fn selection(&self) -> Option + '_>> { + let selection = self.app_model.map_state(|s| &s.selection); + Some(Box::new(selection)) + } +} diff --git a/src/app/components/scrolling_header/mod.rs b/src/app/components/scrolling_header/mod.rs new file mode 100644 index 0000000..1f8c390 --- /dev/null +++ b/src/app/components/scrolling_header/mod.rs @@ -0,0 +1,7 @@ +mod scrolling_header_widget; +use glib::StaticType; +pub use scrolling_header_widget::*; + +pub fn expose_widgets() { + scrolling_header_widget::ScrollingHeaderWidget::static_type(); +} diff --git a/src/app/components/scrolling_header/scrolling_header.blp b/src/app/components/scrolling_header/scrolling_header.blp new file mode 100644 index 0000000..05cfb88 --- /dev/null +++ b/src/app/components/scrolling_header/scrolling_header.blp @@ -0,0 +1,20 @@ +using Gtk 4.0; + +template $ScrollingHeaderWidget : Box { + orientation: vertical; + vexpand: true; + hexpand: true; + + [internal] + Revealer revealer { + transition-type: slide_up; + } + + [internal] + ScrolledWindow scrolled_window { + hscrollbar-policy: never; + propagate-natural-width: true; + hexpand: true; + vexpand: true; + } +} diff --git a/src/app/components/scrolling_header/scrolling_header_widget.rs b/src/app/components/scrolling_header/scrolling_header_widget.rs new file mode 100644 index 0000000..85be005 --- /dev/null +++ b/src/app/components/scrolling_header/scrolling_header_widget.rs @@ -0,0 +1,117 @@ +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/scrolling_header.ui")] + pub struct ScrollingHeaderWidget { + #[template_child] + pub scrolled_window: TemplateChild, + + #[template_child] + pub revealer: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for ScrollingHeaderWidget { + const NAME: &'static str = "ScrollingHeaderWidget"; + type Type = super::ScrollingHeaderWidget; + type ParentType = gtk::Box; + type Interfaces = (gtk::Buildable,); + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for ScrollingHeaderWidget { + fn constructed(&self) { + self.parent_constructed(); + } + } + + impl BuildableImpl for ScrollingHeaderWidget { + fn add_child(&self, builder: >k::Builder, child: &glib::Object, type_: Option<&str>) { + let child_widget = child.downcast_ref::(); + match type_ { + Some("internal") => self.parent_add_child(builder, child, type_), + Some("header") => self.revealer.set_child(child_widget), + _ => self.scrolled_window.set_child(child_widget), + } + } + } + + impl WidgetImpl for ScrollingHeaderWidget {} + impl BoxImpl for ScrollingHeaderWidget {} +} + +glib::wrapper! { + pub struct ScrollingHeaderWidget(ObjectSubclass) @extends gtk::Widget, gtk::Box; +} + +impl ScrollingHeaderWidget { + fn set_header_visible(&self, visible: bool) -> bool { + let widget = self.imp(); + let is_up_to_date = widget.revealer.reveals_child() == visible; + if !is_up_to_date { + widget.revealer.set_reveal_child(visible); + } + is_up_to_date + } + + fn is_scrolled_to_top(&self) -> bool { + self.imp().scrolled_window.vadjustment().value() <= f64::EPSILON + || self.imp().revealer.reveals_child() + } + + pub fn connect_header_visibility(&self, f: F) + where + F: Fn(bool) + Clone + 'static, + { + self.set_header_visible(true); + f(true); + + let scroll_controller = + gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::VERTICAL); + scroll_controller.connect_scroll( + clone!(@strong f, @weak self as _self => @default-return gtk::Inhibit(false), move |_, _, dy| { + let visible = dy < 0f64 && _self.is_scrolled_to_top(); + f(visible); + gtk::Inhibit(!_self.set_header_visible(visible)) + }), + ); + + let swipe_controller = gtk::GestureSwipe::new(); + swipe_controller.set_touch_only(true); + swipe_controller.set_propagation_phase(gtk::PropagationPhase::Capture); + swipe_controller.connect_swipe(clone!(@weak self as _self => move |_, _, dy| { + let visible = dy >= 0f64 && _self.is_scrolled_to_top(); + f(visible); + _self.set_header_visible(visible); + })); + + self.imp().scrolled_window.add_controller(scroll_controller); + self.add_controller(swipe_controller); + } + + pub fn connect_bottom_edge(&self, f: F) + where + F: Fn() + 'static, + { + self.imp() + .scrolled_window + .connect_edge_reached(move |_, pos| { + if let gtk::PositionType::Bottom = pos { + f() + } + }); + } +} diff --git a/src/app/components/search/mod.rs b/src/app/components/search/mod.rs new file mode 100644 index 0000000..4db3b9a --- /dev/null +++ b/src/app/components/search/mod.rs @@ -0,0 +1,9 @@ +#[allow(clippy::module_inception)] +mod search; +pub use search::*; + +mod search_model; +pub use search_model::*; + +mod search_button; +pub use search_button::*; diff --git a/src/app/components/search/search.blp b/src/app/components/search/search.blp new file mode 100644 index 0000000..9135431 --- /dev/null +++ b/src/app/components/search/search.blp @@ -0,0 +1,119 @@ +using Gtk 4.0; +using Adw 1; + +template $SearchResultsWidget : Box { + orientation: vertical; + can-focus: true; + + Adw.HeaderBar main_header { + show-end-title-buttons: true; + + Button go_back { + halign: start; + valign: center; + icon-name: "go-previous-symbolic"; + has-frame: false; + } + + [title] + SearchEntry search_entry { + receives-default: true; + can-focus: true; + } + } + + Overlay overlay { + hexpand: true; + vexpand: true; + + ScrolledWindow search_results { + visible: false; + hexpand: true; + vexpand: true; + hscrollbar-policy: never; + Box { + vexpand: false; + margin-start: 8; + margin-end: 8; + margin-top: 8; + margin-bottom: 8; + orientation: vertical; + spacing: 8; + + Expander { + margin-start: 4; + margin-end: 4; + expanded: true; + vexpand: false; + valign: start; + + ScrolledWindow { + vscrollbar-policy: never; + propagate-natural-height: false; + FlowBox albums_results { + halign: start; + hexpand: true; + vexpand: false; + valign: start; + orientation: vertical; + max-children-per-line: 1; + selection-mode: none; + activate-on-single-click: false; + } + } + + [label] + Label { + /* Translators: This is the title of a section of the search results */ + + label: _("Albums"); + } + } + + Expander { + margin-start: 4; + margin-end: 4; + margin-bottom: 4; + expanded: true; + vexpand: false; + valign: start; + + ScrolledWindow { + vscrollbar-policy: never; + propagate-natural-height: false; + FlowBox artist_results { + halign: start; + hexpand: true; + vexpand: false; + valign: start; + orientation: vertical; + max-children-per-line: 1; + selection-mode: none; + activate-on-single-click: false; + } + } + + [label] + Label { + /* Translators: This is the title of a section of the search results */ + + label: _("Artists"); + } + } + } + } + + [overlay] + Adw.StatusPage status_page { + /* Translators: Title for the empty search page (initial state). */ + + title: _("Search Spotify."); + + /* Translators: Subtitle for the empty search page (initial state). */ + + description: _("Type to search."); + icon-name: "system-search-symbolic"; + visible: true; + } + } +} diff --git a/src/app/components/search/search.rs b/src/app/components/search/search.rs new file mode 100644 index 0000000..990e4c5 --- /dev/null +++ b/src/app/components/search/search.rs @@ -0,0 +1,257 @@ +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; +use std::rc::Rc; + +use crate::app::components::utils::{wrap_flowbox_item, Debouncer}; +use crate::app::components::{AlbumWidget, ArtistWidget, Component, EventListener}; +use crate::app::dispatch::Worker; +use crate::app::models::{AlbumModel, ArtistModel}; +use crate::app::state::{AppEvent, BrowserEvent}; + +use super::SearchResultsModel; +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/search.ui")] + pub struct SearchResultsWidget { + #[template_child] + pub main_header: TemplateChild, + + #[template_child] + pub go_back: TemplateChild, + + #[template_child] + pub search_entry: TemplateChild, + + #[template_child] + pub status_page: TemplateChild, + + #[template_child] + pub search_results: TemplateChild, + + #[template_child] + pub albums_results: TemplateChild, + + #[template_child] + pub artist_results: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for SearchResultsWidget { + const NAME: &'static str = "SearchResultsWidget"; + type Type = super::SearchResultsWidget; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for SearchResultsWidget {} + impl BoxImpl for SearchResultsWidget {} + + impl WidgetImpl for SearchResultsWidget { + fn grab_focus(&self) -> bool { + self.search_entry.grab_focus() + } + } +} + +glib::wrapper! { + pub struct SearchResultsWidget(ObjectSubclass) @extends gtk::Widget, gtk::Box; +} + +impl Default for SearchResultsWidget { + fn default() -> Self { + Self::new() + } +} + +impl SearchResultsWidget { + pub fn new() -> Self { + glib::Object::new() + } + + pub fn bind_to_leaflet(&self, leaflet: &libadwaita::Leaflet) { + leaflet + .bind_property( + "folded", + &*self.imp().main_header, + "show-start-title-buttons", + ) + .build(); + leaflet.notify("folded"); + } + + pub fn connect_go_back(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().go_back.connect_clicked(move |_| f()); + } + + pub fn connect_search_updated(&self, f: F) + where + F: Fn(String) + 'static, + { + self.imp() + .search_entry + .connect_changed(clone!(@weak self as _self => move |s| { + let query = s.text(); + let query = query.as_str(); + _self.imp().status_page.set_visible(query.is_empty()); + _self.imp().search_results.set_visible(!query.is_empty()); + if !query.is_empty() { + f(query.to_string()); + } + })); + } + + fn bind_albums_results(&self, worker: Worker, store: &gio::ListStore, on_album_pressed: F) + where + F: Fn(String) + Clone + 'static, + { + self.imp() + .albums_results + .bind_model(Some(store), move |item| { + wrap_flowbox_item(item, |album_model| { + let f = on_album_pressed.clone(); + let album = AlbumWidget::for_model(album_model, worker.clone()); + album.connect_album_pressed(clone!(@weak album_model => move |_| { + f(album_model.uri()); + })); + album + }) + }); + } + + fn bind_artists_results(&self, worker: Worker, store: &gio::ListStore, on_artist_pressed: F) + where + F: Fn(String) + Clone + 'static, + { + self.imp() + .artist_results + .bind_model(Some(store), move |item| { + wrap_flowbox_item(item, |artist_model| { + let f = on_artist_pressed.clone(); + let artist = ArtistWidget::for_model(artist_model, worker.clone()); + artist.connect_artist_pressed(clone!(@weak artist_model => move |_| { + f(artist_model.id()); + })); + artist + }) + }); + } +} + +pub struct SearchResults { + widget: SearchResultsWidget, + model: Rc, + album_results_model: gio::ListStore, + artist_results_model: gio::ListStore, + debouncer: Debouncer, +} + +impl SearchResults { + pub fn new(model: SearchResultsModel, worker: Worker, leaflet: &libadwaita::Leaflet) -> Self { + let model = Rc::new(model); + let widget = SearchResultsWidget::new(); + + let album_results_model = gio::ListStore::new(AlbumModel::static_type()); + let artist_results_model = gio::ListStore::new(ArtistModel::static_type()); + + widget.bind_to_leaflet(leaflet); + + widget.connect_go_back(clone!(@weak model => move || { + model.go_back(); + })); + + widget.connect_search_updated(clone!(@weak model => move |q| { + model.search(q); + })); + + widget.bind_albums_results( + worker.clone(), + &album_results_model, + clone!(@weak model => move |uri| { + model.open_album(uri); + }), + ); + + widget.bind_artists_results( + worker, + &artist_results_model, + clone!(@weak model => move |id| { + model.open_artist(id); + }), + ); + + Self { + widget, + model, + album_results_model, + artist_results_model, + debouncer: Debouncer::new(), + } + } + + fn update_results(&self) { + if let Some(results) = self.model.get_album_results() { + self.album_results_model.remove_all(); + for album in results.iter() { + self.album_results_model.append(&AlbumModel::new( + &album.artists_name(), + &album.title, + album.year(), + album.art.as_ref(), + &album.id, + )); + } + } + if let Some(results) = self.model.get_artist_results() { + self.artist_results_model.remove_all(); + for artist in results.iter() { + self.artist_results_model.append(&ArtistModel::new( + &artist.name, + &artist.photo, + &artist.id, + )); + } + } + } + + fn update_search_query(&self) { + self.debouncer.debounce( + 600, + clone!(@weak self.model as model => move || model.fetch_results()), + ); + } +} + +impl Component for SearchResults { + fn get_root_widget(&self) -> >k::Widget { + self.widget.as_ref() + } +} + +impl EventListener for SearchResults { + fn on_event(&mut self, app_event: &AppEvent) { + match app_event { + AppEvent::BrowserEvent(BrowserEvent::SearchUpdated) => { + self.get_root_widget().grab_focus(); + self.update_search_query(); + } + AppEvent::BrowserEvent(BrowserEvent::SearchResultsUpdated) => { + self.update_results(); + } + _ => {} + } + } +} diff --git a/src/app/components/search/search_button.rs b/src/app/components/search/search_button.rs new file mode 100644 index 0000000..c320f7a --- /dev/null +++ b/src/app/components/search/search_button.rs @@ -0,0 +1,26 @@ +use gtk::prelude::*; + +use crate::app::components::EventListener; +use crate::app::{ActionDispatcher, AppAction}; + +pub struct SearchBarModel(pub Box); + +impl SearchBarModel { + pub fn navigate_to_search(&self) { + self.0.dispatch(AppAction::ViewSearch()); + } +} + +pub struct SearchButton; + +impl SearchButton { + pub fn new(model: SearchBarModel, search_button: gtk::Button) -> Self { + search_button.connect_clicked(move |_| { + model.navigate_to_search(); + }); + + Self + } +} + +impl EventListener for SearchButton {} diff --git a/src/app/components/search/search_model.rs b/src/app/components/search/search_model.rs new file mode 100644 index 0000000..f6289c6 --- /dev/null +++ b/src/app/components/search/search_model.rs @@ -0,0 +1,66 @@ +use std::ops::Deref; +use std::rc::Rc; + +use crate::app::dispatch::ActionDispatcher; +use crate::app::models::*; +use crate::app::state::{AppAction, AppModel, BrowserAction}; + +pub struct SearchResultsModel { + app_model: Rc, + dispatcher: Box, +} + +impl SearchResultsModel { + pub fn new(app_model: Rc, dispatcher: Box) -> Self { + Self { + app_model, + dispatcher, + } + } + + pub fn go_back(&self) { + self.dispatcher + .dispatch(BrowserAction::NavigationPop.into()); + } + + pub fn search(&self, query: String) { + self.dispatcher + .dispatch(BrowserAction::Search(query).into()); + } + + fn get_query(&self) -> Option + '_> { + self.app_model + .map_state_opt(|s| Some(&s.browser.search_state()?.query).filter(|s| !s.is_empty())) + } + + pub fn fetch_results(&self) { + let api = self.app_model.get_spotify(); + if let Some(query) = self.get_query() { + let query = query.to_owned(); + self.dispatcher + .call_spotify_and_dispatch(move || async move { + api.search(&query, 0, 5) + .await + .map(|results| BrowserAction::SetSearchResults(Box::new(results)).into()) + }); + } + } + + pub fn get_album_results(&self) -> Option> + '_> { + self.app_model + .map_state_opt(|s| Some(&s.browser.search_state()?.album_results)) + } + + pub fn get_artist_results(&self) -> Option> + '_> { + self.app_model + .map_state_opt(|s| Some(&s.browser.search_state()?.artist_results)) + } + + pub fn open_album(&self, id: String) { + self.dispatcher.dispatch(AppAction::ViewAlbum(id)); + } + + pub fn open_artist(&self, id: String) { + self.dispatcher.dispatch(AppAction::ViewArtist(id)); + } +} diff --git a/src/app/components/selection/component.rs b/src/app/components/selection/component.rs new file mode 100644 index 0000000..5737237 --- /dev/null +++ b/src/app/components/selection/component.rs @@ -0,0 +1,233 @@ +use gettextrs::gettext; +use gtk::prelude::*; +use std::ops::Deref; +use std::rc::Rc; + +use crate::app::components::{Component, EventListener}; +use crate::app::models::PlaylistSummary; +use crate::app::state::{ + LoginEvent, SelectionAction, SelectionContext, SelectionEvent, SelectionState, +}; +use crate::app::{ActionDispatcher, AppAction, AppEvent, AppModel, BrowserAction}; + +use super::widget::{SelectionToolState, SelectionToolbarWidget}; + +pub struct SelectionToolbarModel { + app_model: Rc, + dispatcher: Box, +} + +impl SelectionToolbarModel { + pub fn new(app_model: Rc, dispatcher: Box) -> Self { + Self { + app_model, + dispatcher, + } + } + + pub fn move_up_selection(&self) { + self.dispatcher.dispatch(AppAction::MoveUpSelection); + } + + pub fn move_down_selection(&self) { + self.dispatcher.dispatch(AppAction::MoveDownSelection); + } + + pub fn queue_selection(&self) { + self.dispatcher.dispatch(AppAction::QueueSelection); + } + + fn dequeue_selection(&self) { + self.dispatcher.dispatch(AppAction::DequeueSelection); + } + + pub fn remove_selection(&self) { + match &self.selection().context { + SelectionContext::SavedTracks => self.remove_saved_tracks(), + SelectionContext::Queue => self.dequeue_selection(), + SelectionContext::EditablePlaylist(id) => self.remove_from_playlist(id), + _ => {} + } + } + + pub fn save_selection(&self) { + let api = self.app_model.get_spotify(); + let ids: Vec = self + .selection() + .peek_selection() + .map(|s| &s.id) + .cloned() + .collect(); + self.dispatcher + .call_spotify_and_dispatch_many(move || async move { + api.save_tracks(ids).await?; + Ok(vec![ + AppAction::SaveSelection, + AppAction::ShowNotification(gettext("Tracks saved!")), + ]) + }) + } + + fn remove_saved_tracks(&self) { + let api = self.app_model.get_spotify(); + let ids: Vec = self + .selection() + .peek_selection() + .map(|s| &s.id) + .cloned() + .collect(); + self.dispatcher + .call_spotify_and_dispatch_many(move || async move { + api.remove_saved_tracks(ids).await?; + Ok(vec![AppAction::UnsaveSelection]) + }) + } + + fn selection(&self) -> impl Deref + '_ { + self.app_model.map_state(|s| &s.selection) + } + + fn selected_count(&self) -> usize { + self.selection().count() + } + + fn user_playlists(&self) -> impl Deref> + '_ { + self.app_model.map_state(|s| &s.logged_user.playlists) + } + + fn add_to_playlist(&self, id: &str) { + let id = id.to_string(); + let api = self.app_model.get_spotify(); + let uris: Vec = self + .selection() + .peek_selection() + .map(|s| &s.uri) + .cloned() + .collect(); + self.dispatcher + .call_spotify_and_dispatch(move || async move { + api.add_to_playlist(&id, uris).await?; + Ok(SelectionAction::Clear.into()) + }) + } + + fn remove_from_playlist(&self, id: &str) { + let api = self.app_model.get_spotify(); + let id = id.to_string(); + let uris: Vec = self + .selection() + .peek_selection() + .map(|s| &s.uri) + .cloned() + .collect(); + self.dispatcher + .call_spotify_and_dispatch_many(move || async move { + api.remove_from_playlist(&id, uris.clone()).await?; + Ok(vec![ + BrowserAction::RemoveTracksFromPlaylist(id, uris).into(), + SelectionAction::Clear.into(), + ]) + }) + } +} + +pub struct SelectionToolbar { + model: Rc, + widget: SelectionToolbarWidget, +} + +impl SelectionToolbar { + pub fn new(model: SelectionToolbarModel, widget: SelectionToolbarWidget) -> Self { + let model = Rc::new(model); + widget.connect_move_up(clone!(@weak model => move || model.move_up_selection())); + widget.connect_move_down(clone!(@weak model => move || model.move_down_selection())); + widget.connect_queue(clone!(@weak model => move || model.queue_selection())); + widget.connect_remove(clone!(@weak model => move || model.remove_selection())); + widget.connect_save(clone!(@weak model => move || model.save_selection())); + Self { model, widget } + } + + fn update_active_tools(&self) { + let count = self.model.selected_count(); + match self.model.selection().context { + SelectionContext::Default => { + self.widget.set_move(SelectionToolState::Hidden); + self.widget + .set_queue(SelectionToolState::Visible(count > 0)); + self.widget.set_add(SelectionToolState::Visible(count > 0)); + self.widget.set_remove(SelectionToolState::Hidden); + self.widget.set_save(SelectionToolState::Visible(count > 0)); + } + SelectionContext::SavedTracks => { + self.widget.set_move(SelectionToolState::Hidden); + self.widget + .set_queue(SelectionToolState::Visible(count > 0)); + self.widget.set_add(SelectionToolState::Visible(count > 0)); + self.widget + .set_remove(SelectionToolState::Visible(count > 0)); + self.widget.set_save(SelectionToolState::Hidden); + } + SelectionContext::ReadOnlyQueue => { + self.widget.set_move(SelectionToolState::Hidden); + self.widget.set_queue(SelectionToolState::Hidden); + self.widget.set_add(SelectionToolState::Hidden); + self.widget.set_remove(SelectionToolState::Hidden); + self.widget.set_save(SelectionToolState::Visible(count > 0)); + } + SelectionContext::Queue => { + self.widget + .set_move(SelectionToolState::Visible(count == 1)); + self.widget.set_queue(SelectionToolState::Hidden); + self.widget.set_add(SelectionToolState::Hidden); + self.widget + .set_remove(SelectionToolState::Visible(count > 0)); + self.widget.set_save(SelectionToolState::Visible(count > 0)); + } + SelectionContext::Playlist => { + self.widget.set_move(SelectionToolState::Hidden); + self.widget + .set_queue(SelectionToolState::Visible(count > 0)); + self.widget.set_add(SelectionToolState::Hidden); + self.widget.set_remove(SelectionToolState::Hidden); + self.widget.set_save(SelectionToolState::Hidden); + } + SelectionContext::EditablePlaylist(_) => { + self.widget.set_move(SelectionToolState::Hidden); + self.widget + .set_queue(SelectionToolState::Visible(count > 0)); + self.widget.set_add(SelectionToolState::Hidden); + self.widget + .set_remove(SelectionToolState::Visible(count > 0)); + self.widget.set_save(SelectionToolState::Hidden); + } + }; + } +} + +impl Component for SelectionToolbar { + fn get_root_widget(&self) -> >k::Widget { + self.widget.upcast_ref() + } +} + +impl EventListener for SelectionToolbar { + fn on_event(&mut self, event: &AppEvent) { + match event { + AppEvent::SelectionEvent(SelectionEvent::SelectionModeChanged(active)) => { + self.widget.set_visible(*active); + self.update_active_tools(); + } + AppEvent::SelectionEvent(SelectionEvent::SelectionChanged) => { + self.update_active_tools(); + } + AppEvent::LoginEvent(LoginEvent::UserPlaylistsLoaded) => { + let model = &self.model; + self.widget.connect_playlists( + &model.user_playlists(), + clone!(@weak model => move |s| model.add_to_playlist(s)), + ); + } + _ => {} + } + } +} diff --git a/src/app/components/selection/icons/music-queue-symbolic.svg b/src/app/components/selection/icons/music-queue-symbolic.svg new file mode 100644 index 0000000..1152a2d --- /dev/null +++ b/src/app/components/selection/icons/music-queue-symbolic.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/app/components/selection/icons/playlist2-symbolic.svg b/src/app/components/selection/icons/playlist2-symbolic.svg new file mode 100644 index 0000000..3d820c0 --- /dev/null +++ b/src/app/components/selection/icons/playlist2-symbolic.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/app/components/selection/mod.rs b/src/app/components/selection/mod.rs new file mode 100644 index 0000000..a737bac --- /dev/null +++ b/src/app/components/selection/mod.rs @@ -0,0 +1,10 @@ +mod widget; + +mod component; +pub use component::*; + +use glib::prelude::*; + +pub fn expose_widgets() { + widget::SelectionToolbarWidget::static_type(); +} diff --git a/src/app/components/selection/selection_toolbar.blp b/src/app/components/selection/selection_toolbar.blp new file mode 100644 index 0000000..31953cc --- /dev/null +++ b/src/app/components/selection/selection_toolbar.blp @@ -0,0 +1,91 @@ +using Gtk 4.0; +using Adw 1; + +template $SelectionToolbarWidget : Box { + hexpand: true; + visible: false; + + ActionBar action_bar { + hexpand: true; + revealed: true; + + Box { + valign: center; + + styles [ + "linked", + ] + + Button move_up { + icon-name: "go-up-symbolic"; + } + + Button move_down { + icon-name: "go-down-symbolic"; + } + } + + [end] + Button queue { + valign: center; + has-frame: false; + + Adw.Squeezer { + Adw.ButtonContent { + icon-name: "music-queue-symbolic"; + label: _("Add to queue"); + } + + Adw.ButtonContent { + icon-name: "music-queue-symbolic"; + } + } + } + + [end] + MenuButton add { + valign: center; + has-frame: false; + label: _("Add to playlist..."); + direction: up; + } + + [end] + Button remove { + valign: center; + has-frame: false; + + Adw.Squeezer { + Adw.ButtonContent { + icon-name: "user-trash-symbolic"; + label: _("Remove"); + } + + Adw.ButtonContent { + icon-name: "user-trash-symbolic"; + } + } + } + + [end] + Button save { + valign: center; + has-frame: false; + + Adw.Squeezer { + Adw.ButtonContent { + icon-name: "star-new-symbolic"; + label: _("Save to library"); + } + + Adw.ButtonContent { + icon-name: "star-new-symbolic"; + } + } + } + } + + styles [ + "selection_toolbar", + ] +} diff --git a/src/app/components/selection/selection_toolbar.css b/src/app/components/selection/selection_toolbar.css new file mode 100644 index 0000000..c580a23 --- /dev/null +++ b/src/app/components/selection/selection_toolbar.css @@ -0,0 +1,3 @@ +.selection_toolbar { + background: @theme_bg_color; +} diff --git a/src/app/components/selection/widget.rs b/src/app/components/selection/widget.rs new file mode 100644 index 0000000..d6ede9e --- /dev/null +++ b/src/app/components/selection/widget.rs @@ -0,0 +1,183 @@ +use gio::prelude::ActionMapExt; +use gio::{SimpleAction, SimpleActionGroup}; +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; + +use crate::app::components::{display_add_css_provider, labels}; +use crate::app::models::PlaylistSummary; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/selection_toolbar.ui")] + pub struct SelectionToolbarWidget { + #[template_child] + pub action_bar: TemplateChild, + + #[template_child] + pub move_up: TemplateChild, + + #[template_child] + pub move_down: TemplateChild, + + #[template_child] + pub add: TemplateChild, + + #[template_child] + pub remove: TemplateChild, + + #[template_child] + pub queue: TemplateChild, + + #[template_child] + pub save: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for SelectionToolbarWidget { + const NAME: &'static str = "SelectionToolbarWidget"; + type Type = super::SelectionToolbarWidget; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for SelectionToolbarWidget { + fn constructed(&self) { + self.parent_constructed(); + display_add_css_provider(resource!("/components/selection_toolbar.css")); + } + } + + impl WidgetImpl for SelectionToolbarWidget {} + impl BoxImpl for SelectionToolbarWidget {} +} + +glib::wrapper! { + pub struct SelectionToolbarWidget(ObjectSubclass) @extends gtk::Widget, gtk::Box; +} + +#[derive(Debug, Clone, Copy)] +pub enum SelectionToolState { + Hidden, + Visible(bool), +} + +impl SelectionToolState { + fn visible(self) -> bool { + matches!(self, SelectionToolState::Visible(_)) + } + + fn sensitive(self) -> bool { + matches!(self, SelectionToolState::Visible(true)) + } +} + +impl SelectionToolbarWidget { + pub fn connect_move_down(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().move_down.connect_clicked(move |_| f()); + } + + pub fn connect_move_up(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().move_up.connect_clicked(move |_| f()); + } + + pub fn connect_queue(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().queue.connect_clicked(move |_| f()); + } + + pub fn connect_save(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().save.connect_clicked(move |_| f()); + } + + pub fn connect_remove(&self, f: F) + where + F: Fn() + 'static, + { + self.imp().remove.connect_clicked(move |_| f()); + } + + pub fn set_move(&self, state: SelectionToolState) { + self.imp().move_up.set_sensitive(state.sensitive()); + self.imp().move_up.set_visible(state.visible()); + self.imp().move_down.set_sensitive(state.sensitive()); + self.imp().move_down.set_visible(state.visible()); + } + + pub fn set_queue(&self, state: SelectionToolState) { + self.imp().queue.set_sensitive(state.sensitive()); + self.imp().queue.set_visible(state.visible()); + } + + pub fn set_add(&self, state: SelectionToolState) { + self.imp().add.set_sensitive(state.sensitive()); + self.imp().add.set_visible(state.visible()); + } + + pub fn set_remove(&self, state: SelectionToolState) { + self.imp().remove.set_sensitive(state.sensitive()); + self.imp().remove.set_visible(state.visible()); + } + + pub fn set_save(&self, state: SelectionToolState) { + self.imp().save.set_sensitive(state.sensitive()); + self.imp().save.set_visible(state.visible()); + } + + pub fn set_visible(&self, visible: bool) { + gtk::Widget::set_visible(self.upcast_ref(), visible); + self.imp().action_bar.set_revealed(visible); + } + + pub fn connect_playlists(&self, playlists: &[PlaylistSummary], on_playlist_selected: F) + where + F: Fn(&str) + Clone + 'static, + { + let menu = gio::Menu::new(); + let action_group = SimpleActionGroup::new(); + + for PlaylistSummary { title, id } in playlists { + let action_name = format!("playlist_{id}"); + + action_group.add_action(&{ + let id = id.clone(); + let action = SimpleAction::new(&action_name, None); + let f = on_playlist_selected.clone(); + action.connect_activate(move |_, _| f(&id)); + action + }); + + menu.append( + Some(&labels::add_to_playlist_label(title)), + Some(&format!("add_to.{action_name}")), + ); + } + + let popover = gtk::PopoverMenu::from_model(Some(&menu)); + self.imp().add.set_popover(Some(&popover)); + self.imp() + .add + .insert_action_group("add_to", Some(&action_group)); + } +} diff --git a/src/app/components/settings/mod.rs b/src/app/components/settings/mod.rs new file mode 100644 index 0000000..d874084 --- /dev/null +++ b/src/app/components/settings/mod.rs @@ -0,0 +1,6 @@ +#[allow(clippy::module_inception)] +mod settings; +mod settings_model; + +pub use settings::*; +pub use settings_model::*; diff --git a/src/app/components/settings/settings.blp b/src/app/components/settings/settings.blp new file mode 100644 index 0000000..02ba6f1 --- /dev/null +++ b/src/app/components/settings/settings.blp @@ -0,0 +1,109 @@ +using Gtk 4.0; +using Adw 1; + +template $SettingsWindow : Adw.PreferencesWindow { + default-width: 600; + hide-on-close: true; + search-enabled: false; + + Adw.PreferencesPage { + Adw.PreferencesGroup { + /* Translators: Header for a group of preference items regarding audio */ + + title: _("Audio"); + + Adw.ComboRow audio_backend { + /* Translators: Title for an item in preferences */ + + title: _("Audio Backend"); + model: StringList { + strings [ + "PulseAudio", + "ALSA", + "Pipewire (GStreamer)" + ] + }; + } + + Adw.ActionRow alsa_device_row { + /* Translators: Title for an item in preferences */ + + title: _("ALSA Device"); + + /* Translators: Description for the item (ALSA Device) in preferences */ + + subtitle: _("Applied only if audio backend is ALSA"); + + Entry alsa_device { + valign: center; + } + } + + Adw.ComboRow player_bitrate { + /* Translators: Title for an item in preferences */ + + title: _("Audio Quality"); + model: StringList { + strings [ + _("Normal"), + _("High"), + _("Very high"), + ] + }; + } + + Adw.ActionRow gapless_playback { + /* Translators: Title for an item in preferences */ + + title: _("Gapless playback"); + name: "gapless_playback_row"; + activatable-widget: gapless_playback_switch; + visible: true; + + Switch gapless_playback_switch { + margin-top: 12; + margin-bottom: 12; + } + } + } + + Adw.PreferencesGroup { + /* Translators: Header for a group of preference items regarding the application's appearance */ + + title: _("Appearance"); + + Adw.ComboRow theme { + /* Translators: Title for an item in preferences */ + + title: _("Theme"); + model: StringList { + strings [ + _("Light"), + _("Dark"), + _("System") + ] + }; + } + } + + Adw.PreferencesGroup { + /* Translators: Header for a group of preference items regarding network */ + + title: _("Network"); + + Adw.ActionRow { + /* Translators: Title for an item in preferences */ + + title: _("Access Point Port"); + + /* Translators: Longer description for an item (Access Point Port) in preferences */ + + subtitle: _("Port used for connections to Spotify\'s Access Point. Set to 0 if any port is fine."); + + Entry ap_port { + valign: center; + } + } + } + } +} diff --git a/src/app/components/settings/settings.rs b/src/app/components/settings/settings.rs new file mode 100644 index 0000000..cbf4bcf --- /dev/null +++ b/src/app/components/settings/settings.rs @@ -0,0 +1,290 @@ +use crate::app::components::EventListener; +use crate::app::AppEvent; +use crate::settings::SpotSettings; + +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; +use libadwaita::prelude::*; + +use super::SettingsModel; + +const SETTINGS: &str = "dev.alextren.Spot"; + +mod imp { + + use super::*; + use libadwaita::subclass::prelude::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/settings.ui")] + pub struct SettingsWindow { + #[template_child] + pub player_bitrate: TemplateChild, + + #[template_child] + pub alsa_device: TemplateChild, + + #[template_child] + pub alsa_device_row: TemplateChild, + + #[template_child] + pub audio_backend: TemplateChild, + + #[template_child] + pub gapless_playback: TemplateChild, + + #[template_child] + pub ap_port: TemplateChild, + + #[template_child] + pub theme: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for SettingsWindow { + const NAME: &'static str = "SettingsWindow"; + type Type = super::SettingsWindow; + type ParentType = libadwaita::PreferencesWindow; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for SettingsWindow {} + impl WidgetImpl for SettingsWindow {} + impl WindowImpl for SettingsWindow {} + impl AdwWindowImpl for SettingsWindow {} + impl PreferencesWindowImpl for SettingsWindow {} +} + +glib::wrapper! { + pub struct SettingsWindow(ObjectSubclass) @extends gtk::Widget, gtk::Window, libadwaita::Window, libadwaita::PreferencesWindow; +} + +impl Default for SettingsWindow { + fn default() -> Self { + Self::new() + } +} + +impl SettingsWindow { + pub fn new() -> Self { + let window: Self = glib::Object::new(); + + window.bind_backend_and_device(); + window.bind_settings(); + window.connect_theme_select(); + window + } + + fn bind_backend_and_device(&self) { + let widget = self.imp(); + + let audio_backend = widget + .audio_backend + .downcast_ref::() + .unwrap(); + let alsa_device_row = widget + .alsa_device_row + .downcast_ref::() + .unwrap(); + + audio_backend + .bind_property("selected", alsa_device_row, "visible") + .transform_to(|_, value: u32| Some(value == 1)) + .build(); + + if audio_backend.selected() == 0 { + alsa_device_row.set_visible(false); + } + } + + fn bind_settings(&self) { + let widget = self.imp(); + let settings = gio::Settings::new(SETTINGS); + + let player_bitrate = widget + .player_bitrate + .downcast_ref::() + .unwrap(); + settings + .bind("player-bitrate", player_bitrate, "selected") + .mapping(|variant, _| { + variant.str().map(|s| { + match s { + "96" => 0, + "160" => 1, + "320" => 2, + _ => unreachable!(), + } + .to_value() + }) + }) + .set_mapping(|value, _| { + value.get::().ok().map(|u| { + match u { + 0 => "96", + 1 => "160", + 2 => "320", + _ => unreachable!(), + } + .to_variant() + }) + }) + .build(); + + let alsa_device = widget.alsa_device.downcast_ref::().unwrap(); + settings.bind("alsa-device", alsa_device, "text").build(); + + let audio_backend = widget + .audio_backend + .downcast_ref::() + .unwrap(); + settings + .bind("audio-backend", audio_backend, "selected") + .mapping(|variant, _| { + variant.str().map(|s| { + match s { + "pulseaudio" => 0, + "alsa" => 1, + "gstreamer" => 2, + _ => unreachable!(), + } + .to_value() + }) + }) + .set_mapping(|value, _| { + value.get::().ok().map(|u| { + match u { + 0 => "pulseaudio", + 1 => "alsa", + 2 => "gstreamer", + _ => unreachable!(), + } + .to_variant() + }) + }) + .build(); + + let gapless_playback = widget + .gapless_playback + .downcast_ref::() + .unwrap(); + settings + .bind( + "gapless-playback", + &gapless_playback.activatable_widget().unwrap(), + "active", + ) + .build(); + + let ap_port = widget.ap_port.downcast_ref::().unwrap(); + settings + .bind("ap-port", ap_port, "text") + .mapping(|variant, _| variant.get::().map(|s| s.to_value())) + .set_mapping(|value, _| value.get::().ok().map(|u| u.to_variant())) + .build(); + + let theme = widget.theme.downcast_ref::().unwrap(); + settings + .bind("theme-preference", theme, "selected") + .mapping(|variant, _| { + variant.str().map(|s| { + match s { + "light" => 0, + "dark" => 1, + "system" => 2, + _ => unreachable!(), + } + .to_value() + }) + }) + .set_mapping(|value, _| { + value.get::().ok().map(|u| { + match u { + 0 => "light", + 1 => "dark", + 2 => "system", + _ => unreachable!(), + } + .to_variant() + }) + }) + .build(); + } + + fn connect_theme_select(&self) { + let widget = self.imp(); + let theme = widget.theme.downcast_ref::().unwrap(); + theme.connect_selected_notify(|theme| { + debug!("Theme switched! --> value: {}", theme.selected()); + let manager = libadwaita::StyleManager::default(); + + let pref = match theme.selected() { + 0 => libadwaita::ColorScheme::ForceLight, + 1 => libadwaita::ColorScheme::ForceDark, + _ => libadwaita::ColorScheme::Default, + }; + + manager.set_color_scheme(pref); + }); + } + + fn connect_close(&self, on_close: F) + where + F: Fn() + 'static, + { + let window = self.upcast_ref::(); + + window.connect_close_request( + clone!(@weak self as _self => @default-return gtk::Inhibit(false), move |_| { + on_close(); + gtk::Inhibit(false) + }), + ); + } +} + +pub struct Settings { + parent: gtk::Window, + settings_window: SettingsWindow, +} + +impl Settings { + pub fn new(parent: gtk::Window, model: SettingsModel) -> Self { + let settings_window = SettingsWindow::new(); + + settings_window.connect_close(move || { + let new_settings = SpotSettings::new_from_gsettings().unwrap_or_default(); + if model.settings().player_settings != new_settings.player_settings { + model.stop_player(); + } + model.set_settings(); + }); + + Self { + parent, + settings_window, + } + } + + fn window(&self) -> &libadwaita::Window { + self.settings_window.upcast_ref::() + } + + pub fn show_self(&self) { + self.window().set_transient_for(Some(&self.parent)); + self.window().set_modal(true); + self.window().set_visible(true); + } +} + +impl EventListener for Settings { + fn on_event(&mut self, _: &AppEvent) {} +} diff --git a/src/app/components/settings/settings_model.rs b/src/app/components/settings/settings_model.rs new file mode 100644 index 0000000..4706497 --- /dev/null +++ b/src/app/components/settings/settings_model.rs @@ -0,0 +1,32 @@ +use crate::app::state::{PlaybackAction, SettingsAction}; +use crate::app::{ActionDispatcher, AppModel}; +use crate::settings::SpotSettings; +use std::rc::Rc; + +pub struct SettingsModel { + app_model: Rc, + dispatcher: Box, +} + +impl SettingsModel { + pub fn new(app_model: Rc, dispatcher: Box) -> Self { + Self { + app_model, + dispatcher, + } + } + + pub fn stop_player(&self) { + self.dispatcher.dispatch(PlaybackAction::Stop.into()); + } + + pub fn set_settings(&self) { + self.dispatcher + .dispatch(SettingsAction::ChangeSettings.into()); + } + + pub fn settings(&self) -> SpotSettings { + let state = self.app_model.get_state(); + state.settings.settings.clone() + } +} diff --git a/src/app/components/sidebar/create_playlist.blp b/src/app/components/sidebar/create_playlist.blp new file mode 100644 index 0000000..269577f --- /dev/null +++ b/src/app/components/sidebar/create_playlist.blp @@ -0,0 +1,40 @@ +using Gtk 4.0; + +template $CreatePlaylistPopover : Popover { + position: right; + + Box box { + Label label { + /* Translators: label for the entry containing the name of a new playlist */ + + label: _("Name"); + } + + Entry entry { + focusable: true; + margin-start: 12; + margin-end: 12; + } + + Revealer error_revealer { + Label error_label { + max-width-chars: 0; + wrap: true; + xalign: 0; + } + } + + Button button { + /* Translators: Button that creates a new playlist */ + + label: _("Create"); + focusable: true; + halign: end; + use-underline: true; + + styles [ + "suggested-action", + ] + } + } +} diff --git a/src/app/components/sidebar/create_playlist.rs b/src/app/components/sidebar/create_playlist.rs new file mode 100644 index 0000000..27d4238 --- /dev/null +++ b/src/app/components/sidebar/create_playlist.rs @@ -0,0 +1,67 @@ +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; + +mod imp { + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/create_playlist.ui")] + pub struct CreatePlaylistPopover { + #[template_child] + pub label: TemplateChild, + + #[template_child] + pub entry: TemplateChild, + + #[template_child] + pub button: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for CreatePlaylistPopover { + const NAME: &'static str = "CreatePlaylistPopover"; + type Type = super::CreatePlaylistPopover; + type ParentType = gtk::Popover; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for CreatePlaylistPopover {} + impl WidgetImpl for CreatePlaylistPopover {} + impl PopoverImpl for CreatePlaylistPopover {} +} + +glib::wrapper! { + pub struct CreatePlaylistPopover(ObjectSubclass) @extends gtk::Widget, gtk::Popover; +} + +impl Default for CreatePlaylistPopover { + fn default() -> Self { + Self::new() + } +} + +impl CreatePlaylistPopover { + pub fn new() -> Self { + glib::Object::new() + } + + pub fn connect_create(&self, create_fun: F) { + let entry = self.imp().entry.get(); + let closure = clone!(@weak self as popover, @weak entry, @strong create_fun => move || { + create_fun(entry.text().to_string()); + popover.popdown(); + entry.buffer().delete_text(0, None); + }); + let closure_clone = closure.clone(); + entry.connect_activate(move |_| closure()); + self.imp().button.connect_clicked(move |_| closure_clone()); + } +} diff --git a/src/app/components/sidebar/icons/library-music-symbolic.svg b/src/app/components/sidebar/icons/library-music-symbolic.svg new file mode 100644 index 0000000..0ef4ce1 --- /dev/null +++ b/src/app/components/sidebar/icons/library-music-symbolic.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/app/components/sidebar/mod.rs b/src/app/components/sidebar/mod.rs new file mode 100644 index 0000000..95a4e2d --- /dev/null +++ b/src/app/components/sidebar/mod.rs @@ -0,0 +1,9 @@ +#[allow(clippy::module_inception)] +mod sidebar; +pub use sidebar::*; + +mod sidebar_item; +pub use sidebar_item::*; + +mod create_playlist; +mod sidebar_row; diff --git a/src/app/components/sidebar/sidebar.rs b/src/app/components/sidebar/sidebar.rs new file mode 100644 index 0000000..19d96f2 --- /dev/null +++ b/src/app/components/sidebar/sidebar.rs @@ -0,0 +1,200 @@ +use gettextrs::gettext; +use gtk::prelude::*; +use std::rc::Rc; + +use super::create_playlist::CreatePlaylistPopover; +use super::{ + sidebar_row::SidebarRow, SidebarDestination, SidebarItem, CREATE_PLAYLIST_ITEM, + SAVED_PLAYLISTS_SECTION, +}; +use crate::app::models::{AlbumModel, PlaylistSummary}; +use crate::app::state::ScreenName; +use crate::app::{ + ActionDispatcher, AppAction, AppEvent, AppModel, BrowserAction, BrowserEvent, Component, + EventListener, +}; + +const NUM_FIXED_ENTRIES: u32 = 6; +const NUM_PLAYLISTS: usize = 20; + +pub struct SidebarModel { + app_model: Rc, + dispatcher: Box, +} + +impl SidebarModel { + pub fn new(app_model: Rc, dispatcher: Box) -> Self { + Self { + app_model, + dispatcher, + } + } + + fn get_playlists(&self) -> Vec { + self.app_model + .get_state() + .browser + .home_state() + .expect("expected HomeState to be available") + .playlists + .iter() + .take(NUM_PLAYLISTS) + .map(Self::map_to_destination) + .collect() + } + + fn map_to_destination(a: AlbumModel) -> SidebarDestination { + let title = Some(a.album()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| gettext("Unnamed playlist")); + let id = a.uri(); + SidebarDestination::Playlist(PlaylistSummary { id, title }) + } + + fn create_new_playlist(&self, name: String) { + let user_id = self.app_model.get_state().logged_user.user.clone().unwrap(); + let api = self.app_model.get_spotify(); + self.dispatcher + .call_spotify_and_dispatch(move || async move { + api.create_new_playlist(name.as_str(), user_id.as_str()) + .await + .map(AppAction::CreatePlaylist) + }) + } + + fn navigate(&self, dest: SidebarDestination) { + let actions = match dest { + SidebarDestination::Library + | SidebarDestination::SavedTracks + | SidebarDestination::NowPlaying + | SidebarDestination::SavedPlaylists => { + vec![ + BrowserAction::NavigationPopTo(ScreenName::Home).into(), + BrowserAction::SetHomeVisiblePage(dest.id()).into(), + ] + } + SidebarDestination::Playlist(PlaylistSummary { id, .. }) => { + vec![AppAction::ViewPlaylist(id)] + } + }; + self.dispatcher.dispatch_many(actions); + } +} + +pub struct Sidebar { + listbox: gtk::ListBox, + list_store: gio::ListStore, + model: Rc, +} + +impl Sidebar { + pub fn new(listbox: gtk::ListBox, model: Rc) -> Self { + let popover = CreatePlaylistPopover::new(); + popover.connect_create(clone!(@weak model => move |t| model.create_new_playlist(t))); + + let list_store = gio::ListStore::new(SidebarItem::static_type()); + + list_store.append(&SidebarItem::from_destination(SidebarDestination::Library)); + list_store.append(&SidebarItem::from_destination( + SidebarDestination::SavedTracks, + )); + list_store.append(&SidebarItem::from_destination( + SidebarDestination::NowPlaying, + )); + list_store.append(&SidebarItem::playlists_section()); + list_store.append(&SidebarItem::create_playlist_item()); + list_store.append(&SidebarItem::from_destination( + SidebarDestination::SavedPlaylists, + )); + + listbox.bind_model( + Some(&list_store), + clone!(@weak popover => @default-panic, move |obj| { + let item = obj.downcast_ref::().unwrap(); + if item.navigatable() { + Self::make_navigatable(item) + } else { + match item.id().as_str() { + SAVED_PLAYLISTS_SECTION => Self::make_section_label(item), + CREATE_PLAYLIST_ITEM => Self::make_create_playlist(item, popover), + _ => unimplemented!(), + } + } + }), + ); + + listbox.connect_row_activated(clone!(@weak popover, @weak model => move |_, row| { + if let Some(row) = row.downcast_ref::() { + if let Some(dest) = row.item().destination() { + model.navigate(dest); + } else { + match row.item().id().as_str() { + CREATE_PLAYLIST_ITEM => popover.popup(), + _ => unimplemented!() + } + } + } + })); + + Self { + listbox, + list_store, + model, + } + } + + fn make_navigatable(item: &SidebarItem) -> gtk::Widget { + let row = SidebarRow::new(item.clone()); + row.set_selectable(false); + row.upcast() + } + + fn make_section_label(item: &SidebarItem) -> gtk::Widget { + let label = gtk::Label::new(Some(item.title().as_str())); + label.add_css_class("caption-heading"); + let row = gtk::ListBoxRow::builder() + .activatable(false) + .selectable(false) + .sensitive(false) + .child(&label) + .build(); + row.upcast() + } + + fn make_create_playlist(item: &SidebarItem, popover: CreatePlaylistPopover) -> gtk::Widget { + let row = SidebarRow::new(item.clone()); + row.set_activatable(true); + row.set_selectable(false); + row.set_sensitive(true); + popover.set_parent(&row); + row.upcast() + } + + fn update_playlists_in_sidebar(&self) { + let playlists: Vec = self + .model + .get_playlists() + .into_iter() + .map(SidebarItem::from_destination) + .collect(); + self.list_store.splice( + NUM_FIXED_ENTRIES, + self.list_store.n_items() - NUM_FIXED_ENTRIES, + playlists.as_slice(), + ); + } +} + +impl Component for Sidebar { + fn get_root_widget(&self) -> >k::Widget { + self.listbox.upcast_ref() + } +} + +impl EventListener for Sidebar { + fn on_event(&mut self, event: &AppEvent) { + if let AppEvent::BrowserEvent(BrowserEvent::SavedPlaylistsUpdated) = event { + self.update_playlists_in_sidebar(); + } + } +} diff --git a/src/app/components/sidebar/sidebar_item.rs b/src/app/components/sidebar/sidebar_item.rs new file mode 100644 index 0000000..9dddf82 --- /dev/null +++ b/src/app/components/sidebar/sidebar_item.rs @@ -0,0 +1,167 @@ +use gettextrs::gettext; +use glib::Properties; +use gtk::prelude::*; +use gtk::subclass::prelude::*; + +use crate::app::models::PlaylistSummary; + +const LIBRARY: &str = "library"; +const SAVED_TRACKS: &str = "saved_tracks"; +const NOW_PLAYING: &str = "now_playing"; +const SAVED_PLAYLISTS: &str = "saved_playlists"; +const PLAYLIST: &str = "playlist"; +pub const SAVED_PLAYLISTS_SECTION: &str = "saved_playlists_section"; +pub const CREATE_PLAYLIST_ITEM: &str = "create_playlist"; + +#[derive(Debug)] +pub enum SidebarDestination { + Library, + SavedTracks, + NowPlaying, + SavedPlaylists, + Playlist(PlaylistSummary), +} + +impl SidebarDestination { + pub fn id(&self) -> &'static str { + match self { + Self::Library => LIBRARY, + Self::SavedTracks => SAVED_TRACKS, + Self::NowPlaying => NOW_PLAYING, + Self::SavedPlaylists => SAVED_PLAYLISTS, + Self::Playlist(_) => PLAYLIST, + } + } + + pub fn title(&self) -> String { + match self { + // translators: This is a sidebar entry to browse to saved albums. + Self::Library => gettext("Library"), + // translators: This is a sidebar entry to browse to saved tracks. + Self::SavedTracks => gettext("Saved tracks"), + // translators: This is a sidebar entry to browse to saved playlists. + Self::NowPlaying => gettext("Now playing"), + // translators: This is a sidebar entry that marks that the entries below are playlists. + Self::SavedPlaylists => gettext("Playlists"), + Self::Playlist(PlaylistSummary { title, .. }) => title.clone(), + } + } + + pub fn icon(&self) -> &'static str { + match self { + Self::Library => "library-music-symbolic", + Self::SavedTracks => "starred-symbolic", + Self::NowPlaying => "music-queue-symbolic", + Self::SavedPlaylists => "view-app-grid-symbolic", + Self::Playlist(_) => "playlist2-symbolic", + } + } +} + +impl SidebarItem { + pub fn from_destination(dest: SidebarDestination) -> Self { + let (id, data, title) = match dest { + SidebarDestination::Playlist(PlaylistSummary { id, title }) => { + (PLAYLIST, Some(id), title) + } + _ => (dest.id(), None, dest.title()), + }; + glib::Object::builder() + .property("id", id) + .property("data", data.unwrap_or_default()) + .property("title", &title) + .property("navigatable", true) + .build() + } + + pub fn playlists_section() -> Self { + glib::Object::builder() + .property("id", SAVED_PLAYLISTS_SECTION) + .property("data", String::new()) + .property("title", gettext("All Playlists")) + .property("navigatable", false) + .build() + } + + pub fn create_playlist_item() -> Self { + glib::Object::builder() + .property("id", CREATE_PLAYLIST_ITEM) + .property("data", String::new()) + .property("title", gettext("New Playlist")) + .property("navigatable", false) + .build() + } + + pub fn destination(&self) -> Option { + let navigatable = self.property::("navigatable"); + if navigatable { + let id = self.id(); + let data = self.property::("data"); + let title = self.title(); + match id.as_str() { + LIBRARY => Some(SidebarDestination::Library), + SAVED_TRACKS => Some(SidebarDestination::SavedTracks), + NOW_PLAYING => Some(SidebarDestination::NowPlaying), + SAVED_PLAYLISTS => Some(SidebarDestination::SavedPlaylists), + PLAYLIST => Some(SidebarDestination::Playlist(PlaylistSummary { + id: data, + title, + })), + _ => None, + } + } else { + None + } + } + + pub fn icon(&self) -> Option<&str> { + match self.id().as_str() { + CREATE_PLAYLIST_ITEM => Some("list-add-symbolic"), + _ => self.destination().map(|d| d.icon()), + } + } +} + +mod imp { + use super::*; + use gdk::cairo::glib::ParamSpec; + use std::cell::{Cell, RefCell}; + + #[derive(Debug, Default, Properties)] + #[properties(wrapper_type = super::SidebarItem)] + pub struct SidebarItem { + #[property(get, set)] + pub id: RefCell, + #[property(get, set)] + pub data: RefCell, + #[property(get, set)] + pub title: RefCell, + #[property(get, set)] + pub navigatable: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for SidebarItem { + const NAME: &'static str = "SideBarItem"; + type Type = super::SidebarItem; + type ParentType = glib::Object; + } + + impl ObjectImpl for SidebarItem { + fn properties() -> &'static [ParamSpec] { + Self::derived_properties() + } + + fn set_property(&self, id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + self.derived_set_property(id, value, pspec); + } + + fn property(&self, id: usize, pspec: &glib::ParamSpec) -> glib::Value { + self.derived_property(id, pspec) + } + } +} + +glib::wrapper! { + pub struct SidebarItem(ObjectSubclass); +} diff --git a/src/app/components/sidebar/sidebar_row.blp b/src/app/components/sidebar/sidebar_row.blp new file mode 100644 index 0000000..6326471 --- /dev/null +++ b/src/app/components/sidebar/sidebar_row.blp @@ -0,0 +1,17 @@ +using Gtk 4.0; + +template $SidebarRow : ListBoxRow { + Box { + visible: true; + spacing: 12; + + Image icon { + } + + Label title { + width-chars: 20; + ellipsize: end; + xalign: 0; + } + } +} diff --git a/src/app/components/sidebar/sidebar_row.rs b/src/app/components/sidebar/sidebar_row.rs new file mode 100644 index 0000000..f86451f --- /dev/null +++ b/src/app/components/sidebar/sidebar_row.rs @@ -0,0 +1,84 @@ +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; + +use super::SidebarItem; + +impl SidebarRow { + pub fn new(item: SidebarItem) -> Self { + glib::Object::builder().property("item", item).build() + } +} + +mod imp { + use super::*; + use glib::{ParamSpec, Properties}; + use std::cell::RefCell; + + #[derive(Debug, CompositeTemplate, Properties)] + #[template(resource = "/dev/alextren/Spot/sidebar/sidebar_row.ui")] + #[properties(wrapper_type = super::SidebarRow)] + pub struct SidebarRow { + #[template_child] + pub icon: TemplateChild, + + #[template_child] + pub title: TemplateChild, + + #[property(get, set = Self::set_item)] + pub item: RefCell, + } + + impl SidebarRow { + fn set_item(&self, item: SidebarItem) { + self.title.set_text(item.title().as_str()); + self.icon.set_icon_name(item.icon()); + self.obj().set_tooltip_text(Some(item.title().as_str())); + self.item.replace(item); + } + } + + #[glib::object_subclass] + impl ObjectSubclass for SidebarRow { + const NAME: &'static str = "SidebarRow"; + type Type = super::SidebarRow; + type ParentType = gtk::ListBoxRow; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + + fn new() -> Self { + Self { + icon: Default::default(), + title: Default::default(), + item: RefCell::new(glib::Object::new()), + } + } + } + + impl ObjectImpl for SidebarRow { + fn properties() -> &'static [ParamSpec] { + Self::derived_properties() + } + + fn set_property(&self, id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + self.derived_set_property(id, value, pspec); + } + + fn property(&self, id: usize, pspec: &glib::ParamSpec) -> glib::Value { + self.derived_property(id, pspec) + } + } + + impl WidgetImpl for SidebarRow {} + impl ListBoxRowImpl for SidebarRow {} +} + +glib::wrapper! { + pub struct SidebarRow(ObjectSubclass) @extends gtk::Widget, gtk::ListBoxRow; +} diff --git a/src/app/components/user_details/mod.rs b/src/app/components/user_details/mod.rs new file mode 100644 index 0000000..e648930 --- /dev/null +++ b/src/app/components/user_details/mod.rs @@ -0,0 +1,6 @@ +#[allow(clippy::module_inception)] +mod user_details; +pub use user_details::*; + +mod user_details_model; +pub use user_details_model::*; diff --git a/src/app/components/user_details/user_details.blp b/src/app/components/user_details/user_details.blp new file mode 100644 index 0000000..a913ffc --- /dev/null +++ b/src/app/components/user_details/user_details.blp @@ -0,0 +1,44 @@ +using Gtk 4.0; + +template $UserDetailsWidget : Box { + ScrolledWindow scrolled_window { + hscrollbar-policy: never; + hexpand: true; + vexpand: true; + Box { + margin-start: 8; + margin-end: 8; + margin-top: 8; + margin-bottom: 8; + orientation: vertical; + spacing: 10; + + Label user_name { + halign: start; + margin-start: 8; + margin-end: 8; + label: "User"; + wrap: true; + xalign: 0; + + styles [ + "user_details--name", + "large-title", + ] + } + + FlowBox user_playlists { + height-request: 100; + valign: start; + hexpand: true; + min-children-per-line: 1; + selection-mode: none; + activate-on-single-click: false; + } + } + } + + styles [ + "user", + ] +} diff --git a/src/app/components/user_details/user_details.css b/src/app/components/user_details/user_details.css new file mode 100644 index 0000000..50f4d01 --- /dev/null +++ b/src/app/components/user_details/user_details.css @@ -0,0 +1,8 @@ +.user { + transition: opacity .3s ease; + opacity: 0; +} + +.user__loaded { + opacity: 1; +} diff --git a/src/app/components/user_details/user_details.rs b/src/app/components/user_details/user_details.rs new file mode 100644 index 0000000..d34c20e --- /dev/null +++ b/src/app/components/user_details/user_details.rs @@ -0,0 +1,150 @@ +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; +use std::rc::Rc; + +use crate::app::components::utils::wrap_flowbox_item; +use crate::app::components::{display_add_css_provider, AlbumWidget, Component, EventListener}; +use crate::app::{models::*, ListStore}; +use crate::app::{AppEvent, BrowserEvent, Worker}; + +use super::UserDetailsModel; + +mod imp { + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/user_details.ui")] + pub struct UserDetailsWidget { + #[template_child] + pub scrolled_window: TemplateChild, + + #[template_child] + pub user_name: TemplateChild, + + #[template_child] + pub user_playlists: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for UserDetailsWidget { + const NAME: &'static str = "UserDetailsWidget"; + type Type = super::UserDetailsWidget; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for UserDetailsWidget {} + impl WidgetImpl for UserDetailsWidget {} + impl BoxImpl for UserDetailsWidget {} +} + +glib::wrapper! { + pub struct UserDetailsWidget(ObjectSubclass) @extends gtk::Widget, gtk::Box; +} + +impl UserDetailsWidget { + fn new() -> Self { + display_add_css_provider(resource!("/components/user_details.css")); + glib::Object::new() + } + + fn set_user_name(&self, name: &str) { + self.add_css_class("user__loaded"); + self.imp().user_name.set_text(name); + } + + fn connect_bottom_edge(&self, f: F) + where + F: Fn() + 'static, + { + self.imp() + .scrolled_window + .connect_edge_reached(move |_, pos| { + if let gtk::PositionType::Bottom = pos { + f() + } + }); + } + + fn bind_user_playlists(&self, worker: Worker, store: &ListStore, on_pressed: F) + where + F: Fn(String) + Clone + 'static, + { + self.imp() + .user_playlists + .bind_model(Some(store.unsafe_store()), move |item| { + wrap_flowbox_item(item, |item: &AlbumModel| { + let f = on_pressed.clone(); + let album = AlbumWidget::for_model(item, worker.clone()); + album.connect_album_pressed(clone!(@weak item => move |_| { + f(item.uri()); + })); + album + }) + }); + } +} + +pub struct UserDetails { + model: Rc, + widget: UserDetailsWidget, +} + +impl UserDetails { + pub fn new(model: UserDetailsModel, worker: Worker) -> Self { + model.load_user_details(model.id.clone()); + + let widget = UserDetailsWidget::new(); + let model = Rc::new(model); + + widget.connect_bottom_edge(clone!(@weak model => move || { + model.load_more(); + })); + + if let Some(store) = model.get_list_store() { + widget.bind_user_playlists( + worker, + &store, + clone!(@weak model => move |uri| { + model.open_playlist(uri); + }), + ); + } + + Self { model, widget } + } + + fn update_details(&self) { + if let Some(name) = self.model.get_user_name() { + self.widget.set_user_name(&name); + } + } +} + +impl Component for UserDetails { + fn get_root_widget(&self) -> >k::Widget { + self.widget.as_ref() + } +} + +impl EventListener for UserDetails { + fn on_event(&mut self, event: &AppEvent) { + match event { + AppEvent::BrowserEvent(BrowserEvent::UserDetailsUpdated(id)) + if id == &self.model.id => + { + self.update_details(); + } + _ => {} + } + } +} diff --git a/src/app/components/user_details/user_details_model.rs b/src/app/components/user_details/user_details_model.rs new file mode 100644 index 0000000..45c63f0 --- /dev/null +++ b/src/app/components/user_details/user_details_model.rs @@ -0,0 +1,63 @@ +use std::ops::Deref; +use std::rc::Rc; + +use crate::app::models::*; +use crate::app::state::BrowserAction; +use crate::app::{ActionDispatcher, AppAction, AppModel, ListStore}; + +pub struct UserDetailsModel { + pub id: String, + app_model: Rc, + dispatcher: Box, +} + +impl UserDetailsModel { + pub fn new(id: String, app_model: Rc, dispatcher: Box) -> Self { + Self { + id, + app_model, + dispatcher, + } + } + pub fn get_user_name(&self) -> Option + '_> { + self.app_model + .map_state_opt(|s| s.browser.user_state(&self.id)?.user.as_ref()) + } + + pub fn get_list_store(&self) -> Option> + '_> { + self.app_model + .map_state_opt(|s| Some(&s.browser.user_state(&self.id)?.playlists)) + } + + pub fn load_user_details(&self, id: String) { + let api = self.app_model.get_spotify(); + self.dispatcher + .call_spotify_and_dispatch(move || async move { + api.get_user(&id) + .await + .map(|user| BrowserAction::SetUserDetails(Box::new(user)).into()) + }); + } + + pub fn open_playlist(&self, id: String) { + self.dispatcher.dispatch(AppAction::ViewPlaylist(id)); + } + + pub fn load_more(&self) -> Option<()> { + let api = self.app_model.get_spotify(); + let state = self.app_model.get_state(); + let next_page = &state.browser.user_state(&self.id)?.next_page; + + let id = next_page.data.clone(); + let batch_size = next_page.batch_size; + let offset = next_page.next_offset?; + self.dispatcher + .call_spotify_and_dispatch(move || async move { + api.get_user_playlists(&id, offset, batch_size) + .await + .map(|playlists| BrowserAction::AppendUserPlaylists(id, playlists).into()) + }); + + Some(()) + } +} diff --git a/src/app/components/user_menu/mod.rs b/src/app/components/user_menu/mod.rs new file mode 100644 index 0000000..bf8b1a6 --- /dev/null +++ b/src/app/components/user_menu/mod.rs @@ -0,0 +1,6 @@ +#[allow(clippy::module_inception)] +mod user_menu; +pub use user_menu::*; + +mod user_menu_model; +pub use user_menu_model::*; diff --git a/src/app/components/user_menu/user_menu.rs b/src/app/components/user_menu/user_menu.rs new file mode 100644 index 0000000..dc31beb --- /dev/null +++ b/src/app/components/user_menu/user_menu.rs @@ -0,0 +1,93 @@ +use gettextrs::*; +use gio::{prelude::ActionMapExt, SimpleAction, SimpleActionGroup}; +use gtk::prelude::*; +use std::rc::Rc; + +use super::UserMenuModel; +use crate::app::components::{EventListener, Settings}; +use crate::app::{state::LoginEvent, AppEvent}; + +pub struct UserMenu { + user_button: gtk::MenuButton, + model: Rc, +} + +impl UserMenu { + pub fn new( + user_button: gtk::MenuButton, + settings: Settings, + about: libadwaita::AboutWindow, + model: UserMenuModel, + ) -> Self { + let model = Rc::new(model); + + about.connect_close_request( + clone!(@weak about => @default-return gtk::Inhibit(false), move |_| { + about.set_visible(false); + gtk::Inhibit(true) + }), + ); + + let action_group = SimpleActionGroup::new(); + + action_group.add_action(&{ + let logout = SimpleAction::new("logout", None); + logout.connect_activate(clone!(@weak model => move |_, _| { + model.logout(); + })); + logout + }); + + action_group.add_action(&{ + let settings_action = SimpleAction::new("settings", None); + settings_action.connect_activate(clone!(@weak model => move |_, _| { + settings.show_self(); + })); + settings_action + }); + + action_group.add_action(&{ + let about_action = SimpleAction::new("about", None); + about_action.connect_activate(clone!(@weak about => move |_, _| { + about.present(); + })); + about_action + }); + + user_button.insert_action_group("menu", Some(&action_group)); + + Self { user_button, model } + } + + fn update_menu(&self) { + let menu = gio::Menu::new(); + // translators: This is a menu entry. + menu.append(Some(&gettext("Preferences")), Some("menu.settings")); + // translators: This is a menu entry. + menu.append(Some(&gettext("About")), Some("menu.about")); + // translators: This is a menu entry. + menu.append(Some(&gettext("Quit")), Some("app.quit")); + menu.append(Some(&gettext("Log out")), Some("menu.logout")); + + if let Some(username) = self.model.username() { + let user_menu = gio::Menu::new(); + // translators: This is a menu entry. + user_menu.append(Some(&gettext("Log out")), Some("menu.logout")); + menu.insert_section(0, Some(&username), &user_menu); + } + + self.user_button.set_menu_model(Some(&menu)); + } +} + +impl EventListener for UserMenu { + fn on_event(&mut self, event: &AppEvent) { + match event { + AppEvent::LoginEvent(LoginEvent::LoginCompleted(_)) | AppEvent::Started => { + self.update_menu(); + self.model.fetch_user_playlists(); + } + _ => {} + } + } +} diff --git a/src/app/components/user_menu/user_menu_model.rs b/src/app/components/user_menu/user_menu_model.rs new file mode 100644 index 0000000..99dd39e --- /dev/null +++ b/src/app/components/user_menu/user_menu_model.rs @@ -0,0 +1,52 @@ +use crate::api::clear_user_cache; +use crate::app::credentials::Credentials; +use crate::app::state::{LoginAction, PlaybackAction}; +use crate::app::{ActionDispatcher, AppModel}; +use std::ops::Deref; +use std::rc::Rc; + +pub struct UserMenuModel { + app_model: Rc, + dispatcher: Box, +} + +impl UserMenuModel { + pub fn new(app_model: Rc, dispatcher: Box) -> Self { + Self { + app_model, + dispatcher, + } + } + + pub fn username(&self) -> Option + '_> { + self.app_model + .map_state_opt(|s| s.logged_user.user.as_ref()) + } + + pub fn logout(&self) { + self.dispatcher.dispatch(PlaybackAction::Stop.into()); + self.dispatcher.dispatch_async(Box::pin(async { + let _ = Credentials::logout().await; + let _ = clear_user_cache().await; + Some(LoginAction::Logout.into()) + })); + } + + pub fn fetch_user_playlists(&self) { + let api = self.app_model.get_spotify(); + if let Some(current_user) = self.username() { + let current_user = current_user.clone(); + self.dispatcher + .call_spotify_and_dispatch(move || async move { + api.get_saved_playlists(0, 30).await.map(|playlists| { + let summaries = playlists + .into_iter() + .filter(|p| p.owner.id == current_user) + .map(|p| p.into()) + .collect(); + LoginAction::SetUserPlaylists(summaries).into() + }) + }); + } + } +} diff --git a/src/app/components/utils.rs b/src/app/components/utils.rs new file mode 100644 index 0000000..8d36629 --- /dev/null +++ b/src/app/components/utils.rs @@ -0,0 +1,165 @@ +use gtk::prelude::*; +use std::cell::Cell; +use std::rc::Rc; +use std::time::Duration; + +#[derive(Clone)] +pub struct Clock { + interval_ms: u32, + source: Rc>>, +} + +impl Default for Clock { + fn default() -> Self { + Self::new(1000) + } +} + +impl std::fmt::Debug for Clock { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Clock") + .field("interval_ms", &self.interval_ms) + .finish() + } +} + +impl Clock { + pub fn new(interval_ms: u32) -> Self { + Self { + interval_ms, + source: Rc::new(Cell::new(None)), + } + } + + pub fn start(&self, tick: F) { + let new_source = Some(glib::timeout_add_local( + Duration::from_millis(self.interval_ms.into()), + move || { + tick(); + glib::Continue(true) + }, + )); + if let Some(previous_source) = self.source.replace(new_source) { + previous_source.remove(); + } + } + + pub fn stop(&self) { + let new_source = None; + if let Some(previous_source) = self.source.replace(new_source) { + previous_source.remove(); + } + } +} + +#[derive(Clone)] +pub struct Debouncer(Rc>>); + +impl Debouncer { + pub fn new() -> Self { + Self(Rc::new(Cell::new(None))) + } + + pub fn debounce(&self, interval_ms: u32, f: F) { + let source_clone = Rc::downgrade(&self.0); + let new_source = + glib::timeout_add_local(Duration::from_millis(interval_ms.into()), move || { + f(); + if let Some(cell) = source_clone.upgrade() { + cell.set(None); + } + glib::Continue(false) + }); + if let Some(previous_source) = self.0.replace(Some(new_source)) { + previous_source.remove(); + } + } +} + +pub struct Animator { + progress: Rc>, + ease_fn: EasingFn, +} + +pub type AnimatorDefault = Animator f64>; + +impl AnimatorDefault { + fn ease_in_out(x: f64) -> f64 { + match x { + x if x < 0.5 => 0.5 * 2f64.powf(20.0 * x - 10.0), + _ => 0.5 * (2.0 - 2f64.powf(-20.0 * x + 10.0)), + } + } + + pub fn ease_in_out_animator() -> AnimatorDefault { + Animator { + progress: Rc::new(Cell::new(0)), + ease_fn: Self::ease_in_out, + } + } +} + +impl Animator +where + EasingFn: 'static + Copy + Fn(f64) -> f64, +{ + pub fn animate bool + 'static>(&self, steps: u16, f: F) { + self.progress.set(0); + let ease_fn = self.ease_fn; + + let progress = Rc::downgrade(&self.progress); + glib::timeout_add_local(Duration::from_millis(16), move || { + let mut continue_ = false; + if let Some(progress) = progress.upgrade() { + let step = progress.get(); + continue_ = step < steps; + if continue_ { + progress.set(step + 1); + let p = ease_fn(step as f64 / steps as f64); + continue_ = f(p); + } + } + glib::Continue(continue_) + }); + } +} + +pub fn ancestor(widget: &Current) -> Option +where + Current: IsA, + Ancestor: IsA, +{ + widget.parent().and_then(|p| { + p.clone() + .downcast::() + .ok() + .or_else(|| ancestor(&p)) + }) +} + +pub fn wrap_flowbox_item< + Model: glib::IsA, + Widget: gtk::glib::IsA, + F: Fn(&Model) -> Widget, +>( + item: &glib::Object, + f: F, +) -> gtk::Widget { + let item = item.downcast_ref::().unwrap(); + let widget = f(item); + let child = gtk::FlowBoxChild::new(); + child.set_child(Some(&widget)); + child.upcast::() +} + +pub fn format_duration(duration: f64) -> String { + let seconds = (duration / 1000.0) as i32; + let hours = seconds.div_euclid(3600); + let minutes = seconds.div_euclid(60).rem_euclid(60); + let seconds = seconds.rem_euclid(60); + if hours > 0 { + format!("{hours}∶{minutes:02}∶{seconds:02}") + } else { + format!("{minutes}∶{seconds:02}") + } +} diff --git a/src/app/components/window/mod.rs b/src/app/components/window/mod.rs new file mode 100644 index 0000000..04538b4 --- /dev/null +++ b/src/app/components/window/mod.rs @@ -0,0 +1,90 @@ +use gtk::prelude::*; +use std::cell::RefCell; +use std::rc::Rc; + +use crate::app::components::EventListener; +use crate::app::{AppEvent, AppModel}; +use crate::settings::WindowGeometry; + +thread_local! { + static WINDOW_GEOMETRY: RefCell = const { RefCell::new(WindowGeometry { + width: 0, height: 0, is_maximized: false + }) }; +} + +pub struct MainWindow { + initial_window_geometry: WindowGeometry, + window: libadwaita::ApplicationWindow, +} + +impl MainWindow { + pub fn new( + initial_window_geometry: WindowGeometry, + app_model: Rc, + window: libadwaita::ApplicationWindow, + ) -> Self { + window.connect_close_request( + clone!(@weak app_model => @default-return gtk::Inhibit(false), move |window| { + let state = app_model.get_state(); + if state.playback.is_playing() { + window.set_visible(false); + gtk::Inhibit(true) + } else { + gtk::Inhibit(false) + } + }), + ); + + window.connect_default_height_notify(Self::save_window_geometry); + window.connect_default_width_notify(Self::save_window_geometry); + window.connect_maximized_notify(Self::save_window_geometry); + + window.connect_unrealize(|_| { + debug!("saving geometry"); + WINDOW_GEOMETRY.with(|g| g.borrow().save()); + }); + + Self { + initial_window_geometry, + window, + } + } + + fn start(&self) { + self.window.set_default_size( + self.initial_window_geometry.width, + self.initial_window_geometry.height, + ); + if self.initial_window_geometry.is_maximized { + self.window.maximize(); + } + self.window.present(); + } + + fn raise(&self) { + self.window.present(); + } + + fn save_window_geometry(window: &W) { + let (width, height) = window.default_size(); + let is_maximized = window.is_maximized(); + WINDOW_GEOMETRY.with(|g| { + let mut g = g.borrow_mut(); + g.is_maximized = is_maximized; + if !is_maximized { + g.width = width; + g.height = height; + } + }); + } +} + +impl EventListener for MainWindow { + fn on_event(&mut self, event: &AppEvent) { + match event { + AppEvent::Started => self.start(), + AppEvent::Raised => self.raise(), + _ => {} + } + } +} diff --git a/src/app/credentials.rs b/src/app/credentials.rs new file mode 100644 index 0000000..0d315b3 --- /dev/null +++ b/src/app/credentials.rs @@ -0,0 +1,77 @@ +use secret_service::{EncryptionType, Error, SecretService}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, time::SystemTime}; + +static SPOT_ATTR: &str = "spot_credentials"; + +// I'm not sure this is the right way to make credentials identifiable, but hey, it works +fn make_attributes() -> HashMap<&'static str, &'static str> { + let mut attributes = HashMap::new(); + attributes.insert(SPOT_ATTR, "yes"); + attributes +} + +// A (statically accessed) wrapper around the DBUS Secret Service +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct Credentials { + pub username: String, + pub password: String, + pub token: String, + pub token_expiry_time: Option, +} + +impl Credentials { + pub fn token_expired(&self) -> bool { + match self.token_expiry_time { + Some(v) => SystemTime::now() > v, + None => true, + } + } + + pub async fn retrieve() -> Result { + let service = SecretService::connect(EncryptionType::Dh).await?; + let collection = service.get_default_collection().await?; + if collection.is_locked().await? { + collection.unlock().await?; + } + let items = collection.search_items(make_attributes()).await?; + let item = items.first().ok_or(Error::NoResult)?.get_secret().await?; + serde_json::from_slice(&item).map_err(|_| Error::Unavailable) + } + + // Try to clear the credentials + pub async fn logout() -> Result<(), Error> { + let service = SecretService::connect(EncryptionType::Dh).await?; + let collection = service.get_default_collection().await?; + if !collection.is_locked().await? { + let result = collection.search_items(make_attributes()).await?; + let item = result.first().ok_or(Error::NoResult)?; + item.delete().await + } else { + warn!("Keyring is locked -- not clearing credentials"); + Ok(()) + } + } + + pub async fn save(&self) -> Result<(), Error> { + let service = SecretService::connect(EncryptionType::Dh).await?; + let collection = service.get_default_collection().await?; + if collection.is_locked().await? { + collection.unlock().await?; + } + // We simply write our stuct as JSON and send it + info!("Saving credentials"); + let encoded = serde_json::to_vec(&self).unwrap(); + collection + .create_item( + "Spotify Credentials", + make_attributes(), + &encoded, + true, + "text/plain", + ) + .await?; + info!("Saved credentials"); + Ok(()) + } +} diff --git a/src/app/dispatch.rs b/src/app/dispatch.rs new file mode 100644 index 0000000..fb349dd --- /dev/null +++ b/src/app/dispatch.rs @@ -0,0 +1,128 @@ +use futures::channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender}; +use futures::future::BoxFuture; +use futures::future::Future; +use futures::stream::StreamExt; +use std::pin::Pin; + +use super::AppAction; + +// A wrapper around an MPSC sender to send AppActions synchronously or asynchronously +// It is a trait because I guess I wanted to be able to stub it, but see how that went... +pub trait ActionDispatcher { + fn dispatch(&self, action: AppAction); + fn dispatch_many(&self, actions: Vec); + fn dispatch_async(&self, action: BoxFuture<'static, Option>); + fn dispatch_many_async(&self, actions: BoxFuture<'static, Vec>); + // Can't have impl Clone easily so there you go + fn box_clone(&self) -> Box; +} + +#[derive(Clone)] +pub struct ActionDispatcherImpl { + sender: UnboundedSender, + worker: Worker, +} + +impl ActionDispatcherImpl { + pub fn new(sender: UnboundedSender, worker: Worker) -> Self { + Self { sender, worker } + } +} + +impl ActionDispatcher for ActionDispatcherImpl { + fn dispatch(&self, action: AppAction) { + self.sender.unbounded_send(action).unwrap(); + } + + fn dispatch_many(&self, actions: Vec) { + for action in actions.into_iter() { + self.sender.unbounded_send(action).unwrap(); + } + } + + fn dispatch_async(&self, action: BoxFuture<'static, Option>) { + let clone = self.sender.clone(); + self.worker.send_task(async move { + if let Some(action) = action.await { + clone.unbounded_send(action).unwrap(); + } + }); + } + + fn dispatch_many_async(&self, actions: BoxFuture<'static, Vec>) { + let clone = self.sender.clone(); + self.worker.send_task(async move { + for action in actions.await.into_iter() { + clone.unbounded_send(action).unwrap(); + } + }); + } + + fn box_clone(&self) -> Box { + Box::new(self.clone()) + } +} + +// Funky name for a mere wrapper around an MPSC send/recv pair +pub struct DispatchLoop { + receiver: UnboundedReceiver, + sender: UnboundedSender, +} + +impl DispatchLoop { + pub fn new() -> Self { + let (sender, receiver) = unbounded::(); + Self { receiver, sender } + } + + pub fn make_dispatcher(&self) -> UnboundedSender { + self.sender.clone() + } + + pub async fn attach(self, mut handler: impl FnMut(AppAction)) { + self.receiver + .for_each(|action| { + handler(action); + async {} + }) + .await; + } +} + +pub type FutureTask = Pin + Send>>; +pub type FutureLocalTask = Pin>>; + +// The Worker (see below) is a glorified way to send an async task to the GLib(rs) future executor +pub fn spawn_task_handler(context: &glib::MainContext) -> Worker { + let (future_local_sender, future_local_receiver) = unbounded::(); + context.spawn_local_with_priority( + glib::source::PRIORITY_DEFAULT_IDLE, + future_local_receiver.for_each(|t| t), + ); + + let (future_sender, future_receiver) = unbounded::(); + context.spawn_with_priority( + glib::source::PRIORITY_DEFAULT_IDLE, + future_receiver.for_each(|t| t), + ); + + Worker(future_local_sender, future_sender) +} + +// Again, fancy name for an MPSC sender +// Actually two of them, in case you need to send local futures (no Send needed) +#[derive(Clone)] +pub struct Worker( + UnboundedSender, + UnboundedSender, +); + +impl Worker { + pub fn send_local_task + 'static>(&self, task: T) -> Option<()> { + self.0.unbounded_send(Box::pin(task)).ok() + } + + pub fn send_task + Send + 'static>(&self, task: T) -> Option<()> { + self.1.unbounded_send(Box::pin(task)).ok() + } +} diff --git a/src/app/list_store.rs b/src/app/list_store.rs new file mode 100644 index 0000000..93ad6b0 --- /dev/null +++ b/src/app/list_store.rs @@ -0,0 +1,113 @@ +use gio::prelude::*; +use glib::clone::{Downgrade, Upgrade}; +use std::iter::Iterator; +use std::marker::PhantomData; + +// A typed wrapper around a GIO ListStore +pub struct ListStore { + store: gio::ListStore, + _marker: PhantomData, +} + +pub struct WeakListStore { + store: ::Weak, + _marker: PhantomData, +} + +impl ListStore +where + GType: IsA, +{ + pub fn new() -> Self { + Self { + store: gio::ListStore::new(GType::static_type()), + _marker: PhantomData, + } + } + + pub fn unsafe_store(&self) -> &gio::ListStore { + &self.store + } + + pub fn prepend(&mut self, elements: impl Iterator) { + let upcast_vec: Vec = elements.map(|e| e.upcast::()).collect(); + self.store.splice(0, 0, &upcast_vec[..]); + } + + pub fn extend(&mut self, elements: impl Iterator) { + let upcast_vec: Vec = elements.map(|e| e.upcast::()).collect(); + self.store.splice(self.store.n_items(), 0, &upcast_vec[..]); + } + + pub fn replace_all(&mut self, elements: impl Iterator) { + let upcast_vec: Vec = elements.map(|e| e.upcast::()).collect(); + self.store.splice(0, self.store.n_items(), &upcast_vec[..]); + } + + pub fn insert(&mut self, position: u32, element: GType) { + self.store.insert(position, &element); + } + + pub fn remove(&mut self, position: u32) { + self.store.remove(position); + } + + pub fn get(&self, index: u32) -> GType { + self.store.item(index).unwrap().downcast::().unwrap() + } + + pub fn iter(&self) -> impl Iterator + '_ { + let store = &self.store; + let count = store.n_items(); + (0..count).map(move |i| self.get(i)) + } + + pub fn len(&self) -> usize { + self.store.n_items() as usize + } + + // Quick and dirty comparison between the list store and a slice of object that can be compared + // with the contents of the store using some function F. + // Not so great but eh + pub fn eq(&self, other: &[O], comparison: F) -> bool + where + F: Fn(>ype, &O) -> bool, + { + self.len() == other.len() + && self + .iter() + .zip(other.iter()) + .all(|(left, right)| comparison(&left, right)) + } +} + +impl Clone for ListStore { + fn clone(&self) -> Self { + Self { + store: self.store.clone(), + _marker: PhantomData, + } + } +} + +impl Downgrade for ListStore { + type Weak = WeakListStore; + + fn downgrade(&self) -> Self::Weak { + Self::Weak { + store: Downgrade::downgrade(&self.store), + _marker: PhantomData, + } + } +} + +impl Upgrade for WeakListStore { + type Strong = ListStore; + + fn upgrade(&self) -> Option { + Some(Self::Strong { + store: self.store.upgrade()?, + _marker: PhantomData, + }) + } +} diff --git a/src/app/loader.rs b/src/app/loader.rs new file mode 100644 index 0000000..eacaffa --- /dev/null +++ b/src/app/loader.rs @@ -0,0 +1,100 @@ +use crate::api::cache::*; +use gdk_pixbuf::traits::PixbufLoaderExt; +use gdk_pixbuf::{Pixbuf, PixbufLoader}; +use isahc::config::Configurable; +use isahc::{AsyncBody, AsyncReadResponseExt, HttpClient, Response}; +use std::collections::hash_map::DefaultHasher; +use std::hash::Hasher; +use std::io::{Error, ErrorKind, Write}; + +// A wrapper to be able to implement the Write trait on a PixbufLoader +struct LocalPixbufLoader<'a>(&'a PixbufLoader); + +impl<'a> Write for LocalPixbufLoader<'a> { + fn write(&mut self, buf: &[u8]) -> Result { + self.0 + .write(buf) + .map_err(|e| Error::new(ErrorKind::Other, format!("glib error: {e}")))?; + Ok(buf.len()) + } + + fn flush(&mut self) -> Result<(), Error> { + self.0 + .close() + .map_err(|e| Error::new(ErrorKind::Other, format!("glib error: {e}")))?; + Ok(()) + } +} + +// A helper to load remote images, with simple cache management +pub struct ImageLoader { + cache: CacheManager, +} + +impl ImageLoader { + pub fn new() -> Self { + Self { + cache: CacheManager::for_dir("spot/img").unwrap(), + } + } + + // Downloaded images are simply named [hash of url].[file extension] + fn resource_for(url: &str, ext: &str) -> String { + let mut hasher = DefaultHasher::new(); + hasher.write(url.as_bytes()); + let hashed = hasher.finish().to_string(); + hashed + "." + ext + } + + async fn get_image(url: &str) -> Option> { + let mut builder = HttpClient::builder(); + if cfg!(debug_assertions) { + builder = builder.ssl_options(isahc::config::SslOption::DANGER_ACCEPT_INVALID_CERTS); + } + let client = builder.build().unwrap(); + client.get_async(url).await.ok() + } + + pub async fn load_remote( + &self, + url: &str, + ext: &str, + width: i32, + height: i32, + ) -> Option { + let resource = Self::resource_for(url, ext); + let pixbuf_loader = PixbufLoader::new(); + pixbuf_loader.set_size(width, height); + let mut loader = LocalPixbufLoader(&pixbuf_loader); + + // Try to read from cache first, ignoring possible expiry + match self + .cache + .read_cache_file(&resource[..], CachePolicy::IgnoreExpiry) + .await + { + // Write content of cache file to the pixbuf loader if the cache contained something + Ok(CacheFile::Fresh(buffer)) => { + loader.write_all(&buffer[..]).ok()?; + } + // Otherwise, get image over HTTP + _ => { + if let Some(mut resp) = Self::get_image(url).await { + let mut buffer = vec![]; + // Copy the image to a buffer... + resp.copy_to(&mut buffer).await.ok()?; + // ... copy the buffer to the loader... + loader.write_all(&buffer[..]).ok()?; + // ... but also save that buffer to cache + self.cache + .write_cache_file(&resource[..], &buffer[..], CacheExpiry::Never) + .await + .ok()?; + } + } + }; + + pixbuf_loader.close().ok()?; + pixbuf_loader.pixbuf() + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..14e1fec --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,271 @@ +use crate::api::CachedSpotifyClient; +use crate::settings::SpotSettings; +use futures::channel::mpsc::UnboundedSender; +use std::rc::Rc; +use std::sync::Arc; + +pub mod dispatch; +pub use dispatch::{ActionDispatcher, ActionDispatcherImpl, DispatchLoop, Worker}; + +pub mod components; +use components::*; + +pub mod models; + +mod list_store; +pub use list_store::*; + +pub mod state; +pub use state::{AppAction, AppEvent, AppModel, AppState, BrowserAction, BrowserEvent}; + +mod batch_loader; +pub use batch_loader::*; + +pub mod credentials; +pub mod loader; + +pub mod rng; +pub use rng::LazyRandomIndex; + +// Where all the app logic happens +pub struct App { + settings: SpotSettings, + // The builder instance used to properly configure all the widgets created at startup + builder: gtk::Builder, + // All the "components" that will be notified of things happening throughout the app + components: Vec>, + // Holds the app state + model: Rc, + // Allows sending actions that are handled by the model above + sender: UnboundedSender, + worker: Worker, +} + +impl App { + pub fn new( + settings: SpotSettings, + builder: gtk::Builder, + sender: UnboundedSender, + worker: Worker, + ) -> Self { + let state = AppState::new(); + let spotify_client = Arc::new(CachedSpotifyClient::new()); + let model = Rc::new(AppModel::new(state, spotify_client)); + + // Non widget components + let components: Vec> = vec![ + App::make_player_notifier( + Rc::clone(&model), + &settings, + Box::new(ActionDispatcherImpl::new(sender.clone(), worker.clone())), + sender.clone(), + ), + App::make_dbus(Rc::clone(&model), sender.clone()), + ]; + + Self { + settings, + builder, + components, + model, + sender, + worker, + } + } + + fn add_ui_components(&mut self) { + // Most components will need some or all of these to work + // ie some way to retrieve widgets + let builder = &self.builder; + // ...some way to read the app state + let model = &self.model; + // ...some way to handle various asynchronous tasks + let worker = &self.worker; + // ...some (basic) way to send actions that will change the app state + let sender = &self.sender; + // ...ALSO some way to send actions, but more conveniently + let dispatcher = Box::new(ActionDispatcherImpl::new(sender.clone(), worker.clone())); + + // All components that will be available initially + let mut components: Vec> = vec![ + App::make_window(&self.settings, builder, Rc::clone(model)), + App::make_selection_toolbar(builder, Rc::clone(model), dispatcher.box_clone()), + App::make_playback( + builder, + Rc::clone(model), + dispatcher.box_clone(), + worker.clone(), + ), + App::make_login(builder, dispatcher.box_clone(), worker.clone()), + App::make_navigation( + builder, + Rc::clone(model), + dispatcher.box_clone(), + worker.clone(), + ), + App::make_search_button(builder, dispatcher.box_clone()), + App::make_user_menu(builder, Rc::clone(model), dispatcher), + App::make_notification(builder), + ]; + + self.components.append(&mut components); + } + + // A component that listens to what's happening in the app, and translates it for the actual player + fn make_player_notifier( + app_model: Rc, + settings: &SpotSettings, + dispatcher: Box, + sender: UnboundedSender, + ) -> Box { + let api = app_model.get_spotify(); + Box::new(PlayerNotifier::new( + app_model, + dispatcher, + // Either communications with the librespot player + crate::player::start_player_service(settings.player_settings.clone(), sender.clone()), + // or with a Spotify Connect device + crate::connect::start_connect_server(api, sender), + )) + } + + // A component to handle anything DBUS related + fn make_dbus( + app_model: Rc, + sender: UnboundedSender, + ) -> Box { + Box::new(crate::dbus::start_dbus_server(app_model, sender)) + } + + fn make_window( + settings: &SpotSettings, + builder: >k::Builder, + app_model: Rc, + ) -> Box { + let window: libadwaita::ApplicationWindow = builder.object("window").unwrap(); + Box::new(MainWindow::new(settings.window.clone(), app_model, window)) + } + + fn make_navigation( + builder: >k::Builder, + app_model: Rc, + dispatcher: Box, + worker: Worker, + ) -> Box { + let leaflet: libadwaita::Leaflet = builder.object("leaflet").unwrap(); + let navigation_stack: gtk::Stack = builder.object("navigation_stack").unwrap(); + let home_listbox: gtk::ListBox = builder.object("home_listbox").unwrap(); + let model = NavigationModel::new(Rc::clone(&app_model), dispatcher.box_clone()); + // This is where components that are not created initially will be assembled + let screen_factory = ScreenFactory::new( + Rc::clone(&app_model), + dispatcher.box_clone(), + worker, + leaflet.clone(), + ); + Box::new(Navigation::new( + model, + leaflet, + navigation_stack, + home_listbox, + screen_factory, + )) + } + + fn make_login( + builder: >k::Builder, + dispatcher: Box, + worker: Worker, + ) -> Box { + let parent: gtk::Window = builder.object("window").unwrap(); + let model = LoginModel::new(dispatcher, worker); + Box::new(Login::new(parent, model)) + } + + fn make_selection_toolbar( + builder: >k::Builder, + app_model: Rc, + dispatcher: Box, + ) -> Box { + Box::new(SelectionToolbar::new( + SelectionToolbarModel::new(app_model, dispatcher), + builder.object("selection_toolbar").unwrap(), + )) + } + + fn make_playback( + builder: >k::Builder, + app_model: Rc, + dispatcher: Box, + worker: Worker, + ) -> Box { + let model = PlaybackModel::new(app_model, dispatcher); + Box::new(PlaybackControl::new( + model, + builder.object("playback").unwrap(), + worker, + )) + } + + fn make_search_button( + builder: >k::Builder, + dispatcher: Box, + ) -> Box { + let search_button: gtk::Button = builder.object("search_button").unwrap(); + let model = SearchBarModel(dispatcher); + Box::new(SearchButton::new(model, search_button)) + } + + fn make_user_menu( + builder: >k::Builder, + app_model: Rc, + dispatcher: Box, + ) -> Box { + let parent: gtk::Window = builder.object("window").unwrap(); + let settings_model = SettingsModel::new(app_model.clone(), dispatcher.box_clone()); + let settings = Settings::new(parent, settings_model); + + let button: gtk::MenuButton = builder.object("user").unwrap(); + let about: libadwaita::AboutWindow = builder.object("about").unwrap(); + let model = UserMenuModel::new(app_model, dispatcher); + let user_menu = UserMenu::new(button, settings, about, model); + Box::new(user_menu) + } + + fn make_notification(builder: >k::Builder) -> Box { + let toast_overlay: libadwaita::ToastOverlay = builder.object("main").unwrap(); + Box::new(Notification::new(toast_overlay)) + } + + // Main handler called in a loop + fn handle(&mut self, action: AppAction) { + let starting = matches!(&action, &AppAction::Start); + + // Update the state based on an incoming action + // and obtain events representing what that mutation entailed... + let events = self.model.update_state(action); + + // (AppAction::Start is special and is used to setup the initial components) + if !events.is_empty() && starting { + self.add_ui_components(); + } + + // ...and notify every component that we know. + // They'll be responsible for passing down these events, if they feel like it. + for event in events.iter() { + for component in self.components.iter_mut() { + component.on_event(event); + } + } + } + + // Here is the loop + pub async fn attach(mut self, dispatch_loop: DispatchLoop) { + let app = &mut self; + dispatch_loop + .attach(move |action| { + app.handle(action); + }) + .await; + } +} diff --git a/src/app/models/album_model.rs b/src/app/models/album_model.rs new file mode 100644 index 0000000..ce88606 --- /dev/null +++ b/src/app/models/album_model.rs @@ -0,0 +1,73 @@ +#![allow(clippy::all)] + +use gio::prelude::*; +use glib::subclass::prelude::*; +use glib::Properties; + +// UI model! +// Despite the name, it can represent a playlist as well +glib::wrapper! { + pub struct AlbumModel(ObjectSubclass); +} + +impl AlbumModel { + pub fn new( + artist: &String, + album: &String, + year: Option, + cover: Option<&String>, + uri: &String, + ) -> AlbumModel { + let year = &year.unwrap_or(0); + glib::Object::builder() + .property("artist", artist) + .property("album", album) + .property("year", year) + .property("cover", &cover) + .property("uri", uri) + .build() + } +} + +mod imp { + + use super::*; + + use std::cell::{Cell, RefCell}; + + #[derive(Default, Properties)] + #[properties(wrapper_type = super::AlbumModel)] + pub struct AlbumModel { + #[property(get, set)] + album: RefCell, + #[property(get, set)] + artist: RefCell, + #[property(get, set)] + year: Cell, + #[property(get, set)] + cover: RefCell>, + #[property(get, set)] + uri: RefCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for AlbumModel { + const NAME: &'static str = "AlbumModel"; + type Type = super::AlbumModel; + type ParentType = glib::Object; + } + + impl ObjectImpl for AlbumModel { + fn properties() -> &'static [glib::ParamSpec] { + Self::derived_properties() + } + + fn set_property(&self, id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + self.derived_set_property(id, value, pspec) + } + + fn property(&self, id: usize, pspec: &glib::ParamSpec) -> glib::Value { + self.derived_property(id, pspec) + } + } +} diff --git a/src/app/models/artist_model.rs b/src/app/models/artist_model.rs new file mode 100644 index 0000000..b413fda --- /dev/null +++ b/src/app/models/artist_model.rs @@ -0,0 +1,58 @@ +#![allow(clippy::all)] + +use gio::prelude::*; +use glib::subclass::prelude::*; +use glib::Properties; + +// UI model! +glib::wrapper! { + pub struct ArtistModel(ObjectSubclass); +} + +impl ArtistModel { + pub fn new(artist: &str, image: &Option, id: &str) -> ArtistModel { + glib::Object::builder() + .property("artist", &artist) + .property("image", image) + .property("id", &id) + .build() + } +} + +mod imp { + + use super::*; + use std::cell::RefCell; + + #[derive(Default, Properties)] + #[properties(wrapper_type = super::ArtistModel)] + pub struct ArtistModel { + #[property(get, set)] + artist: RefCell, + #[property(get, set)] + image: RefCell>, + #[property(get, set)] + id: RefCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for ArtistModel { + const NAME: &'static str = "ArtistModel"; + type Type = super::ArtistModel; + type ParentType = glib::Object; + } + + impl ObjectImpl for ArtistModel { + fn properties() -> &'static [glib::ParamSpec] { + Self::derived_properties() + } + + fn set_property(&self, id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + self.derived_set_property(id, value, pspec) + } + + fn property(&self, id: usize, pspec: &glib::ParamSpec) -> glib::Value { + self.derived_property(id, pspec) + } + } +} diff --git a/src/app/models/main.rs b/src/app/models/main.rs new file mode 100644 index 0000000..27da151 --- /dev/null +++ b/src/app/models/main.rs @@ -0,0 +1,313 @@ +use std::{ + hash::{Hash, Hasher}, + str::FromStr, +}; + +use crate::app::SongsSource; + +// A batch of whatever +#[derive(Clone, Copy, Debug)] +pub struct Batch { + // What offset does the batch start at + pub offset: usize, + // How many elements + pub batch_size: usize, + // Total number of elements if we had all batches + pub total: usize, +} + +impl Batch { + pub fn first_of_size(batch_size: usize) -> Self { + Self { + offset: 0, + batch_size, + total: 0, + } + } + + pub fn next(self) -> Option { + let Self { + offset, + batch_size, + total, + } = self; + + Some(Self { + offset: offset + batch_size, + batch_size, + total, + }) + .filter(|b| b.offset < total) + } +} + +// "Something"Ref models usually boil down to an ID/url + a display name + +#[derive(Clone, Debug)] +pub struct UserRef { + pub id: String, + pub display_name: String, +} + +#[derive(Clone, Debug)] +pub struct ArtistRef { + pub id: String, + pub name: String, +} + +#[derive(Clone, Debug)] +pub struct AlbumRef { + pub id: String, + pub name: String, +} + +#[derive(Clone, Debug)] +pub struct SearchResults { + pub albums: Vec, + pub artists: Vec, +} + +#[derive(Clone, Debug)] +pub struct AlbumDescription { + pub id: String, + pub title: String, + pub artists: Vec, + pub release_date: Option, + pub art: Option, + pub songs: SongBatch, + pub is_liked: bool, +} + +impl AlbumDescription { + pub fn artists_name(&self) -> String { + self.artists + .iter() + .map(|a| a.name.to_string()) + .collect::>() + .join(", ") + } + + pub fn year(&self) -> Option { + self.release_date + .as_ref() + .and_then(|date| date.split('-').next()) + .and_then(|y| u32::from_str(y).ok()) + } +} + +#[derive(Clone, Debug)] +pub struct AlbumFullDescription { + pub description: AlbumDescription, + pub release_details: AlbumReleaseDetails, +} + +#[derive(Clone, Debug)] +pub struct AlbumReleaseDetails { + pub label: String, + pub copyright_text: String, + pub total_tracks: usize, +} + +#[derive(Clone, Debug)] +pub struct PlaylistDescription { + pub id: String, + pub title: String, + pub art: Option, + pub songs: SongBatch, + pub owner: UserRef, +} + +#[derive(Clone, Copy, Debug)] +pub enum ConnectDeviceKind { + Phone, + Computer, + Speaker, + Other, +} + +#[derive(Clone, Debug)] +pub struct ConnectDevice { + pub id: String, + pub label: String, + pub kind: ConnectDeviceKind, +} + +#[derive(Clone, Debug)] +pub struct PlaylistSummary { + pub id: String, + pub title: String, +} + +#[derive(Clone, Debug)] +pub struct SongDescription { + pub id: String, + pub track_number: Option, + pub uri: String, + pub title: String, + pub artists: Vec, + pub album: AlbumRef, + pub duration: u32, + pub art: Option, +} + +impl SongDescription { + pub fn artists_name(&self) -> String { + self.artists + .iter() + .map(|a| a.name.to_string()) + .collect::>() + .join(", ") + } +} + +impl Hash for SongDescription { + fn hash(&self, state: &mut H) { + self.id.hash(state); + } +} + +#[derive(Copy, Clone, Default)] +pub struct SongState { + pub is_playing: bool, + pub is_selected: bool, +} + +// A batch of SONGS +#[derive(Debug, Clone)] +pub struct SongBatch { + pub songs: Vec, + pub batch: Batch, +} + +impl SongBatch { + pub fn empty() -> Self { + Self { + songs: vec![], + batch: Batch::first_of_size(1), + } + } + + pub fn resize(self, batch_size: usize) -> Vec { + let SongBatch { mut songs, batch } = self; + // Growing a batch is easy... + if batch_size > batch.batch_size { + let new_batch = Batch { + batch_size, + ..batch + }; + vec![Self { + songs, + batch: new_batch, + }] + // Shrinking is not! + // We have to split the batch in multiple batches + } else { + let n = songs.len(); + let iter_count = (n + batch_size - 1) / batch_size; + (0..iter_count) + .map(|i| { + let offset = batch.offset + i * batch_size; + let new_batch = Batch { + offset, + total: batch.total, + batch_size, + }; + let drain_upper = usize::min(batch_size, songs.len()); + let new_songs = songs.drain(0..drain_upper).collect(); + Self { + songs: new_songs, + batch: new_batch, + } + }) + .collect() + } + } +} + +#[derive(Clone, Debug)] +pub struct ArtistDescription { + pub id: String, + pub name: String, + pub albums: Vec, + pub top_tracks: Vec, +} + +#[derive(Clone, Debug)] +pub struct ArtistSummary { + pub id: String, + pub name: String, + pub photo: Option, +} + +#[derive(Clone, Debug)] +pub struct UserDescription { + pub id: String, + pub name: String, + pub playlists: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RepeatMode { + Song, + Playlist, + None, +} + +#[derive(Clone, Debug)] +pub struct ConnectPlayerState { + pub is_playing: bool, + #[allow(dead_code)] + pub source: Option, + pub current_song_id: Option, + pub progress_ms: u32, + pub repeat: RepeatMode, + pub shuffle: bool, +} + +impl Default for ConnectPlayerState { + fn default() -> Self { + Self { + is_playing: false, + source: None, + current_song_id: None, + progress_ms: 0, + repeat: RepeatMode::None, + shuffle: false, + } + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + fn song(id: &str) -> SongDescription { + SongDescription { + id: id.to_string(), + uri: "".to_string(), + title: "Title".to_string(), + artists: vec![], + album: AlbumRef { + id: "".to_string(), + name: "".to_string(), + }, + duration: 1000, + art: None, + track_number: None, + } + } + + #[test] + fn resize_batch() { + let batch = SongBatch { + songs: vec![song("1"), song("2"), song("3"), song("4")], + batch: Batch::first_of_size(4), + }; + + let batches = batch.resize(2); + assert_eq!(batches.len(), 2); + assert_eq!(&batches.get(0).unwrap().songs.get(0).unwrap().id, "1"); + assert_eq!(&batches.get(1).unwrap().songs.get(0).unwrap().id, "3"); + } +} diff --git a/src/app/models/mod.rs b/src/app/models/mod.rs new file mode 100644 index 0000000..e213455 --- /dev/null +++ b/src/app/models/mod.rs @@ -0,0 +1,68 @@ +// Domain models +mod main; +pub use main::*; + +// UI models (GObject) +mod songs; +pub use songs::*; + +mod album_model; +pub use album_model::*; + +mod artist_model; +pub use artist_model::*; + +impl From<&AlbumDescription> for AlbumModel { + fn from(album: &AlbumDescription) -> Self { + AlbumModel::new( + &album.artists_name(), + &album.title, + album.year(), + album.art.as_ref(), + &album.id, + ) + } +} + +impl From for AlbumModel { + fn from(album: AlbumDescription) -> Self { + Self::from(&album) + } +} + +impl From<&PlaylistDescription> for AlbumModel { + fn from(playlist: &PlaylistDescription) -> Self { + AlbumModel::new( + &playlist.owner.display_name, + &playlist.title, + // Playlists do not have their released date since they are expected to be updated anytime. + None, + playlist.art.as_ref(), + &playlist.id, + ) + } +} + +impl From for PlaylistSummary { + fn from(PlaylistDescription { id, title, .. }: PlaylistDescription) -> Self { + Self { id, title } + } +} + +impl From for AlbumModel { + fn from(playlist: PlaylistDescription) -> Self { + Self::from(&playlist) + } +} + +impl From for SongModel { + fn from(song: SongDescription) -> Self { + SongModel::new(song) + } +} + +impl From<&SongDescription> for SongModel { + fn from(song: &SongDescription) -> Self { + SongModel::new(song.clone()) + } +} diff --git a/src/app/models/songs/mod.rs b/src/app/models/songs/mod.rs new file mode 100644 index 0000000..d80a182 --- /dev/null +++ b/src/app/models/songs/mod.rs @@ -0,0 +1,9 @@ +// The underlying data structure for a list of songs +mod support; + +// A GObject wrapper around that list +mod song_list_model; +pub use song_list_model::*; + +mod song_model; +pub use song_model::*; diff --git a/src/app/models/songs/song_list_model.rs b/src/app/models/songs/song_list_model.rs new file mode 100644 index 0000000..43fc132 --- /dev/null +++ b/src/app/models/songs/song_list_model.rs @@ -0,0 +1,255 @@ +use gio::prelude::*; +use gio::ListModel; +use glib::Properties; +use glib::StaticType; +use gtk::subclass::prelude::*; +use std::cell::{Cell, Ref, RefCell, RefMut}; + +use super::support::*; +use crate::app::models::*; + +// A struct to perform multiple mutations on a SongListModel +// Eventually commit() must be called to send an update signal with merged affected ranges +#[must_use] +pub struct SongListModelPending<'a> { + change: Option, + song_list_model: &'a mut SongListModel, +} + +impl<'a> SongListModelPending<'a> { + fn new(change: Option, song_list_model: &'a mut SongListModel) -> Self { + Self { + change, + song_list_model, + } + } + + pub fn and(self, op: Op) -> Self + where + Op: FnOnce(&mut SongListModel) -> SongListModelPending<'_> + 'static, + { + let Self { + change, + song_list_model, + } = self; + + let new_change = op(song_list_model).change; + + let merged_change = if let (Some(change), Some(new_change)) = (change, new_change) { + Some(change.merge(new_change)) + } else { + change.or(new_change) + }; + + Self { + change: merged_change, + song_list_model, + } + } + + pub fn commit(self) -> bool { + let Self { + change, + song_list_model, + } = self; + song_list_model.notify_changes(change); + change.is_some() + } +} + +// A GObject wrapper around the SongList +glib::wrapper! { + pub struct SongListModel(ObjectSubclass) @implements gio::ListModel; +} + +impl SongListModel { + pub fn new(batch_size: u32) -> Self { + glib::Object::builder() + .property("batch-size", batch_size) + .build() + } + + fn inner_mut(&mut self) -> RefMut { + self.imp().get_mut() + } + + fn inner(&self) -> Ref { + self.imp().get() + } + + fn notify_changes(&self, changes: impl IntoIterator + 'static) { + // Eh, not great but that works + if cfg!(not(test)) { + glib::source::idle_add_local_once(clone!(@weak self as s => move || { + for ListRangeUpdate(a, b, c) in changes.into_iter() { + debug!("pos {}, removed {}, added {}", a, b, c); + s.items_changed(a as u32, b as u32, c as u32); + } + })); + } + } + + pub fn for_each(&self, f: F) + where + F: Fn(usize, &SongModel), + { + for (i, song) in self.inner().iter().enumerate() { + f(i, song); + } + } + + pub fn collect(&self) -> Vec { + self.inner().iter().map(|s| s.into_description()).collect() + } + + pub fn map_collect(&self, map: impl Fn(SongDescription) -> T) -> Vec { + self.inner() + .iter() + .map(|s| map(s.into_description())) + .collect() + } + + pub fn add(&mut self, song_batch: SongBatch) -> SongListModelPending { + let range = self.inner_mut().add(song_batch); + SongListModelPending::new(range, self) + } + + pub fn get(&self, id: &str) -> Option { + self.inner().get(id).cloned() + } + + pub fn index(&self, i: usize) -> Option { + self.inner().index(i).cloned() + } + + pub fn index_continuous(&self, i: usize) -> Option { + self.inner().index_continuous(i).cloned() + } + + pub fn song_batch_for(&self, i: usize) -> Option { + self.inner().song_batch_for(i) + } + + pub fn last_batch(&self) -> Option { + self.inner().last_batch() + } + + pub fn needed_batch_for(&self, i: usize) -> Option { + self.inner().needed_batch_for(i) + } + + pub fn partial_len(&self) -> usize { + self.inner().partial_len() + } + + pub fn len(&self) -> usize { + self.inner().len() + } + + pub fn append(&mut self, songs: Vec) -> SongListModelPending { + let range = self.inner_mut().append(songs); + SongListModelPending::new(Some(range), self) + } + + pub fn prepend(&mut self, songs: Vec) -> SongListModelPending { + let range = self.inner_mut().prepend(songs); + SongListModelPending::new(Some(range), self) + } + + pub fn find_index(&self, song_id: &str) -> Option { + self.inner().find_index(song_id) + } + + pub fn remove(&mut self, ids: &[String]) -> SongListModelPending { + let change = self.inner_mut().remove(ids); + SongListModelPending::new(Some(change), self) + } + + pub fn move_down(&mut self, a: usize) -> SongListModelPending { + let swap = self.inner_mut().swap(a + 1, a); + SongListModelPending::new(swap, self) + } + + pub fn move_up(&mut self, a: usize) -> SongListModelPending { + let swap = self.inner_mut().swap(a - 1, a); + SongListModelPending::new(swap, self) + } + + pub fn clear(&mut self) -> SongListModelPending { + let removed = self.inner_mut().clear(); + SongListModelPending::new(Some(removed), self) + } +} + +mod imp { + + use super::*; + + #[derive(Default, Properties)] + #[properties(wrapper_type = super::SongListModel)] + pub struct SongListModel { + #[property(get, set = Self::set_batch_size, name = "batch-size")] + batch_size: Cell, + song_list: RefCell>, + } + + impl SongListModel { + fn set_batch_size(&self, batch_size: u32) { + self.batch_size.set(batch_size); + self.song_list + .replace(Some(SongList::new_sized(batch_size as usize))); + } + } + + #[glib::object_subclass] + impl ObjectSubclass for SongListModel { + const NAME: &'static str = "SongList"; + type Type = super::SongListModel; + type ParentType = glib::Object; + type Interfaces = (ListModel,); + } + + impl ObjectImpl for SongListModel { + fn properties() -> &'static [glib::ParamSpec] { + Self::derived_properties() + } + + fn set_property(&self, id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + self.derived_set_property(id, value, pspec); + } + + fn property(&self, id: usize, pspec: &glib::ParamSpec) -> glib::Value { + self.derived_property(id, pspec) + } + } + + impl ListModelImpl for SongListModel { + fn item_type(&self) -> glib::Type { + SongModel::static_type() + } + + fn n_items(&self) -> u32 { + self.get().partial_len() as u32 + } + + fn item(&self, position: u32) -> Option { + self.get() + .index_continuous(position as usize) + .map(|m| m.clone().upcast()) + } + } + + impl SongListModel { + pub fn get_mut(&self) -> RefMut { + RefMut::map(self.song_list.borrow_mut(), |s| { + s.as_mut().expect("set at construction") + }) + } + + pub fn get(&self) -> Ref { + Ref::map(self.song_list.borrow(), |s| { + s.as_ref().expect("set at construction") + }) + } + } +} diff --git a/src/app/models/songs/song_model.rs b/src/app/models/songs/song_model.rs new file mode 100644 index 0000000..3cb21e6 --- /dev/null +++ b/src/app/models/songs/song_model.rs @@ -0,0 +1,255 @@ +#![allow(clippy::all)] + +use gio::prelude::*; +use glib::{subclass::prelude::*, SignalHandlerId}; +use std::{cell::Ref, ops::Deref}; + +use crate::app::components::utils::format_duration; +use crate::app::models::*; + +// UI model for a song +glib::wrapper! { + pub struct SongModel(ObjectSubclass); +} + +impl SongModel { + pub fn new(song: SongDescription) -> Self { + let o: Self = glib::Object::new(); + o.imp().song.replace(Some(song)); + o + } + + pub fn set_playing(&self, is_playing: bool) { + self.set_property("playing", is_playing); + } + + pub fn set_selected(&self, is_selected: bool) { + self.set_property("selected", is_selected); + } + + pub fn get_playing(&self) -> bool { + self.property("playing") + } + + pub fn get_selected(&self) -> bool { + self.property("selected") + } + + pub fn get_id(&self) -> String { + self.property("id") + } + + pub fn bind_index(&self, o: &impl ObjectType, property: &str) { + self.imp().push_binding( + self.bind_property("index", o, property) + .flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE) + .build(), + ); + } + + pub fn bind_artist(&self, o: &impl ObjectType, property: &str) { + self.imp().push_binding( + self.bind_property("artist", o, property) + .flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE) + .build(), + ); + } + + pub fn bind_title(&self, o: &impl ObjectType, property: &str) { + self.imp().push_binding( + self.bind_property("title", o, property) + .flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE) + .build(), + ); + } + + pub fn bind_duration(&self, o: &impl ObjectType, property: &str) { + self.imp().push_binding( + self.bind_property("duration", o, property) + .flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE) + .build(), + ); + } + + pub fn bind_playing(&self, o: &impl ObjectType, property: &str) { + self.imp().push_binding( + self.bind_property("playing", o, property) + .flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE) + .build(), + ); + } + + pub fn bind_selected(&self, o: &impl ObjectType, property: &str) { + self.imp().push_binding( + self.bind_property("selected", o, property) + .flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE) + .build(), + ); + } + + pub fn unbind_all(&self) { + self.imp().unbind_all(self); + } + + pub fn description(&self) -> impl Deref + '_ { + Ref::map(self.imp().song.borrow(), |s| { + s.as_ref().expect("song set at constructor") + }) + } + + pub fn into_description(&self) -> SongDescription { + self.imp() + .song + .borrow() + .as_ref() + .cloned() + .expect("song set at constructor") + } +} + +mod imp { + + use super::*; + use std::cell::{Cell, RefCell}; + + // Keep track of signals and bindings targeting this song + #[derive(Default)] + struct BindingsInner { + pub signals: Vec, + pub bindings: Vec, + } + + #[derive(Default)] + pub struct SongModel { + pub song: RefCell>, + pub state: Cell, + bindings: RefCell, + } + + impl SongModel { + pub fn push_signal(&self, id: SignalHandlerId) { + self.bindings.borrow_mut().signals.push(id); + } + + pub fn push_binding(&self, binding: glib::Binding) { + self.bindings.borrow_mut().bindings.push(binding); + } + + pub fn unbind_all(&self, o: &O) { + let mut bindings = self.bindings.borrow_mut(); + bindings.signals.drain(..).for_each(|s| o.disconnect(s)); + bindings.bindings.drain(..).for_each(|b| b.unbind()); + } + } + + #[glib::object_subclass] + impl ObjectSubclass for SongModel { + const NAME: &'static str = "SongModel"; + type Type = super::SongModel; + type ParentType = glib::Object; + } + + lazy_static! { + static ref PROPERTIES: [glib::ParamSpec; 8] = [ + glib::ParamSpecString::builder("id").read_only().build(), + glib::ParamSpecUInt::builder("index").read_only().build(), + glib::ParamSpecString::builder("title").read_only().build(), + glib::ParamSpecString::builder("artist").read_only().build(), + glib::ParamSpecString::builder("duration") + .read_only() + .build(), + // URL + glib::ParamSpecString::builder("art").read_only().build(), + // Can be true when playback is paused; just means this is the current song + glib::ParamSpecBoolean::builder("playing") + .readwrite() + .build(), + glib::ParamSpecBoolean::builder("selected") + .readwrite() + .build(), + ]; + } + + impl ObjectImpl for SongModel { + fn properties() -> &'static [glib::ParamSpec] { + &*PROPERTIES + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + match pspec.name() { + "playing" => { + let is_playing = value + .get() + .expect("type conformity checked by `Object::set_property`"); + let SongState { is_selected, .. } = self.state.get(); + self.state.set(SongState { + is_playing, + is_selected, + }); + } + "selected" => { + let is_selected = value + .get() + .expect("type conformity checked by `Object::set_property`"); + let SongState { is_playing, .. } = self.state.get(); + self.state.set(SongState { + is_playing, + is_selected, + }); + } + _ => unimplemented!(), + } + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "index" => self + .song + .borrow() + .as_ref() + .expect("song set at constructor") + .track_number + .unwrap_or(1) + .to_value(), + "title" => self + .song + .borrow() + .as_ref() + .expect("song set at constructor") + .title + .to_value(), + "artist" => self + .song + .borrow() + .as_ref() + .expect("song set at constructor") + .artists_name() + .to_value(), + "id" => self + .song + .borrow() + .as_ref() + .expect("song set at constructor") + .id + .to_value(), + "duration" => self + .song + .borrow() + .as_ref() + .map(|s| format_duration(s.duration.into())) + .expect("song set at constructor") + .to_value(), + "art" => self + .song + .borrow() + .as_ref() + .expect("song set at constructor") + .art + .to_value(), + "playing" => self.state.get().is_playing.to_value(), + "selected" => self.state.get().is_selected.to_value(), + _ => unimplemented!(), + } + } + } +} diff --git a/src/app/models/songs/support.rs b/src/app/models/songs/support.rs new file mode 100644 index 0000000..4b02600 --- /dev/null +++ b/src/app/models/songs/support.rs @@ -0,0 +1,686 @@ +use std::collections::HashMap; +use std::convert::{TryFrom, TryInto}; + +use crate::app::models::*; + +// A range of numbers [a, b], empty range is allowed as well +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Range { + Empty, + NotEmpty(u32, u32), +} + +impl Range { + // Create a range [a, b] if b >= a, or an empty range otherwise + fn of(a: impl TryInto, b: impl TryInto) -> Self { + match (a.try_into(), b.try_into()) { + (Ok(a), Ok(b)) if b >= a => Self::NotEmpty(a, b), + _ => Self::Empty, + } + } + + fn len(self) -> u32 { + match self { + Self::Empty => 0, + Self::NotEmpty(a, b) => b - a + 1, + } + } + + fn union(self, other: Self) -> Self { + match (self, other) { + (Self::NotEmpty(a0, b0), Self::NotEmpty(a1, b1)) => { + let start = u32::min(a0, a1); + let end = u32::max(b0, b1); + Self::NotEmpty(start, end) + } + (Self::Empty, r) | (r, Self::Empty) => r, + } + } + + fn offset_by(self, offset: i32) -> Self { + match self { + Self::Empty => Self::Empty, + Self::NotEmpty(a, b) => Self::of((a as i32) + offset, (b as i32) + offset), + } + } + + // Start index of the range, if not an empty range + fn start(self) -> Option + where + Target: TryFrom, + { + match self { + Self::Empty => None, + Self::NotEmpty(a, _) => Some(a.try_into().ok()?), + } + } +} + +// Represents the range affected by an operation on the list +// ListRangeUpdate(position, nb of elements added, nb of elements removed) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ListRangeUpdate(pub i32, pub i32, pub i32); + +impl ListRangeUpdate { + pub fn inserted(position: impl TryInto, added: impl TryInto) -> Self { + Self( + position.try_into().unwrap_or_default(), + 0, + added.try_into().unwrap_or_default(), + ) + } + + pub fn removed(position: impl TryInto, removed: impl TryInto) -> Self { + Self( + position.try_into().unwrap_or_default(), + removed.try_into().unwrap_or_default(), + 0, + ) + } + + pub fn updated(position: impl TryInto) -> Self { + Self(position.try_into().unwrap_or_default(), 1, 1) + } + + // Merge two range updates + pub fn merge(self, other: Self) -> Self { + // reorder for simplicity + let (left, right) = if self.0 <= other.0 { + (self, other) + } else { + (other, self) + }; + + let Self(p0, r0, a0) = left; + let Self(p1, r1, a1) = right; + + // range [s, e] affected by first update + let ra0 = Range::of(p0, p0 + r0 - 1); + + // ...second update, but only the range affecting existing elements + let ra1 = { + let s1 = i32::max(p0 + a0, p1); + let e1 = i32::max(s1 - 1, p1 + r1 - 1); + Range::of(s1, e1) + }; + + // remap to original + let ra1 = ra1.offset_by(r0 - a0); + + // union + let rau = ra0.union(ra1); + + let removed = rau.len() as i32; + let position = rau.start().unwrap_or(p0); + let added = removed - (r0 - a0) - (r1 - a1); + Self(position, removed, added) + } +} + +// A list of songs that supports +// - batch loading (with non contiguous batches if songs are accessed in random order) +// - O(1) time access to a song by its id +// - manually adding content (not batched), when managing a queue for instance +// - tracking the affected range after a mutation +// +// Note: the mutated ranges are given in terms of LOADED tracks. The theoretical size of the list is not accounted for. +// This is to ease the work of updating the UI: we want to know what loaded/visible elements have moved around. +// +// Some operations are not very efficient. It might have been smarter to have different structures for our two use cases: +// - fixed, batched sources (an album, a playlist) +// - editable lists (queue) +#[derive(Clone, Debug)] +pub struct SongList { + total: usize, + total_loaded: usize, + batch_size: usize, + last_batch_key: usize, + // Here a batch has an index (key) and a list of associated song ids + // Why not a Vec? We could have batch 1, 2, NOT 3, then 4 + batches: HashMap>, + indexed_songs: HashMap, +} + +impl SongList { + pub fn new_sized(batch_size: usize) -> Self { + Self { + total: 0, + total_loaded: 0, + batch_size, + last_batch_key: 0, + batches: Default::default(), + indexed_songs: Default::default(), + } + } + + pub fn batch_size(&self) -> usize { + self.batch_size + } + + pub fn iter(&self) -> impl Iterator { + let indexed_songs = &self.indexed_songs; + self.iter_ids_from(0) + .filter_map(move |(_, id)| indexed_songs.get(id)) + } + + // How many songs we actually have at the moment + pub fn partial_len(&self) -> usize { + self.total_loaded + } + + // How many songs are loaded, up to a given batch index + fn estimated_len(&self, up_to_batch_index: usize) -> usize { + let batches = &self.batches; + let batch_size = self.batch_size; + let batch_count = (0..up_to_batch_index) + .filter(move |i| batches.contains_key(i)) + .count(); + batch_size * batch_count + } + + // The theoretical len of the playlist, if we had all songs + pub fn len(&self) -> usize { + self.total + } + + fn iter_ids_from(&self, i: usize) -> impl Iterator { + let batch_size = self.batch_size; + let index = i / batch_size; + self.iter_range(index, self.last_batch_key) + .skip(i % batch_size) + } + + // Find the position of a song in the list + pub fn find_index(&self, song_id: &str) -> Option { + self.iter_ids_from(0) + .find(|(_, id)| &id[..] == song_id) + .map(|(pos, _)| pos) + } + + // Iterate over batches (in a given batch range), returning a tuple with the index of a song and its id + fn iter_range(&self, a: usize, b: usize) -> impl Iterator { + let batch_size = self.batch_size; + let batches = &self.batches; + (a..=b) + .filter_map(move |i| batches.get_key_value(&i)) + .flat_map(move |(k, b)| { + b.iter() + .enumerate() + .map(move |(i, id)| (i + *k * batch_size, id)) + }) + } + + // Add an id to our batches + fn batches_add(batches: &mut HashMap>, batch_size: usize, id: &str) { + let index = batches.len().saturating_sub(1); + let count = batches + .get(&index) + .map(|b| b.len() % batch_size) + .unwrap_or(0); + // If there's no space in the last batch, we insert a new one + if count == 0 { + batches.insert(batches.len(), vec![id.to_string()]); + } else { + batches.get_mut(&index).unwrap().push(id.to_string()); + } + } + + pub fn clear(&mut self) -> ListRangeUpdate { + let len = self.partial_len(); + *self = Self::new_sized(self.batch_size); + ListRangeUpdate::removed(0, len) + } + + pub fn remove(&mut self, ids: &[String]) -> ListRangeUpdate { + let len = self.total_loaded; + let mut batches = HashMap::>::default(); + self.iter_ids_from(0) + .filter(|(_, s)| !ids.contains(s)) + // Removing is expensive, we have to recreate all batches + .for_each(|(_, next)| { + Self::batches_add(&mut batches, self.batch_size, next); + }); + self.last_batch_key = batches.len().saturating_sub(1); + self.batches = batches; + let removed = ids.len(); + self.total = self.total.saturating_sub(removed); + self.total_loaded = self.total_loaded.saturating_sub(removed); + // Lazy computation of the affected range, basically assume everything has changed + ListRangeUpdate(0, len as i32, self.total_loaded as i32) + } + + pub fn append(&mut self, songs: Vec) -> ListRangeUpdate { + let songs_len = songs.len(); + // How many loaded/visible songs so far + let insertion_start = self.estimated_len(self.last_batch_key + 1); + self.total = self.total.saturating_add(songs_len); + self.total_loaded = self.total_loaded.saturating_add(songs_len); + for song in songs { + Self::batches_add(&mut self.batches, self.batch_size, &song.id); + self.indexed_songs + .insert(song.id.clone(), SongModel::new(song)); + } + self.last_batch_key = self.batches.len().saturating_sub(1); + ListRangeUpdate::inserted(insertion_start, songs_len) + } + + pub fn prepend(&mut self, songs: Vec) -> ListRangeUpdate { + let songs_len = songs.len(); + let insertion_start = 0; + + // Prepending also requires redoing all the batches + let mut batches = HashMap::>::default(); + for song in songs { + Self::batches_add(&mut batches, self.batch_size, &song.id); + self.indexed_songs + .insert(song.id.clone(), SongModel::new(song)); + } + self.iter_ids_from(0).for_each(|(_, next)| { + Self::batches_add(&mut batches, self.batch_size, next); + }); + + self.total = self.total.saturating_add(songs_len); + self.total_loaded = self.total_loaded.saturating_add(songs_len); + self.last_batch_key = batches.len().saturating_sub(1); + self.batches = batches; + + // But it's a bit easier to computer the visibly affected range :) + ListRangeUpdate::inserted(insertion_start, songs_len) + } + + // Adding a batch is easy, might only require a resize + pub fn add(&mut self, song_batch: SongBatch) -> Option { + if song_batch.batch.batch_size != self.batch_size { + song_batch + .resize(self.batch_size) + .into_iter() + .map(|new_batch| { + debug!("adding batch {:?}", &new_batch.batch); + self.add_one(new_batch) + }) + .reduce(|acc, cur| { + // If we have added more than one batch we just merge the affected ranges + let merged = acc?.merge(cur?); + Some(merged).or(acc).or(cur) + }) + .unwrap_or(None) + } else { + self.add_one(song_batch) + } + } + + fn add_one(&mut self, SongBatch { songs, batch }: SongBatch) -> Option { + assert_eq!(batch.batch_size, self.batch_size); + + let index = batch.offset / batch.batch_size; + + if self.batches.contains_key(&index) { + debug!("batch already loaded"); + return None; + } + + let insertion_start = self.estimated_len(index); + let len = songs.len(); + let ids = songs + .into_iter() + .map(|song| { + let song_id = song.id.clone(); + self.indexed_songs + .insert(song_id.clone(), SongModel::new(song)); + song_id + }) + .collect(); + + self.batches.insert(index, ids); + self.total = batch.total; + self.total_loaded += len; + self.last_batch_key = usize::max(self.last_batch_key, index); + + Some(ListRangeUpdate::inserted(insertion_start, len)) + } + + fn index_mut(&mut self, i: usize) -> Option<&mut String> { + let batch_size = self.batch_size; + let i_batch = i / batch_size; + self.batches + .get_mut(&i_batch) + .and_then(|s| s.get_mut(i % batch_size)) + } + + pub fn swap(&mut self, a: usize, b: usize) -> Option { + if a == b { + return None; + } + let a_value = self.index_mut(a).map(std::mem::take); + let a_value = a_value.as_ref(); + let new_a_value = self + .index_mut(b) + .and_then(|v| Some(std::mem::replace(v, a_value?.clone()))) + .or_else(|| a_value.cloned()); + let a_mut = self.index_mut(a); + if let (Some(a_mut), Some(a_value)) = (a_mut, new_a_value) { + *a_mut = a_value; + } + Some(ListRangeUpdate::updated(a).merge(ListRangeUpdate::updated(b))) + } + + // Get the song at i (if the index is valid AND has been loaded) + pub fn index(&self, i: usize) -> Option<&SongModel> { + let batch_size = self.batch_size; + let batch_id = i / batch_size; + let indexed_songs = &self.indexed_songs; + self.batches + .get(&batch_id) + .and_then(|batch| batch.get(i % batch_size)) + .and_then(move |id| indexed_songs.get(id)) + } + + // Get the i-th loaded song. VERY different! + pub fn index_continuous(&self, i: usize) -> Option<&SongModel> { + let batch_size = self.batch_size; + let bi = i / batch_size; + let batch = (0..=self.last_batch_key) + // Skip missing/not loaded batches + .filter_map(move |i| self.batches.get(&i)) + .nth(bi)?; + batch + .get(i % batch_size) + .and_then(move |id| self.indexed_songs.get(id)) + } + + // Return the batch needed to access the song at index i (if it's not loaded yet) + pub fn needed_batch_for(&self, i: usize) -> Option { + let total = self.total; + let batch_size = self.batch_size; + let batch_id = i / batch_size; + if self.batches.contains_key(&batch_id) { + None + } else { + Some(Batch { + batch_size, + total, + offset: batch_id * batch_size, + }) + } + } + + // Get the full song batch that contains i + pub fn song_batch_for(&self, i: usize) -> Option { + let total = self.total; + let batch_size = self.batch_size; + let batch_id = i / batch_size; + let indexed_songs = &self.indexed_songs; + self.batches.get(&batch_id).map(|songs| SongBatch { + songs: songs + .iter() + .filter_map(move |id| Some(indexed_songs.get(id)?.into_description())) + .collect(), + batch: Batch { + batch_size, + total, + offset: batch_id * batch_size, + }, + }) + } + + // The last loaded batch + pub fn last_batch(&self) -> Option { + if self.total_loaded == 0 { + None + } else { + Some(Batch { + batch_size: self.batch_size, + total: self.total, + offset: self.last_batch_key * self.batch_size, + }) + } + } + + pub fn get(&self, id: &str) -> Option<&SongModel> { + self.indexed_songs.get(id) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + const NO_CHANGE: ListRangeUpdate = ListRangeUpdate(0, 0, 0); + + impl SongList { + fn new_from_initial_batch(initial: SongBatch) -> Self { + let mut s = Self::new_sized(initial.batch.batch_size); + s.add(initial); + s + } + } + + fn song(id: &str) -> SongDescription { + SongDescription { + id: id.to_string(), + uri: "".to_string(), + title: "Title".to_string(), + artists: vec![], + album: AlbumRef { + id: "".to_string(), + name: "".to_string(), + }, + duration: 1000, + art: None, + track_number: None, + } + } + + fn batch(id: usize) -> SongBatch { + let offset = id * 2; + SongBatch { + batch: Batch { + offset, + batch_size: 2, + total: 10, + }, + songs: vec![ + song(&format!("song{offset}")), + song(&format!("song{}", offset + 1)), + ], + } + } + + #[test] + fn test_merge_range() { + // [0, 1, 2, 3, 4, 5] + let change1 = ListRangeUpdate(0, 4, 2); + // [x, x, 4, 5] + let change2 = ListRangeUpdate(1, 1, 2); + // [x, y, y, 4, 5] + assert_eq!(change1.merge(change2), ListRangeUpdate(0, 4, 3)); + assert_eq!(change2.merge(change1), ListRangeUpdate(0, 4, 3)); + + // [0, 1, 2, 3, 4, 5, 6] + let change1 = ListRangeUpdate(0, 2, 3); + // [x, x, x, 2, 3, 4, 5, 6] + let change2 = ListRangeUpdate(4, 1, 1); + // [x, x, x, 2, y, 4, 5, 6] + assert_eq!(change1.merge(change2), ListRangeUpdate(0, 4, 5)); + assert_eq!(change2.merge(change1), ListRangeUpdate(0, 4, 5)); + + // [0, 1, 2, 3, 4, 5, 6] + let change1 = ListRangeUpdate(0, 3, 2); + // [x, x, 3, 4, 5, 6] + let change2 = ListRangeUpdate(4, 1, 1); + // [x, x, 3, 4, y, 6] + assert_eq!(change1.merge(change2), ListRangeUpdate(0, 6, 5)); + assert_eq!(change2.merge(change1), ListRangeUpdate(0, 6, 5)); + + // [0, 1, 2, 3, 4, 5] + let change1 = ListRangeUpdate(0, 4, 2); + // [x, x, 4, 5] + let change2 = ListRangeUpdate(1, 1, 1); + // [x, y, 4, 5] + assert_eq!(change1.merge(change2), ListRangeUpdate(0, 4, 2)); + assert_eq!(change2.merge(change1), ListRangeUpdate(0, 4, 2)); + + // [0, 1, 2, 3, 4, 5] + let change1 = ListRangeUpdate(0, 4, 2); + // [x, x, 4, 5] + let change2 = ListRangeUpdate(0, 4, 2); + // [y, y] + assert_eq!(change1.merge(change2), ListRangeUpdate(0, 6, 2)); + assert_eq!(change2.merge(change1), ListRangeUpdate(0, 6, 2)); + + // [] + let change1 = ListRangeUpdate(0, 0, 2); + // [x, x] + let change2 = ListRangeUpdate(2, 0, 2); + // [x, x, y, y] + assert_eq!(change1.merge(change2), ListRangeUpdate(0, 0, 4)); + assert_eq!(change2.merge(change1), ListRangeUpdate(0, 0, 4)); + + let change1 = ListRangeUpdate(0, 4, 2); + assert_eq!(change1.merge(NO_CHANGE), ListRangeUpdate(0, 4, 2)); + assert_eq!(NO_CHANGE.merge(change1), ListRangeUpdate(0, 4, 2)); + } + + #[test] + fn test_iter() { + let list = SongList::new_from_initial_batch(batch(0)); + + let mut list_iter = list.iter(); + assert_eq!(list_iter.next().unwrap().description().id, "song0"); + assert_eq!(list_iter.next().unwrap().description().id, "song1"); + assert!(list_iter.next().is_none()); + } + + #[test] + fn test_index() { + let list = SongList::new_from_initial_batch(batch(0)); + + let song1 = list.index(1); + assert!(song1.is_some()); + + let song3 = list.index(3); + assert!(song3.is_none()); + } + + #[test] + fn test_add() { + let mut list = SongList::new_from_initial_batch(batch(0)); + list.add(batch(1)); + + let song3 = list.index(3); + assert!(song3.is_some()); + let list_iter = list.iter(); + assert_eq!(list_iter.count(), 4); + } + + #[test] + fn test_add_with_range() { + let mut list = SongList::new_from_initial_batch(batch(0)); + + let range = list.add(batch(1)); + assert_eq!(range, Some(ListRangeUpdate::inserted(2, 2))); + assert_eq!(list.partial_len(), 4); + + let range = list.add(batch(3)); + assert_eq!(range, Some(ListRangeUpdate::inserted(4, 2))); + assert_eq!(list.partial_len(), 6); + + let range = list.add(batch(2)); + assert_eq!(range, Some(ListRangeUpdate::inserted(4, 2))); + assert_eq!(list.partial_len(), 8); + + let range = list.add(batch(2)); + assert_eq!(range, None); + assert_eq!(list.partial_len(), 8); + } + + #[test] + fn test_find_non_contiguous() { + let mut list = SongList::new_from_initial_batch(batch(0)); + list.add(batch(3)); + + let index = list.find_index("song6"); + + assert_eq!(index, Some(6)); + } + + #[test] + fn test_iter_non_contiguous() { + let mut list = SongList::new_from_initial_batch(batch(0)); + list.add(batch(2)); + + assert_eq!(list.partial_len(), 4); + + let mut list_iter = list.iter(); + assert_eq!(list_iter.next().unwrap().description().id, "song0"); + assert_eq!(list_iter.next().unwrap().description().id, "song1"); + assert_eq!(list_iter.next().unwrap().description().id, "song4"); + assert_eq!(list_iter.next().unwrap().description().id, "song5"); + assert!(list_iter.next().is_none()); + } + + #[test] + fn test_remove() { + let mut list = SongList::new_from_initial_batch(batch(0)); + list.add(batch(1)); + + list.remove(&["song0".to_string()]); + + assert_eq!(list.partial_len(), 3); + + let mut list_iter = list.iter(); + assert_eq!(list_iter.next().unwrap().description().id, "song1"); + assert_eq!(list_iter.next().unwrap().description().id, "song2"); + assert_eq!(list_iter.next().unwrap().description().id, "song3"); + assert!(list_iter.next().is_none()); + } + + #[test] + fn test_batch_for() { + let mut list = SongList::new_from_initial_batch(batch(0)); + list.add(batch(1)); + list.add(batch(2)); + list.add(batch(3)); + + assert_eq!(list.partial_len(), 8); + + let batch = list.song_batch_for(3); + assert_eq!(batch.unwrap().batch.offset, 2); + } + + #[test] + fn test_append() { + let mut list = SongList::new_from_initial_batch(batch(0)); + list.append(vec![song("song2")]); + list.append(vec![song("song3")]); + list.append(vec![song("song4")]); + + let mut list_iter = list.iter(); + assert_eq!(list_iter.next().unwrap().description().id, "song0"); + assert_eq!(list_iter.next().unwrap().description().id, "song1"); + assert_eq!(list_iter.next().unwrap().description().id, "song2"); + assert_eq!(list_iter.next().unwrap().description().id, "song3"); + assert_eq!(list_iter.next().unwrap().description().id, "song4"); + assert!(list_iter.next().is_none()); + } + + #[test] + fn test_swap() { + let mut list = SongList::new_sized(10); + list.append(vec![song("song0"), song("song1"), song("song2")]); + + list.swap(0, 3); // should be a no-op + list.swap(2, 3); // should be a no-op + list.swap(0, 2); + list.swap(0, 1); + list.swap(2, 2); // should be no-op + list.swap(2, 3); // should be no-op + + let mut list_iter = list.iter(); + assert_eq!(list_iter.next().unwrap().description().id, "song1"); + assert_eq!(list_iter.next().unwrap().description().id, "song2"); + assert_eq!(list_iter.next().unwrap().description().id, "song0"); + assert!(list_iter.next().is_none()); + } +} diff --git a/src/app/rng.rs b/src/app/rng.rs new file mode 100644 index 0000000..2c2e5c0 --- /dev/null +++ b/src/app/rng.rs @@ -0,0 +1,198 @@ +use rand::{rngs::SmallRng, RngCore, SeedableRng}; + +// A random, resizable mapping (i-th element to play => j-th track) used to handle shuffled playlists +// It's lazy: initially we don't compute what index i maps to +// It's resizable: if our playlist grows or shrinks, we have to keep the generated mappings stable +// (we don't want to reshuffle!) +#[derive(Debug)] +pub struct LazyRandomIndex { + rng: SmallRng, + indices: Vec, + // How many mapping were generated + generated: usize, +} + +impl Default for LazyRandomIndex { + fn default() -> Self { + Self::from(SmallRng::from_entropy()) + } +} + +impl LazyRandomIndex { + fn from(rng: SmallRng) -> Self { + Self { + rng, + indices: Default::default(), + generated: 0, + } + } + + // Resets the mapping, but make sure some index `first` will be mapped from 0 + // This is used to "pin" the position of a track when switching in and out of shuffle mode + // See tests below for an example + pub fn reset_picking_first(&mut self, first: usize) { + self.generated = 0; + if let Some(index) = self.indices.iter().position(|i| *i == first) { + self.pick_next(index); + } + } + + // Grow or shrink + pub fn resize(&mut self, size: usize) { + if size >= self.indices.len() { + self.grow(size); + } else { + self.shrink(size); + } + } + + // Resize the underlying Vec, but we don't generate mappings yet + // We don't update the `generated` count: for now whatever is beyond that index is just the non-shuffled index + pub fn grow(&mut self, size: usize) { + let current_size = self.indices.len(); + self.indices.extend(current_size..size); + } + + pub fn shrink(&mut self, size: usize) { + self.generated = usize::min(size, self.generated); + self.indices.truncate(size); + } + + // Get the index (for instance in a playlist) of the i-th next element to play + pub fn get(&self, i: usize) -> Option { + if i >= self.generated || i >= self.indices.len() { + None + } else { + Some(self.indices[i]) + } + } + + // Generate all mappings until the mapping for i has been generated + pub fn next_until(&mut self, i: usize) -> Option { + if i >= self.indices.len() { + return None; + } + + loop { + if self.generated > i { + break Some(self.indices[i]); + } + self.next(); + } + } + + // Generate the next mapping + pub fn next(&mut self) -> Option { + if self.indices.len() < self.generated { + return None; + } + + let last = self.generated; + // Pick a random index in the range [k, n[ where + // k is the next index to map + // n is the size of our targeted list + + // The element at that index will be swapped with whatever is in pos k + // That is, we never mess with the already picked indices, we want this to be stable: + // a0, a1, ..., ak-1, ak, ..., an + // <-already mapped-> + // <-not mapped-> + // We just pick something between k and n and make it ak + + // Example gen with size 3 + // [0, 1, 2], generated = 0 + // [1, 0, 2], generated = 1, we swapped 0 and 1 + // [1, 0, 2], generated = 2, we swapped 1 and 1 (no-op) + // [1, 0, 2], generated = 3, no-op again (no choice, only one element left to place) + let next = (self.rng.next_u64() as usize) % (self.indices.len() - last) + last; + Some(self.pick_next(next)) + } + + fn pick_next(&mut self, next: usize) -> usize { + let last = self.generated; + self.indices.swap(last, next); + self.generated += 1; + self.indices[last] + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + fn rng_for_test() -> SmallRng { + SmallRng::seed_from_u64(0) + } + + fn get_sequence(n: usize) -> Vec { + let mut rng = rng_for_test(); + (0..n) + .into_iter() + .map(|_| rng.next_u64() as usize) + .collect() + } + + #[test] + fn test_initial() { + let seq = get_sequence(10); + let first = Some(seq[0] % 10); + + // It's controlled randomness for the test :) + let mut index = LazyRandomIndex::from(rng_for_test()); + index.grow(10); + + let next = index.next(); + assert_eq!(next, first); + assert_eq!(index.get(0), first); + } + + #[test] + fn test_sample_all() { + let mut index = LazyRandomIndex::from(rng_for_test()); + index.grow(2); + index.grow(5); + + let mut values = (0..5) + .into_iter() + .filter_map(|_| index.next()) + .collect::>(); + let sorted = &mut values[..]; + sorted.sort(); + // Check that we have all our indices mapped + assert_eq!(sorted, &[0, 1, 2, 3, 4]); + } + + #[test] + fn test_after_grow() { + let mut index = LazyRandomIndex::from(rng_for_test()); + + index.grow(5); + index.next_until(2); + let values = &[index.get(0), index.get(1), index.get(2)]; + + index.grow(10); + let same_values = &[index.get(0), index.get(1), index.get(2)]; + + // After growing the index from 5 to 10, we want to check + // that the previously generated mappings are unaffected. + assert_eq!(values, same_values); + } + + #[test] + fn test_reset() { + let mut index = LazyRandomIndex::from(rng_for_test()); + + // Example use + // Assume we have 5 songs in our shuffled playlist, and we generate those 5 few random mappings + index.grow(5); + index.next_until(5); + + // We exit shuffle mode at some point + + // Shuffle is toggled again: we want index 2 to come up first in the shuffled playlist + // because it is what's currently playing. + index.reset_picking_first(2); + assert_eq!(index.get(0), Some(2)); + } +} diff --git a/src/app/state/app_model.rs b/src/app/state/app_model.rs new file mode 100644 index 0000000..5c777da --- /dev/null +++ b/src/app/state/app_model.rs @@ -0,0 +1,79 @@ +use crate::api::SpotifyApiClient; +use crate::app::{state::*, BatchLoader}; +use ref_filter_map::*; +use std::cell::{Ref, RefCell}; +use std::sync::Arc; + +pub struct AppServices { + pub spotify_api: Arc, + pub batch_loader: BatchLoader, +} + +// Two purposes: give access to some services to users of the AppModel (shared) +// and give a read only view of the state +pub struct AppModel { + state: RefCell, + services: AppServices, +} + +impl AppModel { + pub fn new(state: AppState, spotify_api: Arc) -> Self { + let services = AppServices { + batch_loader: BatchLoader::new(Arc::clone(&spotify_api)), + spotify_api, + }; + let state = RefCell::new(state); + Self { state, services } + } + + pub fn get_spotify(&self) -> Arc { + Arc::clone(&self.services.spotify_api) + } + + pub fn get_batch_loader(&self) -> BatchLoader { + self.services.batch_loader.clone() + } + + // Read only access to the state! + pub fn get_state(&self) -> Ref<'_, AppState> { + self.state.borrow() + } + + // Convenience... + pub fn map_state &T>(&self, map: F) -> Ref<'_, T> { + Ref::map(self.state.borrow(), map) + } + + // Convenience... + pub fn map_state_opt Option<&T>>( + &self, + map: F, + ) -> Option> { + ref_filter_map(self.state.borrow(), map) + } + + pub fn update_state(&self, action: AppAction) -> Vec { + // The LoginActions are a bit special, we intercept them to grab the Spotify token + // and save it in our Arc'd API client + match &action { + AppAction::LoginAction(LoginAction::SetLoginSuccess( + SetLoginSuccessAction::Password(creds), + )) => { + self.services.spotify_api.update_token(creds.token.clone()); + } + AppAction::LoginAction(LoginAction::SetLoginSuccess(SetLoginSuccessAction::Token( + creds, + ))) => { + self.services.spotify_api.update_token(creds.token.clone()); + } + AppAction::LoginAction(LoginAction::SetRefreshedToken { token, .. }) => { + self.services.spotify_api.update_token(token.clone()); + } + _ => {} + } + + // And this is the only mutable borrow of our state! + let mut state = self.state.borrow_mut(); + state.update_state(action) + } +} diff --git a/src/app/state/app_state.rs b/src/app/state/app_state.rs new file mode 100644 index 0000000..fb7ebb1 --- /dev/null +++ b/src/app/state/app_state.rs @@ -0,0 +1,270 @@ +use std::borrow::Cow; + +use crate::app::models::{PlaylistDescription, PlaylistSummary}; +use crate::app::state::{ + browser_state::{BrowserAction, BrowserEvent, BrowserState}, + login_state::{LoginAction, LoginEvent, LoginState}, + playback_state::{PlaybackAction, PlaybackEvent, PlaybackState}, + selection_state::{SelectionAction, SelectionContext, SelectionEvent, SelectionState}, + settings_state::{SettingsAction, SettingsEvent, SettingsState}, + ScreenName, UpdatableState, +}; + +// It's a big one... +// All possible actions! +// It's probably a VERY poor way to layout such a big enum, just look at the size, I'm so sorry I am not a sytems programmer +// Could use a few more Boxes maybe? +#[derive(Clone, Debug)] +pub enum AppAction { + // With sub categories :) + PlaybackAction(PlaybackAction), + BrowserAction(BrowserAction), + SelectionAction(SelectionAction), + LoginAction(LoginAction), + SettingsAction(SettingsAction), + Start, + Raise, + ShowNotification(String), + ViewNowPlaying, + // Cross-state actions + QueueSelection, + DequeueSelection, + MoveUpSelection, + MoveDownSelection, + SaveSelection, + UnsaveSelection, + EnableSelection(SelectionContext), + CancelSelection, + CreatePlaylist(PlaylistDescription), + UpdatePlaylistName(PlaylistSummary), +} + +// Not actual actions, just neat wrappers +impl AppAction { + // An action to open a Spotify URI + #[allow(non_snake_case)] + pub fn OpenURI(uri: String) -> Option { + debug!("parsing {}", &uri); + let mut parts = uri.split(':'); + if parts.next()? != "spotify" { + return None; + } + + // Might start with /// because of https://gitlab.gnome.org/GNOME/glib/-/issues/1886/ + let action = parts + .next()? + .strip_prefix("///") + .filter(|p| !p.is_empty())?; + let data = parts.next()?; + + match action { + "album" => Some(Self::ViewAlbum(data.to_string())), + "artist" => Some(Self::ViewArtist(data.to_string())), + "playlist" => Some(Self::ViewPlaylist(data.to_string())), + "user" => Some(Self::ViewUser(data.to_string())), + _ => None, + } + } + + #[allow(non_snake_case)] + pub fn ViewAlbum(id: String) -> Self { + BrowserAction::NavigationPush(ScreenName::AlbumDetails(id)).into() + } + + #[allow(non_snake_case)] + pub fn ViewArtist(id: String) -> Self { + BrowserAction::NavigationPush(ScreenName::Artist(id)).into() + } + + #[allow(non_snake_case)] + pub fn ViewPlaylist(id: String) -> Self { + BrowserAction::NavigationPush(ScreenName::PlaylistDetails(id)).into() + } + + #[allow(non_snake_case)] + pub fn ViewUser(id: String) -> Self { + BrowserAction::NavigationPush(ScreenName::User(id)).into() + } + + #[allow(non_snake_case)] + pub fn ViewSearch() -> Self { + BrowserAction::NavigationPush(ScreenName::Search).into() + } +} + +// Actions mutate stuff, and we know what changed thanks to these events +#[derive(Clone, Debug)] +pub enum AppEvent { + // Also subcategorized + PlaybackEvent(PlaybackEvent), + BrowserEvent(BrowserEvent), + SelectionEvent(SelectionEvent), + LoginEvent(LoginEvent), + Started, + Raised, + NotificationShown(String), + PlaylistCreatedNotificationShown(String), + NowPlayingShown, + SettingsEvent(SettingsEvent), +} + +// The actual state, split five-ways +pub struct AppState { + started: bool, + pub playback: PlaybackState, + pub browser: BrowserState, + pub selection: SelectionState, + pub logged_user: LoginState, + pub settings: SettingsState, +} + +impl AppState { + pub fn new() -> Self { + Self { + started: false, + playback: Default::default(), + browser: BrowserState::new(), + selection: Default::default(), + logged_user: Default::default(), + settings: Default::default(), + } + } + + pub fn update_state(&mut self, message: AppAction) -> Vec { + match message { + AppAction::Start if !self.started => { + self.started = true; + vec![AppEvent::Started] + } + // Couple of actions that don't mutate the state (not intested in keeping track of what they change) + // they're here just to have a consistent way of doing things (always an Action) + AppAction::ShowNotification(c) => vec![AppEvent::NotificationShown(c)], + AppAction::ViewNowPlaying => vec![AppEvent::NowPlayingShown], + AppAction::Raise => vec![AppEvent::Raised], + // Cross-state actions: multiple "substates" are affected by these actions, that's why they're handled here + // Might need some clean-up + AppAction::QueueSelection => { + self.playback.queue(self.selection.take_selection()); + vec![ + SelectionEvent::SelectionModeChanged(false).into(), + PlaybackEvent::PlaylistChanged.into(), + ] + } + AppAction::DequeueSelection => { + let tracks: Vec = self + .selection + .take_selection() + .into_iter() + .map(|s| s.id) + .collect(); + self.playback.dequeue(&tracks); + + vec![ + SelectionEvent::SelectionModeChanged(false).into(), + PlaybackEvent::PlaylistChanged.into(), + ] + } + AppAction::MoveDownSelection => { + let mut selection = self.selection.peek_selection(); + let playback = &mut self.playback; + selection + .next() + .and_then(|song| playback.move_down(&song.id)) + .map(|_| vec![PlaybackEvent::PlaylistChanged.into()]) + .unwrap_or_default() + } + AppAction::MoveUpSelection => { + let mut selection = self.selection.peek_selection(); + let playback = &mut self.playback; + selection + .next() + .and_then(|song| playback.move_up(&song.id)) + .map(|_| vec![PlaybackEvent::PlaylistChanged.into()]) + .unwrap_or_default() + } + AppAction::SaveSelection => { + let tracks = self.selection.take_selection(); + let mut events: Vec = forward_action( + BrowserAction::SaveTracks(tracks), + self.browser.home_state_mut().unwrap(), + ); + events.push(SelectionEvent::SelectionModeChanged(false).into()); + events + } + AppAction::UnsaveSelection => { + let tracks: Vec = self + .selection + .take_selection() + .into_iter() + .map(|s| s.id) + .collect(); + let mut events: Vec = forward_action( + BrowserAction::RemoveSavedTracks(tracks), + self.browser.home_state_mut().unwrap(), + ); + events.push(SelectionEvent::SelectionModeChanged(false).into()); + events + } + AppAction::EnableSelection(context) => { + if let Some(active) = self.selection.set_mode(Some(context)) { + vec![SelectionEvent::SelectionModeChanged(active).into()] + } else { + vec![] + } + } + AppAction::CancelSelection => { + if let Some(active) = self.selection.set_mode(None) { + vec![SelectionEvent::SelectionModeChanged(active).into()] + } else { + vec![] + } + } + AppAction::CreatePlaylist(playlist) => { + let id = playlist.id.clone(); + let mut events = forward_action( + LoginAction::PrependUserPlaylist(vec![playlist.clone().into()]), + &mut self.logged_user, + ); + let mut more_events = forward_action( + BrowserAction::PrependPlaylistsContent(vec![playlist]), + &mut self.browser, + ); + events.append(&mut more_events); + events.push(AppEvent::PlaylistCreatedNotificationShown(id)); + events + } + AppAction::UpdatePlaylistName(s) => { + let mut events = forward_action( + LoginAction::UpdateUserPlaylist(s.clone()), + &mut self.logged_user, + ); + let mut more_events = + forward_action(BrowserAction::UpdatePlaylistName(s), &mut self.browser); + events.append(&mut more_events); + events + } + // As for all other actions, we forward them to the substates :) + AppAction::PlaybackAction(a) => forward_action(a, &mut self.playback), + AppAction::BrowserAction(a) => forward_action(a, &mut self.browser), + AppAction::SelectionAction(a) => forward_action(a, &mut self.selection), + AppAction::LoginAction(a) => forward_action(a, &mut self.logged_user), + AppAction::SettingsAction(a) => forward_action(a, &mut self.settings), + _ => vec![], + } + } +} + +fn forward_action( + action: A, + target: &mut impl UpdatableState, +) -> Vec +where + A: Clone, + E: Into, +{ + target + .update_with(Cow::Owned(action)) + .into_iter() + .map(|e| e.into()) + .collect() +} diff --git a/src/app/state/browser_state.rs b/src/app/state/browser_state.rs new file mode 100644 index 0000000..34a4799 --- /dev/null +++ b/src/app/state/browser_state.rs @@ -0,0 +1,440 @@ +use super::{ + AppAction, AppEvent, ArtistState, DetailsState, HomeState, PlaylistDetailsState, ScreenName, + SearchState, UpdatableState, UserState, +}; +use crate::app::models::*; +use std::borrow::Cow; +use std::iter::Iterator; + +// Actions that affect any "screen" that we push over time +#[derive(Clone, Debug)] +pub enum BrowserAction { + SetNavigationHidden(bool), + SetHomeVisiblePage(&'static str), + SetLibraryContent(Vec), + PrependPlaylistsContent(Vec), + AppendLibraryContent(Vec), + SetPlaylistsContent(Vec), + AppendPlaylistsContent(Vec), + RemoveTracksFromPlaylist(String, Vec), + SetAlbumDetails(Box), + AppendAlbumTracks(String, Box), + SetPlaylistDetails(Box), + UpdatePlaylistName(PlaylistSummary), + AppendPlaylistTracks(String, Box), + Search(String), + SetSearchResults(Box), + SetArtistDetails(Box), + AppendArtistReleases(String, Vec), + NavigationPush(ScreenName), + NavigationPop, + NavigationPopTo(ScreenName), + SaveAlbum(Box), + UnsaveAlbum(String), + SetUserDetails(Box), + AppendUserPlaylists(String, Vec), + SetSavedTracks(Box), + AppendSavedTracks(Box), + SaveTracks(Vec), + RemoveSavedTracks(Vec), +} + +impl From for AppAction { + fn from(browser_action: BrowserAction) -> Self { + Self::BrowserAction(browser_action) + } +} + +#[derive(Eq, PartialEq, Clone, Debug)] +pub enum BrowserEvent { + NavigationHidden(bool), + HomeVisiblePageChanged(&'static str), + LibraryUpdated, + SavedPlaylistsUpdated, + AlbumDetailsLoaded(String), + AlbumTracksAppended(String), + PlaylistDetailsLoaded(String), + PlaylistTracksAppended(String), + PlaylistTracksRemoved(String), + SearchUpdated, + SearchResultsUpdated, + ArtistDetailsUpdated(String), + NavigationPushed(ScreenName), + NavigationPopped, + NavigationPoppedTo(ScreenName), + AlbumSaved(String), + AlbumUnsaved(String), + UserDetailsUpdated(String), + SavedTracksUpdated, +} + +impl From for AppEvent { + fn from(browser_event: BrowserEvent) -> Self { + Self::BrowserEvent(browser_event) + } +} + +// Any screen that can be "pushed" +pub enum BrowserScreen { + Home(Box), // Except this one is special, it's there at the start + AlbumDetails(Box), + Search(Box), + Artist(Box), + PlaylistDetails(Box), + User(Box), +} + +impl BrowserScreen { + fn from_name(name: &ScreenName) -> Self { + match name { + ScreenName::Home => BrowserScreen::Home(Default::default()), + ScreenName::AlbumDetails(id) => { + BrowserScreen::AlbumDetails(Box::new(DetailsState::new(id.to_string()))) + } + ScreenName::Search => BrowserScreen::Search(Default::default()), + ScreenName::Artist(id) => { + BrowserScreen::Artist(Box::new(ArtistState::new(id.to_string()))) + } + ScreenName::PlaylistDetails(id) => { + BrowserScreen::PlaylistDetails(Box::new(PlaylistDetailsState::new(id.to_string()))) + } + ScreenName::User(id) => BrowserScreen::User(Box::new(UserState::new(id.to_string()))), + } + } + + // Each screen has a state that can be updated with a BrowserAction + fn state(&mut self) -> &mut dyn UpdatableState { + match self { + Self::Home(state) => &mut **state, + Self::AlbumDetails(state) => &mut **state, + Self::Search(state) => &mut **state, + Self::Artist(state) => &mut **state, + Self::PlaylistDetails(state) => &mut **state, + Self::User(state) => &mut **state, + } + } +} + +impl NamedScreen for BrowserScreen { + type Name = ScreenName; + + fn name(&self) -> &Self::Name { + match self { + Self::Home(state) => &state.name, + Self::AlbumDetails(state) => &state.name, + Self::Search(state) => &state.name, + Self::Artist(state) => &state.name, + Self::PlaylistDetails(state) => &state.name, + Self::User(state) => &state.name, + } + } +} + +pub trait NamedScreen { + type Name: PartialEq; + fn name(&self) -> &Self::Name; +} + +#[derive(Debug)] +enum ScreenState { + NotPresent, + Present, + Current, +} + +// The navigation stack where we push screens (something with an equatable name) +struct NavStack(Vec); + +impl NavStack +where + Screen: NamedScreen, +{ + // Its len is guaranteed to be always 1 (see can_pop) + fn new(initial: Screen) -> Self { + Self(vec![initial]) + } + + fn iter_mut(&mut self) -> impl Iterator { + self.0.iter_mut().rev() + } + + fn iter_rev(&self) -> impl Iterator { + self.0.iter().rev() + } + + fn count(&self) -> usize { + self.0.len() + } + + fn current(&self) -> &Screen { + self.0.last().unwrap() + } + + fn current_mut(&mut self) -> &mut Screen { + self.0.last_mut().unwrap() + } + + fn can_pop(&self) -> bool { + self.0.len() > 1 + } + + fn push(&mut self, screen: Screen) { + self.0.push(screen) + } + + fn pop(&mut self) -> bool { + if self.can_pop() { + self.0.pop(); + true + } else { + false + } + } + + fn pop_to(&mut self, name: &Screen::Name) { + let split = self.0.iter().position(|s| s.name() == name).unwrap(); + self.0.truncate(split + 1); + } + + fn screen_visibility(&self, name: &Screen::Name) -> ScreenState { + // We iterate screens in reverse order + self.0 + .iter() + .rev() + .enumerate() + .find_map(|(i, screen)| { + let is_screen = screen.name() == name; + match (i, is_screen) { + // If we find the screen at pos 0 (last), it's therefore the current screen + (0, true) => Some(ScreenState::Current), + (_, true) => Some(ScreenState::Present), + (_, _) => None, + } + }) + .unwrap_or(ScreenState::NotPresent) + } +} + +pub struct BrowserState { + navigation_hidden: bool, + navigation: NavStack, +} + +macro_rules! extract_state { + ($e:expr, $p:pat if $guard:expr => $i:ident) => { + extract_state_full!($e, $p if $guard => $i) + }; + ($e:expr, $p:pat => $i:ident) => { + extract_state_full!($e, $p if true => $i) + }; +} + +macro_rules! extract_state_full { + ($e:expr, $p:pat if $guard:expr => $i:ident) => {{ + $e.navigation.iter_rev().find_map(|screen| match screen { + $p if $guard => Some(&**$i), + _ => None, + }) + }}; +} + +impl BrowserState { + pub fn new() -> Self { + Self { + navigation_hidden: false, + navigation: NavStack::new(BrowserScreen::Home(Default::default())), + } + } + + pub fn current_screen(&self) -> &ScreenName { + self.navigation.current().name() + } + + pub fn can_pop(&self) -> bool { + self.navigation.can_pop() || self.navigation_hidden + } + + pub fn count(&self) -> usize { + self.navigation.count() + } + + pub fn home_state(&self) -> Option<&HomeState> { + extract_state!(self, BrowserScreen::Home(s) => s) + } + + pub fn home_state_mut(&mut self) -> Option<&mut HomeState> { + self.navigation.iter_mut().find_map(|screen| match screen { + BrowserScreen::Home(s) => Some(&mut **s), + _ => None, + }) + } + + pub fn details_state(&self, id: &str) -> Option<&DetailsState> { + extract_state!(self, BrowserScreen::AlbumDetails(state) if state.id == id => state) + } + + pub fn search_state(&self) -> Option<&SearchState> { + extract_state!(self, BrowserScreen::Search(s) => s) + } + + pub fn artist_state(&self, id: &str) -> Option<&ArtistState> { + extract_state!(self, BrowserScreen::Artist(state) if state.id == id => state) + } + + pub fn playlist_details_state(&self, id: &str) -> Option<&PlaylistDetailsState> { + extract_state!(self, BrowserScreen::PlaylistDetails(state) if state.id == id => state) + } + + pub fn user_state(&self, id: &str) -> Option<&UserState> { + extract_state!(self, BrowserScreen::User(state) if state.id == id => state) + } + + // If a screen we want to push is already in the stack + // we just pop all the way back to it + fn push_if_needed(&mut self, name: &ScreenName) -> Vec { + let navigation = &mut self.navigation; + let screen_visibility = navigation.screen_visibility(name); + + match screen_visibility { + ScreenState::Current => vec![], + ScreenState::Present => { + navigation.pop_to(name); + vec![BrowserEvent::NavigationPoppedTo(name.clone())] + } + ScreenState::NotPresent => { + navigation.push(BrowserScreen::from_name(name)); + vec![BrowserEvent::NavigationPushed(name.clone())] + } + } + } +} + +impl UpdatableState for BrowserState { + type Action = BrowserAction; + type Event = BrowserEvent; + + fn update_with(&mut self, action: Cow) -> Vec { + let can_pop = self.navigation.can_pop(); + let action_ref = action.as_ref(); + + match action_ref { + BrowserAction::SetNavigationHidden(navigation_hidden) => { + self.navigation_hidden = *navigation_hidden; + vec![BrowserEvent::NavigationHidden(*navigation_hidden)] + } + // The search action will be handled here first before being passed down + // to push the search screen if it's not there already + BrowserAction::Search(_) => { + let mut events = self.push_if_needed(&ScreenName::Search); + + let mut update_events = self.navigation.current_mut().state().update_with(action); + events.append(&mut update_events); + events + } + BrowserAction::NavigationPush(name) => self.push_if_needed(name), + BrowserAction::NavigationPopTo(name) => { + self.navigation.pop_to(name); + vec![BrowserEvent::NavigationPoppedTo(name.clone())] + } + BrowserAction::NavigationPop if can_pop => { + self.navigation.pop(); + vec![BrowserEvent::NavigationPopped] + } + BrowserAction::NavigationPop if self.navigation_hidden => { + self.navigation_hidden = false; + vec![BrowserEvent::NavigationHidden(false)] + } + // Besides navigation actions, we just forward actions to each dedicated reducer + _ => self + .navigation + .iter_mut() + .flat_map(|s| s.state().update_with(Cow::Borrowed(action_ref))) + .collect(), + } + } +} + +#[cfg(test)] +pub mod tests { + + use super::*; + + #[test] + fn test_navigation_push() { + let mut state = BrowserState::new(); + + assert_eq!(*state.current_screen(), ScreenName::Home); + assert_eq!(state.count(), 1); + + let new_screen = ScreenName::Artist("some_id".to_string()); + state.update_with(Cow::Owned(BrowserAction::NavigationPush( + new_screen.clone(), + ))); + + assert_eq!(state.current_screen(), &new_screen); + assert_eq!(state.count(), 2); + assert_eq!(state.artist_state("some_id").is_some(), true); + } + + #[test] + fn test_navigation_pop() { + let mut state = BrowserState::new(); + let new_screen = ScreenName::Artist("some_id".to_string()); + state.update_with(Cow::Owned(BrowserAction::NavigationPush( + new_screen.clone(), + ))); + + assert_eq!(state.current_screen(), &new_screen); + assert_eq!(state.count(), 2); + + state.update_with(Cow::Owned(BrowserAction::NavigationPop)); + assert_eq!(state.current_screen(), &ScreenName::Home); + assert_eq!(state.count(), 1); + + let events = state.update_with(Cow::Owned(BrowserAction::NavigationPop)); + assert_eq!(state.current_screen(), &ScreenName::Home); + assert_eq!(state.count(), 1); + assert_eq!(events, vec![]); + } + + #[test] + fn test_navigation_push_same_screen() { + let mut state = BrowserState::new(); + let new_screen = ScreenName::Artist("some_id".to_string()); + state.update_with(Cow::Owned(BrowserAction::NavigationPush( + new_screen.clone(), + ))); + + assert_eq!(state.current_screen(), &new_screen); + assert_eq!(state.count(), 2); + + let events = state.update_with(Cow::Owned(BrowserAction::NavigationPush( + new_screen.clone(), + ))); + assert_eq!(state.current_screen(), &new_screen); + assert_eq!(state.count(), 2); + assert_eq!(events, vec![]); + } + + #[test] + fn test_navigation_push_same_screen_will_pop() { + let mut state = BrowserState::new(); + let new_screen = ScreenName::Artist("some_id".to_string()); + state.update_with(Cow::Owned(BrowserAction::NavigationPush( + new_screen.clone(), + ))); + state.update_with(Cow::Owned(BrowserAction::NavigationPush( + ScreenName::Search, + ))); + + assert_eq!(state.current_screen(), &ScreenName::Search); + assert_eq!(state.count(), 3); + + let events = state.update_with(Cow::Owned(BrowserAction::NavigationPush( + new_screen.clone(), + ))); + assert_eq!(state.current_screen(), &new_screen); + assert_eq!(state.count(), 2); + assert_eq!(events, vec![BrowserEvent::NavigationPoppedTo(new_screen)]); + } +} diff --git a/src/app/state/login_state.rs b/src/app/state/login_state.rs new file mode 100644 index 0000000..39fae50 --- /dev/null +++ b/src/app/state/login_state.rs @@ -0,0 +1,153 @@ +use gettextrs::*; +use std::borrow::Cow; +use std::time::SystemTime; + +use crate::app::credentials::Credentials; +use crate::app::models::PlaylistSummary; +use crate::app::state::{AppAction, AppEvent, UpdatableState}; + +#[derive(Clone, Debug)] +pub enum TryLoginAction { + Password { username: String, password: String }, + Token { username: String, token: String }, + OAuthSpotify {}, +} + +#[derive(Clone, Debug)] +pub enum SetLoginSuccessAction { + Password(Credentials), + Token(Credentials), +} + +#[derive(Clone, Debug)] +pub enum LoginAction { + ShowLogin, + TryLogin(TryLoginAction), + SetLoginSuccess(SetLoginSuccessAction), + SetUserPlaylists(Vec), + UpdateUserPlaylist(PlaylistSummary), + PrependUserPlaylist(Vec), + SetLoginFailure, + RefreshToken, + SetRefreshedToken { + token: String, + token_expiry_time: SystemTime, + }, + Logout, +} + +impl From for AppAction { + fn from(login_action: LoginAction) -> Self { + Self::LoginAction(login_action) + } +} + +#[derive(Clone, Debug)] +pub enum LoginStartedEvent { + Password { username: String, password: String }, + Token { username: String, token: String }, + OAuthSpotify {}, +} + +#[derive(Clone, Debug)] +pub enum LoginCompletedEvent { + Password(Credentials), + Token(Credentials), +} + +#[derive(Clone, Debug)] +pub enum LoginEvent { + LoginShown, + LoginStarted(LoginStartedEvent), + LoginCompleted(LoginCompletedEvent), + UserPlaylistsLoaded, + LoginFailed, + FreshTokenRequested, + RefreshTokenCompleted { + token: String, + token_expiry_time: SystemTime, + }, + LogoutCompleted, +} + +impl From for AppEvent { + fn from(login_event: LoginEvent) -> Self { + Self::LoginEvent(login_event) + } +} + +#[derive(Default)] +pub struct LoginState { + // Username + pub user: Option, + // Playlists owned by the logged in user + pub playlists: Vec, +} + +impl UpdatableState for LoginState { + type Action = LoginAction; + type Event = AppEvent; + + // The login state has a lot of actions that just translate to events + fn update_with(&mut self, action: Cow) -> Vec { + info!("update_with({:?})", action); + match action.into_owned() { + LoginAction::ShowLogin => vec![LoginEvent::LoginShown.into()], + LoginAction::TryLogin(TryLoginAction::Password { username, password }) => { + vec![ + LoginEvent::LoginStarted(LoginStartedEvent::Password { username, password }) + .into(), + ] + } + LoginAction::TryLogin(TryLoginAction::Token { username, token }) => { + vec![LoginEvent::LoginStarted(LoginStartedEvent::Token { username, token }).into()] + } + LoginAction::SetLoginSuccess(SetLoginSuccessAction::Password(creds)) => { + self.user = Some(creds.username.clone()); + vec![LoginEvent::LoginCompleted(LoginCompletedEvent::Password(creds)).into()] + } + LoginAction::SetLoginSuccess(SetLoginSuccessAction::Token(creds)) => { + self.user = Some(creds.username.clone()); + vec![LoginEvent::LoginCompleted(LoginCompletedEvent::Token(creds)).into()] + } + LoginAction::SetLoginFailure => vec![LoginEvent::LoginFailed.into()], + LoginAction::RefreshToken => vec![LoginEvent::FreshTokenRequested.into()], + LoginAction::SetRefreshedToken { + token, + token_expiry_time, + } => { + // translators: This notification is shown when, after some inactivity, the session is successfully restored. The user might have to repeat its last action. + vec![ + AppEvent::NotificationShown(gettext("Connection restored")), + LoginEvent::RefreshTokenCompleted { + token, + token_expiry_time, + } + .into(), + ] + } + LoginAction::Logout => { + self.user = None; + vec![LoginEvent::LogoutCompleted.into()] + } + LoginAction::SetUserPlaylists(playlists) => { + self.playlists = playlists; + vec![LoginEvent::UserPlaylistsLoaded.into()] + } + LoginAction::UpdateUserPlaylist(PlaylistSummary { id, title }) => { + if let Some(p) = self.playlists.iter_mut().find(|p| p.id == id) { + p.title = title; + } + vec![LoginEvent::UserPlaylistsLoaded.into()] + } + LoginAction::PrependUserPlaylist(mut summaries) => { + summaries.append(&mut self.playlists); + self.playlists = summaries; + vec![LoginEvent::UserPlaylistsLoaded.into()] + } + LoginAction::TryLogin(TryLoginAction::OAuthSpotify { .. }) => { + vec![LoginEvent::LoginStarted(LoginStartedEvent::OAuthSpotify {}).into()] + } + } + } +} diff --git a/src/app/state/mod.rs b/src/app/state/mod.rs new file mode 100644 index 0000000..2c3d272 --- /dev/null +++ b/src/app/state/mod.rs @@ -0,0 +1,25 @@ +mod app_model; +mod app_state; +mod browser_state; +mod login_state; +mod pagination; +mod playback_state; +mod screen_states; +mod selection_state; +mod settings_state; + +pub use app_model::AppModel; +pub use app_state::*; +pub use browser_state::*; +pub use login_state::*; +pub use playback_state::*; +pub use screen_states::*; +pub use selection_state::*; +pub use settings_state::*; + +pub trait UpdatableState { + type Action: Clone; + type Event; + + fn update_with(&mut self, action: std::borrow::Cow) -> Vec; +} diff --git a/src/app/state/pagination.rs b/src/app/state/pagination.rs new file mode 100644 index 0000000..b92a0e6 --- /dev/null +++ b/src/app/state/pagination.rs @@ -0,0 +1,60 @@ +// A structure for batched queries that I introduced before proper batch management +// Still used to load album lists for instance +// Doesn't know how many elements exist in total ahead of time +#[derive(Clone, Debug)] +pub struct Pagination +where + T: Clone, +{ + pub data: T, + // The next offset (of things to load) is set to None whenever we get less than we asked for + // as it probably means we've reached the end of some list + pub next_offset: Option, + pub batch_size: usize, +} + +impl Pagination +where + T: Clone, +{ + pub fn new(data: T, batch_size: usize) -> Self { + Self { + data, + next_offset: Some(0), + batch_size, + } + } + + pub fn reset_count(&mut self, new_length: usize) { + self.next_offset = if new_length >= self.batch_size { + Some(self.batch_size) + } else { + None + } + } + + pub fn set_loaded_count(&mut self, loaded_count: usize) { + if let Some(offset) = self.next_offset.take() { + self.next_offset = if loaded_count >= self.batch_size { + Some(offset + self.batch_size) + } else { + None + } + } + } + + // If we remove elements from paginated data without refetching from the source, + // we have to adjust the next offset to load + pub fn decrement(&mut self) { + if let Some(offset) = self.next_offset.take() { + self.next_offset = Some(offset - 1); + } + } + + // Same idea as decrement + pub fn increment(&mut self) { + if let Some(offset) = self.next_offset.take() { + self.next_offset = Some(offset + 1); + } + } +} diff --git a/src/app/state/playback_state.rs b/src/app/state/playback_state.rs new file mode 100644 index 0000000..5e07c94 --- /dev/null +++ b/src/app/state/playback_state.rs @@ -0,0 +1,785 @@ +use std::borrow::Cow; +use std::time::Instant; + +use crate::app::models::*; +use crate::app::state::{AppAction, AppEvent, UpdatableState}; +use crate::app::{BatchQuery, LazyRandomIndex, SongsSource}; + +#[derive(Debug)] +pub struct PlaybackState { + available_devices: Vec, + current_device: Device, + // A mapping of indices for shuffled playback + index: LazyRandomIndex, + // The actual list like thing backing the currently playing tracks + songs: SongListModel, + list_position: Option, + seek_position: PositionMillis, + source: Option, + repeat: RepeatMode, + is_playing: bool, + is_shuffled: bool, +} + +// Most mutatings methods shouldn't be pub +// If they are, they probably are only used by the app state +impl PlaybackState { + pub fn songs(&self) -> &SongListModel { + &self.songs + } + + pub fn is_playing(&self) -> bool { + self.is_playing && self.list_position.is_some() + } + + pub fn is_shuffled(&self) -> bool { + self.is_shuffled + } + + pub fn repeat_mode(&self) -> RepeatMode { + self.repeat + } + + // Whatever batch of songs we would need to grab if we were to play the next track + pub fn next_query(&self) -> Option { + let next_index = self.next_index()?; + let next_index = if self.is_shuffled { + self.index.get(next_index)? + } else { + next_index + }; + let batch = self.songs.needed_batch_for(next_index); + if let Some(batch) = batch { + let source = self.source.as_ref().cloned()?; + Some(BatchQuery { source, batch }) + } else { + None + } + } + + fn index(&self, i: usize) -> Option { + let song = if self.is_shuffled { + self.songs.index(self.index.get(i)?) + } else { + self.songs.index(i) + }; + Some(song?.into_description()) + } + + pub fn current_source(&self) -> Option<&SongsSource> { + self.source.as_ref() + } + + pub fn current_song_index(&self) -> Option { + self.list_position + } + + pub fn current_song_id(&self) -> Option { + Some(self.index(self.list_position?)?.id) + } + + pub fn current_song(&self) -> Option { + self.index(self.list_position?) + } + + fn next_id(&self) -> Option { + self.next_index() + .and_then(|i| Some(self.songs().index(i)?.description().id.clone())) + } + + fn clear(&mut self, source: Option) -> SongListModelPending { + self.source = source; + self.index = Default::default(); + self.list_position = None; + self.songs.clear() + } + + // Replaces (!) the current playlist with the contents of a song batch + fn set_batch(&mut self, source: Option, song_batch: SongBatch) -> bool { + let ok = self.clear(source).and(|s| s.add(song_batch)).commit(); + self.index.resize(self.songs.len()); + ok + } + + fn add_batch(&mut self, song_batch: SongBatch) -> bool { + let ok = self.songs.add(song_batch).commit(); + self.index.resize(self.songs.len()); + ok + } + + // Replaces (!) the current playlist with a bunch of songs (not batched, not expected to grow) + fn set_queue(&mut self, tracks: Vec) { + self.clear(None).and(|s| s.append(tracks)).commit(); + self.index.grow(self.songs.len()); + } + + pub fn queue(&mut self, tracks: Vec) { + self.source = None; + self.songs.append(tracks).commit(); + self.index.grow(self.songs.len()); + } + + pub fn dequeue(&mut self, ids: &[String]) { + let current_id = self.current_song_id(); + self.songs.remove(ids).commit(); + self.list_position = current_id.and_then(|id| self.songs.find_index(&id)); + self.index.shrink(self.songs.len()); + } + + // Update the current playing track (identified by a position in the list) if we're swapping songs + fn swap_pos(&mut self, index: usize, other_index: usize) { + let len = self.songs.len(); + self.list_position = self + .list_position + .map(|position| match position { + i if i == index => other_index, + i if i == other_index => index, + _ => position, + }) + .map(|p| usize::min(p, len - 1)) + } + + pub fn move_down(&mut self, id: &str) -> Option { + let index = self.songs.find_index(id)?; + self.songs.move_down(index).commit(); + self.swap_pos(index + 1, index); + Some(index) + } + + pub fn move_up(&mut self, id: &str) -> Option { + let index = self.songs.find_index(id).filter(|&index| index > 0)?; + self.songs.move_up(index).commit(); + self.swap_pos(index - 1, index); + Some(index) + } + + fn play(&mut self, id: &str) -> bool { + if self.current_song_id().map(|cur| cur == id).unwrap_or(false) { + return false; + } + + let found_index = self.songs.find_index(id); + + if let Some(index) = found_index { + // If shufflings songs, we make sure the track we just picked is the first to come up + if self.is_shuffled { + self.index.reset_picking_first(index); + self.play_index(0); + } else { + self.play_index(index); + } + true + } else { + false + } + } + + fn stop(&mut self) { + self.list_position = None; + self.is_playing = false; + self.seek_position.set(0, false); + } + + fn play_index(&mut self, index: usize) -> Option { + self.is_playing = true; + self.list_position.replace(index); + self.seek_position.set(0, true); + self.index.next_until(index + 1); + self.current_song_id() + } + + fn play_next(&mut self) -> Option { + self.next_index().and_then(|i| { + self.seek_position.set(0, true); + self.play_index(i) + }) + } + + pub fn next_index(&self) -> Option { + let len = self.songs.len(); + self.list_position.and_then(|p| match self.repeat { + RepeatMode::Song => Some(p), + RepeatMode::Playlist if len != 0 => Some((p + 1) % len), + RepeatMode::None => Some(p + 1).filter(|&i| i < len), + _ => None, + }) + } + + fn play_prev(&mut self) -> Option { + self.prev_index().and_then(|i| { + // Only jump to the previous track if we aren't more than 2 seconds (2,000 ms) into the current track. + // Otherwise, seek to the start of the current track. + // (This replicates the behavior of official Spotify clients.) + if self.seek_position.current() <= 2000 { + self.seek_position.set(0, true); + self.play_index(i) + } else { + self.seek_position.set(0, true); + None + } + }) + } + + pub fn prev_index(&self) -> Option { + let len = self.songs.len(); + self.list_position.and_then(|p| match self.repeat { + RepeatMode::Song => Some(p), + RepeatMode::Playlist if len != 0 => Some((if p == 0 { len } else { p }) - 1), + RepeatMode::None => Some(p).filter(|&i| i > 0).map(|i| i - 1), + _ => None, + }) + } + + fn toggle_play(&mut self) -> Option { + if self.list_position.is_some() { + self.is_playing = !self.is_playing; + + match self.is_playing { + false => self.seek_position.pause(), + true => self.seek_position.resume(), + }; + + Some(self.is_playing) + } else { + None + } + } + + fn set_shuffled(&mut self, shuffled: bool) { + self.is_shuffled = shuffled; + let old = self.list_position.replace(0).unwrap_or(0); + self.index.reset_picking_first(old); + } + + pub fn available_devices(&self) -> &Vec { + &self.available_devices + } + + pub fn current_device(&self) -> &Device { + &self.current_device + } +} + +impl Default for PlaybackState { + fn default() -> Self { + Self { + available_devices: vec![], + current_device: Device::Local, + index: LazyRandomIndex::default(), + songs: SongListModel::new(50), + list_position: None, + seek_position: PositionMillis::new(1.0), + source: None, + repeat: RepeatMode::None, + is_playing: false, + is_shuffled: false, + } + } +} + +#[derive(Clone, Debug)] +pub enum PlaybackAction { + TogglePlay, + Play, + Pause, + Stop, + SetRepeatMode(RepeatMode), + SetShuffled(bool), + ToggleRepeat, + ToggleShuffle, + Seek(u32), + // I can't remember the diff betweek Seek and SyncSeek right now. Probably the source of the action + SyncSeek(u32), + Load(String), + LoadSongs(Vec), + LoadPagedSongs(SongsSource, SongBatch), + SetVolume(f64), + Next, + Previous, + Preload, + Queue(Vec), + Dequeue(String), + SwitchDevice(Device), + SetAvailableDevices(Vec), +} + +impl From for AppAction { + fn from(playback_action: PlaybackAction) -> Self { + Self::PlaybackAction(playback_action) + } +} + +#[derive(Clone, Debug)] +pub enum Device { + Local, + Connect(ConnectDevice), +} + +#[derive(Clone, Debug)] +pub enum PlaybackEvent { + PlaybackPaused, + PlaybackResumed, + RepeatModeChanged(RepeatMode), + TrackSeeked(u32), + SeekSynced(u32), + VolumeSet(f64), + TrackChanged(String), + SourceChanged, + Preload(String), + ShuffleChanged(bool), + PlaylistChanged, + PlaybackStopped, + SwitchedDevice(Device), + AvailableDevicesChanged, +} + +impl From for AppEvent { + fn from(playback_event: PlaybackEvent) -> Self { + Self::PlaybackEvent(playback_event) + } +} + +impl UpdatableState for PlaybackState { + type Action = PlaybackAction; + type Event = PlaybackEvent; + + // Main "reducer" :) + fn update_with(&mut self, action: Cow) -> Vec { + match action.into_owned() { + PlaybackAction::TogglePlay => { + if let Some(playing) = self.toggle_play() { + if playing { + vec![PlaybackEvent::PlaybackResumed] + } else { + vec![PlaybackEvent::PlaybackPaused] + } + } else { + vec![] + } + } + PlaybackAction::Play => { + if !self.is_playing() && self.toggle_play() == Some(true) { + vec![PlaybackEvent::PlaybackResumed] + } else { + vec![] + } + } + PlaybackAction::Pause => { + if self.is_playing() && self.toggle_play() == Some(false) { + vec![PlaybackEvent::PlaybackPaused] + } else { + vec![] + } + } + PlaybackAction::ToggleRepeat => { + self.repeat = match self.repeat { + RepeatMode::Song => RepeatMode::None, + RepeatMode::Playlist => RepeatMode::Song, + RepeatMode::None => RepeatMode::Playlist, + }; + vec![PlaybackEvent::RepeatModeChanged(self.repeat)] + } + PlaybackAction::SetRepeatMode(mode) if self.repeat != mode => { + self.repeat = mode; + vec![PlaybackEvent::RepeatModeChanged(self.repeat)] + } + PlaybackAction::SetShuffled(shuffled) if self.is_shuffled != shuffled => { + self.set_shuffled(shuffled); + vec![PlaybackEvent::ShuffleChanged(shuffled)] + } + PlaybackAction::ToggleShuffle => { + self.set_shuffled(!self.is_shuffled); + vec![PlaybackEvent::ShuffleChanged(self.is_shuffled)] + } + PlaybackAction::Next => { + if let Some(id) = self.play_next() { + vec![ + PlaybackEvent::TrackChanged(id), + PlaybackEvent::PlaybackResumed, + ] + } else { + self.stop(); + vec![PlaybackEvent::PlaybackStopped] + } + } + PlaybackAction::Stop => { + self.stop(); + vec![PlaybackEvent::PlaybackStopped] + } + PlaybackAction::Previous => { + if let Some(id) = self.play_prev() { + vec![ + PlaybackEvent::TrackChanged(id), + PlaybackEvent::PlaybackResumed, + ] + } else { + vec![PlaybackEvent::TrackSeeked(0)] + } + } + PlaybackAction::Load(id) => { + if self.play(&id) { + vec![ + PlaybackEvent::TrackChanged(id), + PlaybackEvent::PlaybackResumed, + ] + } else { + vec![] + } + } + PlaybackAction::Preload => { + if let Some(id) = self.next_id() { + vec![PlaybackEvent::Preload(id)] + } else { + vec![] + } + } + PlaybackAction::LoadPagedSongs(source, batch) + if Some(&source) == self.source.as_ref() => + { + if self.add_batch(batch) { + vec![PlaybackEvent::PlaylistChanged] + } else { + vec![] + } + } + PlaybackAction::LoadPagedSongs(source, batch) + if Some(&source) != self.source.as_ref() => + { + debug!("new source: {:?}", &source); + self.set_batch(Some(source), batch); + vec![PlaybackEvent::PlaylistChanged, PlaybackEvent::SourceChanged] + } + PlaybackAction::LoadSongs(tracks) => { + self.set_queue(tracks); + vec![PlaybackEvent::PlaylistChanged, PlaybackEvent::SourceChanged] + } + PlaybackAction::Queue(tracks) => { + self.queue(tracks); + vec![PlaybackEvent::PlaylistChanged] + } + PlaybackAction::Dequeue(id) => { + self.dequeue(&[id]); + vec![PlaybackEvent::PlaylistChanged] + } + PlaybackAction::Seek(pos) => { + self.seek_position.set(pos as u64 * 1000, true); + vec![PlaybackEvent::TrackSeeked(pos)] + } + PlaybackAction::SyncSeek(pos) => { + self.seek_position.set(pos as u64 * 1000, true); + vec![PlaybackEvent::SeekSynced(pos)] + } + PlaybackAction::SetVolume(volume) => vec![PlaybackEvent::VolumeSet(volume)], + PlaybackAction::SetAvailableDevices(list) => { + self.available_devices = list; + vec![PlaybackEvent::AvailableDevicesChanged] + } + PlaybackAction::SwitchDevice(new_device) => { + self.current_device = new_device.clone(); + vec![PlaybackEvent::SwitchedDevice(new_device)] + } + _ => vec![], + } + } +} + +// A struct to keep track of the playback position +// Caller must call pause/play at the right time +#[derive(Debug)] +struct PositionMillis { + // Last recorded position in the track (in milliseconds) + last_known_position: u64, + // Last time we resumed playback + last_resume_instant: Option, + // Playback rate (1) + rate: f32, +} + +impl PositionMillis { + fn new(rate: f32) -> Self { + Self { + last_known_position: 0, + last_resume_instant: None, + rate, + } + } + + // Read the current pos by adding elapsed time since the last time we resumed playback to the last know position + fn current(&self) -> u64 { + let current_progress = self.last_resume_instant.map(|ri| { + let elapsed = ri.elapsed().as_millis() as f32; + let real_elapsed = self.rate * elapsed; + real_elapsed.ceil() as u64 + }); + self.last_known_position + current_progress.unwrap_or(0) + } + + fn set(&mut self, position: u64, playing: bool) { + self.last_known_position = position; + self.last_resume_instant = if playing { Some(Instant::now()) } else { None } + } + + fn pause(&mut self) { + self.last_known_position = self.current(); + self.last_resume_instant = None; + } + + fn resume(&mut self) { + self.last_resume_instant = Some(Instant::now()); + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use crate::app::models::AlbumRef; + + fn song(id: &str) -> SongDescription { + SongDescription { + id: id.to_string(), + uri: "".to_string(), + title: "Title".to_string(), + artists: vec![], + album: AlbumRef { + id: "".to_string(), + name: "".to_string(), + }, + duration: 1000, + art: None, + track_number: None, + } + } + + impl PlaybackState { + fn current_position(&self) -> Option { + self.list_position + } + + fn prev_id(&self) -> Option { + self.prev_index() + .and_then(|i| Some(self.songs().index(i)?.description().id.clone())) + } + + fn song_ids(&self) -> Vec { + self.songs() + .collect() + .iter() + .map(|s| s.id.clone()) + .collect() + } + } + + #[test] + fn test_initial_state() { + let state = PlaybackState::default(); + assert!(!state.is_playing()); + assert!(!state.is_shuffled()); + assert!(state.current_song().is_none()); + assert!(state.prev_index().is_none()); + assert!(state.next_index().is_none()); + } + + #[test] + fn test_play_one() { + let mut state = PlaybackState::default(); + state.queue(vec![song("foo")]); + + state.play("foo"); + assert!(state.is_playing()); + + assert_eq!(state.current_song_id(), Some("foo".to_string())); + assert!(state.prev_index().is_none()); + assert!(state.next_index().is_none()); + + state.toggle_play(); + assert!(!state.is_playing()); + } + + #[test] + fn test_queue() { + let mut state = PlaybackState::default(); + state.queue(vec![song("1"), song("2"), song("3")]); + + assert_eq!(state.songs().len(), 3); + + state.play("2"); + + state.queue(vec![song("4")]); + assert_eq!(state.songs().len(), 4); + } + + #[test] + fn test_play_multiple() { + let mut state = PlaybackState::default(); + state.queue(vec![song("1"), song("2"), song("3")]); + assert_eq!(state.songs().len(), 3); + + state.play("2"); + assert!(state.is_playing()); + + assert_eq!(state.current_position(), Some(1)); + assert_eq!(state.prev_id(), Some("1".to_string())); + assert_eq!(state.current_song_id(), Some("2".to_string())); + assert_eq!(state.next_id(), Some("3".to_string())); + + state.toggle_play(); + assert!(!state.is_playing()); + + state.play_next(); + assert!(state.is_playing()); + assert_eq!(state.current_position(), Some(2)); + assert_eq!(state.prev_id(), Some("2".to_string())); + assert_eq!(state.current_song_id(), Some("3".to_string())); + assert!(state.next_index().is_none()); + + state.play_next(); + assert!(state.is_playing()); + assert_eq!(state.current_position(), Some(2)); + assert_eq!(state.current_song_id(), Some("3".to_string())); + + state.play_prev(); + state.play_prev(); + assert!(state.is_playing()); + assert_eq!(state.current_position(), Some(0)); + assert!(state.prev_index().is_none()); + assert_eq!(state.current_song_id(), Some("1".to_string())); + assert_eq!(state.next_id(), Some("2".to_string())); + + state.play_prev(); + assert!(state.is_playing()); + assert_eq!(state.current_position(), Some(0)); + assert_eq!(state.current_song_id(), Some("1".to_string())); + } + + #[test] + fn test_shuffle() { + let mut state = PlaybackState::default(); + state.queue(vec![song("1"), song("2"), song("3"), song("4")]); + + assert_eq!(state.songs().len(), 4); + + state.play("2"); + assert_eq!(state.current_position(), Some(1)); + + state.set_shuffled(true); + assert!(state.is_shuffled()); + assert_eq!(state.current_position(), Some(0)); + + state.play_next(); + assert_eq!(state.current_position(), Some(1)); + + state.set_shuffled(false); + assert!(!state.is_shuffled()); + + let ids = state.song_ids(); + assert_eq!( + ids, + vec![ + "1".to_string(), + "2".to_string(), + "3".to_string(), + "4".to_string() + ] + ); + } + + #[test] + fn test_shuffle_queue() { + let mut state = PlaybackState::default(); + state.queue(vec![song("1"), song("2"), song("3")]); + + state.set_shuffled(true); + assert!(state.is_shuffled()); + + state.queue(vec![song("4")]); + + state.set_shuffled(false); + assert!(!state.is_shuffled()); + + let ids = state.song_ids(); + assert_eq!( + ids, + vec![ + "1".to_string(), + "2".to_string(), + "3".to_string(), + "4".to_string() + ] + ); + } + + #[test] + fn test_move() { + let mut state = PlaybackState::default(); + state.queue(vec![song("1"), song("2"), song("3")]); + + state.play("2"); + assert!(state.is_playing()); + + state.move_down("1"); + assert_eq!(state.current_song_id(), Some("2".to_string())); + let ids = state.song_ids(); + assert_eq!(ids, vec!["2".to_string(), "1".to_string(), "3".to_string()]); + + state.move_down("2"); + state.move_down("2"); + assert_eq!(state.current_song_id(), Some("2".to_string())); + let ids = state.song_ids(); + assert_eq!(ids, vec!["1".to_string(), "3".to_string(), "2".to_string()]); + + state.move_down("2"); + assert_eq!(state.current_song_id(), Some("2".to_string())); + let ids = state.song_ids(); + assert_eq!(ids, vec!["1".to_string(), "3".to_string(), "2".to_string()]); + + state.move_up("2"); + + assert_eq!(state.current_song_id(), Some("2".to_string())); + let ids = state.song_ids(); + assert_eq!(ids, vec!["1".to_string(), "2".to_string(), "3".to_string()]); + } + + #[test] + fn test_dequeue_last() { + let mut state = PlaybackState::default(); + state.queue(vec![song("1"), song("2"), song("3")]); + + state.play("3"); + assert!(state.is_playing()); + + state.dequeue(&["3".to_string()]); + assert_eq!(state.current_song_id(), None); + } + + #[test] + fn test_dequeue_a_few_songs() { + let mut state = PlaybackState::default(); + state.queue(vec![ + song("1"), + song("2"), + song("3"), + song("4"), + song("5"), + song("6"), + ]); + + state.play("5"); + assert!(state.is_playing()); + + state.dequeue(&["1".to_string(), "2".to_string(), "3".to_string()]); + assert_eq!(state.current_song_id(), Some("5".to_string())); + } + + #[test] + fn test_dequeue_all() { + let mut state = PlaybackState::default(); + state.queue(vec![song("3")]); + + state.play("3"); + assert!(state.is_playing()); + + state.dequeue(&["3".to_string()]); + assert_eq!(state.current_song_id(), None); + } +} diff --git a/src/app/state/screen_states.rs b/src/app/state/screen_states.rs new file mode 100644 index 0000000..55240ae --- /dev/null +++ b/src/app/state/screen_states.rs @@ -0,0 +1,471 @@ +use std::borrow::Cow; +use std::cmp::PartialEq; + +use super::{pagination::Pagination, BrowserAction, BrowserEvent, UpdatableState}; +use crate::app::models::*; +use crate::app::ListStore; + +#[derive(Clone, Debug)] +pub enum ScreenName { + Home, + AlbumDetails(String), + Search, + Artist(String), + PlaylistDetails(String), + User(String), +} + +impl ScreenName { + pub fn identifier(&self) -> Cow { + match self { + Self::Home => Cow::Borrowed("home"), + Self::AlbumDetails(s) => Cow::Owned(format!("album_{s}")), + Self::Search => Cow::Borrowed("search"), + Self::Artist(s) => Cow::Owned(format!("artist_{s}")), + Self::PlaylistDetails(s) => Cow::Owned(format!("playlist_{s}")), + Self::User(s) => Cow::Owned(format!("user_{s}")), + } + } +} + +impl PartialEq for ScreenName { + fn eq(&self, other: &Self) -> bool { + self.identifier() == other.identifier() + } +} + +impl Eq for ScreenName {} + +// ALBUM details +pub struct DetailsState { + pub id: String, + pub name: ScreenName, + pub content: Option, + // Read the songs from here, not content (won't get more than the initial batch of songs) + pub songs: SongListModel, +} + +impl DetailsState { + pub fn new(id: String) -> Self { + Self { + id: id.clone(), + name: ScreenName::AlbumDetails(id), + content: None, + songs: SongListModel::new(50), + } + } +} + +impl UpdatableState for DetailsState { + type Action = BrowserAction; + type Event = BrowserEvent; + + fn update_with(&mut self, action: Cow) -> Vec { + match action.as_ref() { + BrowserAction::SetAlbumDetails(album) if album.description.id == self.id => { + let AlbumDescription { id, songs, .. } = album.description.clone(); + self.songs.add(songs).commit(); + self.content = Some(*album.clone()); + vec![BrowserEvent::AlbumDetailsLoaded(id)] + } + BrowserAction::AppendAlbumTracks(id, batch) if id == &self.id => { + self.songs.add(*batch.clone()).commit(); + vec![BrowserEvent::AlbumTracksAppended(id.clone())] + } + BrowserAction::SaveAlbum(album) if album.id == self.id => { + let id = album.id.clone(); + if let Some(album) = self.content.as_mut() { + album.description.is_liked = true; + vec![BrowserEvent::AlbumSaved(id)] + } else { + vec![] + } + } + BrowserAction::UnsaveAlbum(id) if id == &self.id => { + if let Some(album) = self.content.as_mut() { + album.description.is_liked = false; + vec![BrowserEvent::AlbumUnsaved(id.clone())] + } else { + vec![] + } + } + _ => vec![], + } + } +} + +pub struct PlaylistDetailsState { + pub id: String, + pub name: ScreenName, + pub playlist: Option, + // Read the songs from here, not content (won't get more than the initial batch of songs) + pub songs: SongListModel, +} + +impl PlaylistDetailsState { + pub fn new(id: String) -> Self { + Self { + id: id.clone(), + name: ScreenName::PlaylistDetails(id), + playlist: None, + songs: SongListModel::new(100), + } + } +} + +impl UpdatableState for PlaylistDetailsState { + type Action = BrowserAction; + type Event = BrowserEvent; + + fn update_with(&mut self, action: Cow) -> Vec { + match action.as_ref() { + BrowserAction::SetPlaylistDetails(playlist) if playlist.id == self.id => { + let PlaylistDescription { id, songs, .. } = *playlist.clone(); + self.songs.add(songs).commit(); + self.playlist = Some(*playlist.clone()); + vec![BrowserEvent::PlaylistDetailsLoaded(id)] + } + BrowserAction::UpdatePlaylistName(PlaylistSummary { id, title }) if id == &self.id => { + if let Some(p) = self.playlist.as_mut() { + p.title = title.clone(); + } + vec![BrowserEvent::PlaylistDetailsLoaded(self.id.clone())] + } + BrowserAction::AppendPlaylistTracks(id, song_batch) if id == &self.id => { + self.songs.add(*song_batch.clone()).commit(); + vec![BrowserEvent::PlaylistTracksAppended(id.clone())] + } + BrowserAction::RemoveTracksFromPlaylist(id, uris) if id == &self.id => { + self.songs.remove(&uris[..]).commit(); + vec![BrowserEvent::PlaylistTracksRemoved(self.id.clone())] + } + _ => vec![], + } + } +} + +pub struct ArtistState { + pub id: String, + pub name: ScreenName, + pub artist: Option, + pub next_page: Pagination, + pub albums: ListStore, + pub top_tracks: SongListModel, +} + +impl ArtistState { + pub fn new(id: String) -> Self { + Self { + id: id.clone(), + name: ScreenName::Artist(id.clone()), + artist: None, + next_page: Pagination::new(id, 20), + albums: ListStore::new(), + top_tracks: SongListModel::new(10), + } + } +} + +impl UpdatableState for ArtistState { + type Action = BrowserAction; + type Event = BrowserEvent; + + fn update_with(&mut self, action: Cow) -> Vec { + match action.as_ref() { + BrowserAction::SetArtistDetails(details) if details.id == self.id => { + let ArtistDescription { + id, + name, + albums, + mut top_tracks, + } = *details.clone(); + self.artist = Some(name); + self.albums + .replace_all(albums.into_iter().map(|a| a.into())); + self.next_page.reset_count(self.albums.len()); + + top_tracks.truncate(5); + self.top_tracks.append(top_tracks).commit(); + + vec![BrowserEvent::ArtistDetailsUpdated(id)] + } + BrowserAction::AppendArtistReleases(id, albums) if id == &self.id => { + self.next_page.set_loaded_count(albums.len()); + self.albums.extend(albums.iter().map(|a| a.into())); + vec![BrowserEvent::ArtistDetailsUpdated(self.id.clone())] + } + _ => vec![], + } + } +} + +// The "home" represents screens visible initially (saved albums, saved playlists, saved tracks) +pub struct HomeState { + pub name: ScreenName, + pub visible_page: &'static str, + pub next_albums_page: Pagination<()>, + pub albums: ListStore, + pub next_playlists_page: Pagination<()>, + pub playlists: ListStore, + pub saved_tracks: SongListModel, +} + +impl Default for HomeState { + fn default() -> Self { + Self { + name: ScreenName::Home, + visible_page: "library", + next_albums_page: Pagination::new((), 30), + albums: ListStore::new(), + next_playlists_page: Pagination::new((), 30), + playlists: ListStore::new(), + saved_tracks: SongListModel::new(50), + } + } +} + +impl UpdatableState for HomeState { + type Action = BrowserAction; + type Event = BrowserEvent; + + fn update_with(&mut self, action: Cow) -> Vec { + match action.as_ref() { + BrowserAction::SetHomeVisiblePage(page) => { + self.visible_page = *page; + vec![BrowserEvent::HomeVisiblePageChanged(page)] + } + BrowserAction::SetLibraryContent(content) => { + if !self.albums.eq(content, |a, b| a.uri() == b.id) { + self.albums.replace_all(content.iter().map(|a| a.into())); + self.next_albums_page.reset_count(self.albums.len()); + vec![BrowserEvent::LibraryUpdated] + } else { + vec![] + } + } + BrowserAction::PrependPlaylistsContent(content) => { + self.playlists.prepend(content.iter().map(|a| a.into())); + vec![BrowserEvent::SavedPlaylistsUpdated] + } + BrowserAction::AppendLibraryContent(content) => { + self.next_albums_page.set_loaded_count(content.len()); + self.albums.extend(content.iter().map(|a| a.into())); + vec![BrowserEvent::LibraryUpdated] + } + BrowserAction::SaveAlbum(album) => { + let album_id = album.id.clone(); + let already_present = self.albums.iter().any(|a| a.uri() == album_id); + if already_present { + vec![] + } else { + self.albums.insert(0, (*album.clone()).into()); + self.next_albums_page.increment(); + vec![BrowserEvent::LibraryUpdated] + } + } + BrowserAction::UnsaveAlbum(id) => { + let position = self.albums.iter().position(|a| a.uri() == *id); + if let Some(position) = position { + self.albums.remove(position as u32); + self.next_albums_page.decrement(); + vec![BrowserEvent::LibraryUpdated] + } else { + vec![] + } + } + BrowserAction::SetPlaylistsContent(content) => { + if !self.playlists.eq(content, |a, b| a.uri() == b.id) { + self.playlists.replace_all(content.iter().map(|a| a.into())); + self.next_playlists_page.reset_count(self.playlists.len()); + vec![BrowserEvent::SavedPlaylistsUpdated] + } else { + vec![] + } + } + BrowserAction::AppendPlaylistsContent(content) => { + self.next_playlists_page.set_loaded_count(content.len()); + self.playlists.extend(content.iter().map(|p| p.into())); + vec![BrowserEvent::SavedPlaylistsUpdated] + } + BrowserAction::UpdatePlaylistName(PlaylistSummary { id, title }) => { + if let Some(p) = self.playlists.iter().find(|p| &p.uri() == id) { + p.set_album(title.to_owned()); + } + vec![BrowserEvent::SavedPlaylistsUpdated] + } + BrowserAction::AppendSavedTracks(song_batch) => { + if self.saved_tracks.add(*song_batch.clone()).commit() { + vec![BrowserEvent::SavedTracksUpdated] + } else { + vec![] + } + } + BrowserAction::SetSavedTracks(song_batch) => { + let song_batch = *song_batch.clone(); + if self + .saved_tracks + .clear() + .and(|s| s.add(song_batch)) + .commit() + { + vec![BrowserEvent::SavedTracksUpdated] + } else { + vec![] + } + } + BrowserAction::SaveTracks(tracks) => { + self.saved_tracks.prepend(tracks.clone()).commit(); + vec![BrowserEvent::SavedTracksUpdated] + } + BrowserAction::RemoveSavedTracks(tracks) => { + self.saved_tracks.remove(&tracks[..]).commit(); + vec![BrowserEvent::SavedTracksUpdated] + } + _ => vec![], + } + } +} + +pub struct SearchState { + pub name: ScreenName, + pub query: String, + pub album_results: Vec, + pub artist_results: Vec, +} + +impl Default for SearchState { + fn default() -> Self { + Self { + name: ScreenName::Search, + query: "".to_owned(), + album_results: vec![], + artist_results: vec![], + } + } +} + +impl UpdatableState for SearchState { + type Action = BrowserAction; + type Event = BrowserEvent; + + fn update_with(&mut self, action: Cow) -> Vec { + match action.as_ref() { + BrowserAction::Search(query) if query != &self.query => { + self.query = query.clone(); + vec![BrowserEvent::SearchUpdated] + } + BrowserAction::SetSearchResults(results) => { + self.album_results = results.albums.clone(); + self.artist_results = results.artists.clone(); + vec![BrowserEvent::SearchResultsUpdated] + } + _ => vec![], + } + } +} + +// Screen when we click on the name of a playlist owner +pub struct UserState { + pub id: String, + pub name: ScreenName, + pub user: Option, + pub next_page: Pagination, + pub playlists: ListStore, +} + +impl UserState { + pub fn new(id: String) -> Self { + Self { + id: id.clone(), + name: ScreenName::User(id.clone()), + user: None, + next_page: Pagination::new(id, 30), + playlists: ListStore::new(), + } + } +} + +impl UpdatableState for UserState { + type Action = BrowserAction; + type Event = BrowserEvent; + + fn update_with(&mut self, action: Cow) -> Vec { + match action.as_ref() { + BrowserAction::SetUserDetails(user) if user.id == self.id => { + let UserDescription { + id, + name, + playlists, + } = *user.clone(); + self.user = Some(name); + self.playlists + .replace_all(playlists.iter().map(|p| p.into())); + self.next_page.reset_count(self.playlists.len()); + + vec![BrowserEvent::UserDetailsUpdated(id)] + } + BrowserAction::AppendUserPlaylists(id, playlists) if id == &self.id => { + self.next_page.set_loaded_count(playlists.len()); + self.playlists.extend(playlists.iter().map(|p| p.into())); + vec![BrowserEvent::UserDetailsUpdated(self.id.clone())] + } + _ => vec![], + } + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_next_page_no_next() { + let mut artist_state = ArtistState::new("id".to_owned()); + artist_state.update_with(Cow::Owned(BrowserAction::SetArtistDetails(Box::new( + ArtistDescription { + id: "id".to_owned(), + name: "Foo".to_owned(), + albums: vec![], + top_tracks: vec![], + }, + )))); + + let next = artist_state.next_page; + assert_eq!(None, next.next_offset); + } + + #[test] + fn test_next_page_more() { + let fake_album = AlbumDescription { + id: "".to_owned(), + title: "".to_owned(), + artists: vec![], + release_date: Some("1970-01-01".to_owned()), + art: Some("".to_owned()), + songs: SongBatch::empty(), + is_liked: false, + }; + let id = "id".to_string(); + let mut artist_state = ArtistState::new(id.clone()); + artist_state.update_with(Cow::Owned(BrowserAction::SetArtistDetails(Box::new( + ArtistDescription { + id: id.clone(), + name: "Foo".to_owned(), + albums: (0..20).map(|_| fake_album.clone()).collect(), + top_tracks: vec![], + }, + )))); + + let next = &artist_state.next_page; + assert_eq!(Some(20), next.next_offset); + + artist_state.update_with(Cow::Owned(BrowserAction::AppendArtistReleases( + id.clone(), + vec![], + ))); + + let next = &artist_state.next_page; + assert_eq!(None, next.next_offset); + } +} diff --git a/src/app/state/selection_state.rs b/src/app/state/selection_state.rs new file mode 100644 index 0000000..c1847b1 --- /dev/null +++ b/src/app/state/selection_state.rs @@ -0,0 +1,153 @@ +use std::borrow::Cow; +use std::collections::HashSet; + +use crate::app::models::SongDescription; +use crate::app::state::{AppAction, AppEvent, UpdatableState}; + +#[derive(Clone, Debug)] +pub enum SelectionAction { + Select(Vec), + Deselect(Vec), + Clear, +} + +impl From for AppAction { + fn from(selection_action: SelectionAction) -> Self { + Self::SelectionAction(selection_action) + } +} + +#[derive(Clone, Debug)] +pub enum SelectionEvent { + // Mode means selection active or not + SelectionModeChanged(bool), + SelectionChanged, +} + +impl From for AppEvent { + fn from(selection_event: SelectionEvent) -> Self { + Self::SelectionEvent(selection_event) + } +} + +#[derive(Debug, Clone)] +pub enum SelectionContext { + ReadOnlyQueue, + Queue, + Playlist, + EditablePlaylist(String), + SavedTracks, + Default, +} + +pub struct SelectionState { + selected_songs: Vec, + selected_songs_ids: HashSet, + selection_active: bool, + pub context: SelectionContext, +} + +impl Default for SelectionState { + fn default() -> Self { + Self { + selected_songs: Default::default(), + selected_songs_ids: Default::default(), + selection_active: false, + context: SelectionContext::Default, + } + } +} + +impl SelectionState { + fn select(&mut self, song: SongDescription) -> bool { + let selected = self.selected_songs_ids.contains(&song.id); + if !selected { + self.selected_songs_ids.insert(song.id.clone()); + self.selected_songs.push(song); + } + !selected + } + + fn deselect(&mut self, id: &str) -> bool { + let songs: Vec = std::mem::take(&mut self.selected_songs) + .into_iter() + .filter(|s| s.id != id) + .collect(); + self.selected_songs = songs; + self.selected_songs_ids.remove(id) + } + + pub fn set_mode(&mut self, context: Option) -> Option { + let currently_active = self.selection_active; + match (currently_active, context) { + (false, Some(context)) => { + *self = Default::default(); + self.selection_active = true; + self.context = context; + Some(true) + } + (true, None) => { + *self = Default::default(); + self.selection_active = false; + Some(false) + } + _ => None, + } + } + + pub fn is_selection_enabled(&self) -> bool { + self.selection_active + } + + pub fn is_song_selected(&self, id: &str) -> bool { + self.selected_songs_ids.contains(id) + } + + pub fn count(&self) -> usize { + self.selected_songs_ids.len() + } + + // Clears (!) the selection, returns associated memory + pub fn take_selection(&mut self) -> Vec { + std::mem::take(self).selected_songs + } + + // Just have a look at the selection without changing it + pub fn peek_selection(&self) -> impl Iterator { + self.selected_songs.iter() + } +} + +impl UpdatableState for SelectionState { + type Action = SelectionAction; + type Event = SelectionEvent; + + fn update_with(&mut self, action: Cow) -> Vec { + match action.into_owned() { + SelectionAction::Select(tracks) => { + let changed = tracks + .into_iter() + .fold(false, |result, track| self.select(track) || result); + if changed { + vec![SelectionEvent::SelectionChanged] + } else { + vec![] + } + } + SelectionAction::Deselect(ids) => { + let changed = ids + .iter() + .fold(false, |result, id| self.deselect(id) || result); + if changed { + vec![SelectionEvent::SelectionChanged] + } else { + vec![] + } + } + SelectionAction::Clear => { + self.take_selection(); + vec![SelectionEvent::SelectionModeChanged(false)] + } + } + } +} diff --git a/src/app/state/settings_state.rs b/src/app/state/settings_state.rs new file mode 100644 index 0000000..60e3b43 --- /dev/null +++ b/src/app/state/settings_state.rs @@ -0,0 +1,54 @@ +use crate::{ + app::state::{AppAction, AppEvent, UpdatableState}, + settings::SpotSettings, +}; + +#[derive(Clone, Debug)] +pub enum SettingsAction { + ChangeSettings, +} + +impl From for AppAction { + fn from(settings_action: SettingsAction) -> Self { + Self::SettingsAction(settings_action) + } +} + +#[derive(Clone, Debug)] +pub enum SettingsEvent { + PlayerSettingsChanged, +} + +impl From for AppEvent { + fn from(settings_event: SettingsEvent) -> Self { + Self::SettingsEvent(settings_event) + } +} + +#[derive(Default)] +pub struct SettingsState { + // Probably shouldn't be stored, the source of truth is GSettings anyway + pub settings: SpotSettings, +} + +impl UpdatableState for SettingsState { + type Action = SettingsAction; + type Event = AppEvent; + + fn update_with(&mut self, action: std::borrow::Cow) -> Vec { + match action.into_owned() { + SettingsAction::ChangeSettings => { + let old_settings = &self.settings; + let new_settings = SpotSettings::new_from_gsettings().unwrap_or_default(); + let player_settings_changed = + new_settings.player_settings != old_settings.player_settings; + self.settings = new_settings; + if player_settings_changed { + vec![SettingsEvent::PlayerSettingsChanged.into()] + } else { + vec![] + } + } + } + } +} diff --git a/src/config.rs.in b/src/config.rs.in new file mode 100644 index 0000000..05f5aff --- /dev/null +++ b/src/config.rs.in @@ -0,0 +1,5 @@ +// Configured by meson +pub static PKGDATADIR: &str = @PKGDATADIR@; +pub static VERSION: &str = "@VERSION@"; +pub static LOCALEDIR: &str = @LOCALEDIR@; +pub static APPID: &str = @APPID@; diff --git a/src/connect/mod.rs b/src/connect/mod.rs new file mode 100644 index 0000000..9f3cdde --- /dev/null +++ b/src/connect/mod.rs @@ -0,0 +1,47 @@ +use std::sync::Arc; +use std::time::Duration; + +use futures::channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender}; +use futures::StreamExt; +use tokio::{task, time}; + +use crate::api::SpotifyApiClient; +use crate::app::AppAction; + +mod player; +pub use player::ConnectCommand; + +#[tokio::main] +async fn connect_server( + api: Arc, + action_sender: UnboundedSender, + receiver: UnboundedReceiver, +) { + let player = Arc::new(player::ConnectPlayer::new(api, action_sender)); + + let player_clone = Arc::clone(&player); + task::spawn(async move { + let mut interval = time::interval(Duration::from_secs(5)); + loop { + interval.tick().await; + if player_clone.has_device() { + player_clone.sync_state().await; + } + } + }); + + receiver + .for_each(|command| async { player.handle_command(command).await.unwrap() }) + .await; +} + +pub fn start_connect_server( + api: Arc, + action_sender: UnboundedSender, +) -> UnboundedSender { + let (sender, receiver) = unbounded(); + + std::thread::spawn(move || connect_server(api, action_sender, receiver)); + + sender +} diff --git a/src/connect/player.rs b/src/connect/player.rs new file mode 100644 index 0000000..bffc51f --- /dev/null +++ b/src/connect/player.rs @@ -0,0 +1,257 @@ +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, RwLock}; + +use futures::channel::mpsc::UnboundedSender; +use gettextrs::gettext; + +use crate::api::{SpotifyApiClient, SpotifyApiError, SpotifyResult}; +use crate::app::models::{ConnectPlayerState, RepeatMode, SongDescription}; +use crate::app::state::{Device, PlaybackAction}; +use crate::app::{AppAction, SongsSource}; + +#[derive(Debug)] +pub enum ConnectCommand { + SetDevice(String), + PlayerLoadInContext { + source: SongsSource, + offset: usize, + song: String, + }, + PlayerLoad { + songs: Vec, + offset: usize, + }, + PlayerResume, + PlayerPause, + PlayerStop, + PlayerSeek(usize), + PlayerRepeat(RepeatMode), + PlayerShuffle(bool), + PlayerSetVolume(u8), +} + +pub struct ConnectPlayer { + api: Arc, + action_sender: UnboundedSender, + device_id: RwLock>, + last_queue: RwLock, + last_state: RwLock, +} + +impl ConnectPlayer { + pub fn new( + api: Arc, + action_sender: UnboundedSender, + ) -> Self { + Self { + api: api.clone(), + action_sender, + device_id: Default::default(), + last_queue: Default::default(), + last_state: Default::default(), + } + } + + fn send_actions(&self, actions: impl IntoIterator) { + for action in actions.into_iter() { + self.action_sender.unbounded_send(action).unwrap(); + } + } + + fn device_lost(&self) { + let _ = self.device_id.write().unwrap().take(); + self.send_actions([ + AppAction::ShowNotification(gettext("Connection to device lost!")), + PlaybackAction::SwitchDevice(Device::Local).into(), + PlaybackAction::SetAvailableDevices(vec![]).into(), + ]); + } + + async fn get_queue_if_changed(&self) -> Option> { + let last_queue = *self.last_queue.read().ok().as_deref().unwrap_or(&0u64); + let songs = self.api.get_player_queue().await.ok(); + songs.filter(|songs| { + let hash = { + let mut hasher = DefaultHasher::new(); + songs.hash(&mut hasher); + hasher.finish() + }; + if let Some(last_queue) = self.last_queue.try_write().ok().as_deref_mut() { + *last_queue = hash; + } + hash != last_queue + }) + } + + async fn apply_remote_state(&self, state: &ConnectPlayerState) { + if let Some(songs) = self.get_queue_if_changed().await { + self.send_actions([PlaybackAction::LoadSongs(songs).into()]); + } + + let play_pause = if state.is_playing { + PlaybackAction::Load(state.current_song_id.clone().unwrap()) + } else { + PlaybackAction::Pause + }; + + self.send_actions([ + play_pause.into(), + PlaybackAction::SetRepeatMode(state.repeat).into(), + PlaybackAction::SetShuffled(state.shuffle).into(), + PlaybackAction::SyncSeek(state.progress_ms).into(), + ]); + } + + pub fn has_device(&self) -> bool { + self.device_id + .read() + .map(|it| it.is_some()) + .unwrap_or(false) + } + + pub async fn sync_state(&self) { + debug!("polling connect device..."); + let player_state = self.api.player_state().await; + let Ok(state) = player_state else { + self.device_lost(); + return; + }; + self.apply_remote_state(&state).await; + if let Ok(mut last_state) = self.last_state.write() { + *last_state = state; + } + } + + async fn handle_player_load_in_context( + &self, + device_id: String, + current_state: &ConnectPlayerState, + command: ConnectCommand, + ) -> SpotifyResult<()> { + let ConnectCommand::PlayerLoadInContext { + source, + offset, + song, + } = command + else { + panic!("Illegal call"); + }; + let is_diff_song = current_state + .current_song_id + .as_ref() + .map(|it| it != &song) + .unwrap_or(true); + let is_paused = !current_state.is_playing; + if is_diff_song { + let context = source.spotify_uri().unwrap(); + self.api + .player_play_in_context(device_id, context, offset) + .await + } else if is_paused { + self.api.player_resume(device_id).await + } else { + Ok(()) + } + } + + async fn handle_player_load( + &self, + device_id: String, + current_state: &ConnectPlayerState, + command: ConnectCommand, + ) -> SpotifyResult<()> { + let ConnectCommand::PlayerLoad { songs, offset } = command else { + panic!("Illegal call"); + }; + let is_diff_song = current_state + .current_song_id + .as_ref() + .map(|it| it != &songs[offset]) + .unwrap_or(true); + let is_paused = !current_state.is_playing; + if is_diff_song { + self.api + .player_play_no_context( + device_id, + songs + .into_iter() + .map(|s| format!("spotify:track:{}", s)) + .collect(), + offset, + ) + .await + } else if is_paused { + self.api.player_resume(device_id).await + } else { + Ok(()) + } + } + + async fn handle_other_command( + &self, + device_id: String, + command: ConnectCommand, + ) -> SpotifyResult<()> { + let state = self.last_state.read(); + let Ok(state) = state.as_deref() else { + return Ok(()); + }; + match command { + ConnectCommand::PlayerLoadInContext { .. } => { + self.handle_player_load_in_context(device_id, state, command) + .await + } + ConnectCommand::PlayerLoad { .. } => { + self.handle_player_load(device_id, state, command).await + } + ConnectCommand::PlayerResume if !state.is_playing => { + self.api.player_resume(device_id).await + } + ConnectCommand::PlayerPause if state.is_playing => { + self.api.player_pause(device_id).await + } + ConnectCommand::PlayerSeek(offset) => self.api.player_seek(device_id, offset).await, + ConnectCommand::PlayerRepeat(mode) => self.api.player_repeat(device_id, mode).await, + ConnectCommand::PlayerShuffle(shuffle) => { + self.api.player_shuffle(device_id, shuffle).await + } + ConnectCommand::PlayerSetVolume(volume) => { + self.api.player_volume(device_id, volume).await + } + _ => Ok(()), + } + } + + pub async fn handle_command(&self, command: ConnectCommand) -> Option<()> { + let device_lost = match command { + ConnectCommand::SetDevice(new_device_id) => { + self.device_id.write().ok()?.replace(new_device_id); + self.sync_state().await; + false + } + ConnectCommand::PlayerStop => { + let device_id = self.device_id.write().ok()?.take(); + if let Some(old_id) = device_id { + let _ = self.api.player_pause(old_id).await; + } + false + } + _ => { + let device_id = self.device_id.read().ok()?.clone(); + if let Some(device_id) = device_id { + let result = self.handle_other_command(device_id, command).await; + matches!(result, Err(SpotifyApiError::BadStatus(404, _))) + } else { + true + } + } + }; + + if device_lost { + self.device_lost(); + } + + Some(()) + } +} diff --git a/src/dbus/listener.rs b/src/dbus/listener.rs new file mode 100644 index 0000000..d9c12ec --- /dev/null +++ b/src/dbus/listener.rs @@ -0,0 +1,131 @@ +use futures::channel::mpsc::UnboundedSender; +use std::rc::Rc; + +use crate::app::{ + components::EventListener, + models::{RepeatMode, SongDescription}, + state::PlaybackEvent, + AppEvent, AppModel, +}; + +use super::types::{LoopStatus, PlaybackStatus, TrackMetadata}; + +#[derive(Debug)] +#[allow(clippy::enum_variant_names)] +pub enum MprisStateUpdate { + SetVolume(f64), + SetCurrentTrack { + has_prev: bool, + current: Option, + has_next: bool, + }, + SetPositionMs(u128), + SetLoopStatus { + has_prev: bool, + loop_status: LoopStatus, + has_next: bool, + }, + SetShuffled(bool), + SetPlaying(PlaybackStatus), +} + +pub struct AppPlaybackStateListener { + app_model: Rc, + sender: UnboundedSender, +} + +impl AppPlaybackStateListener { + pub fn new(app_model: Rc, sender: UnboundedSender) -> Self { + Self { app_model, sender } + } + + fn make_track_meta(&self) -> Option { + let SongDescription { + id, + title, + artists, + album, + duration, + art, + .. + } = self.app_model.get_state().playback.current_song()?; + Some(TrackMetadata { + id: format!("/dev/alextren/Spot/Track/{id}"), + length: 1000 * duration as u64, + title, + album: album.name, + artist: artists.into_iter().map(|a| a.name).collect(), + art, + }) + } + + fn has_prev_next(&self) -> (bool, bool) { + let state = self.app_model.get_state(); + ( + state.playback.prev_index().is_some(), + state.playback.next_index().is_some(), + ) + } + + fn loop_status(&self) -> LoopStatus { + let state = self.app_model.get_state(); + match state.playback.repeat_mode() { + RepeatMode::None => LoopStatus::None, + RepeatMode::Song => LoopStatus::Track, + RepeatMode::Playlist => LoopStatus::Playlist, + } + } + + fn update_for(&self, event: &PlaybackEvent) -> Option { + match event { + PlaybackEvent::PlaybackPaused => { + Some(MprisStateUpdate::SetPlaying(PlaybackStatus::Paused)) + } + PlaybackEvent::PlaybackResumed => { + Some(MprisStateUpdate::SetPlaying(PlaybackStatus::Playing)) + } + PlaybackEvent::PlaybackStopped => { + Some(MprisStateUpdate::SetPlaying(PlaybackStatus::Stopped)) + } + PlaybackEvent::TrackChanged(_) => { + let current = self.make_track_meta(); + let (has_prev, has_next) = self.has_prev_next(); + Some(MprisStateUpdate::SetCurrentTrack { + has_prev, + has_next, + current, + }) + } + PlaybackEvent::RepeatModeChanged(_) => { + let loop_status = self.loop_status(); + let (has_prev, has_next) = self.has_prev_next(); + Some(MprisStateUpdate::SetLoopStatus { + has_prev, + has_next, + loop_status, + }) + } + PlaybackEvent::ShuffleChanged(shuffled) => { + Some(MprisStateUpdate::SetShuffled(*shuffled)) + } + PlaybackEvent::TrackSeeked(pos) | PlaybackEvent::SeekSynced(pos) => { + let pos = 1000 * (*pos as u128); + Some(MprisStateUpdate::SetPositionMs(pos)) + } + PlaybackEvent::VolumeSet(vol) => Some(MprisStateUpdate::SetVolume(*vol)), + _ => None, + } + } +} + +impl EventListener for AppPlaybackStateListener { + fn on_event(&mut self, event: &AppEvent) { + if let AppEvent::PlaybackEvent(event) = event { + if let Some(update) = self.update_for(event) { + self.sender + .unbounded_send(update) + .expect("Could not send event to DBUS server"); + } + } + } +} diff --git a/src/dbus/mod.rs b/src/dbus/mod.rs new file mode 100644 index 0000000..51752fa --- /dev/null +++ b/src/dbus/mod.rs @@ -0,0 +1,103 @@ +use futures::channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender}; +use futures::StreamExt; +use std::rc::Rc; +use std::thread; +use zbus::Connection; + +use crate::app::{AppAction, AppModel}; + +mod mpris; +pub use mpris::*; + +mod types; + +mod listener; +use listener::*; + +#[tokio::main] +async fn dbus_server( + mpris: SpotMpris, + player: SpotMprisPlayer, + receiver: UnboundedReceiver, +) -> zbus::Result<()> { + let connection = Connection::session().await?; + connection + .object_server() + .at("/org/mpris/MediaPlayer2", mpris) + .await?; + connection + .object_server() + .at("/org/mpris/MediaPlayer2", player) + .await?; + connection + .request_name("org.mpris.MediaPlayer2.Spot") + .await?; + + receiver + .for_each(|update| async { + if let Ok(player_ref) = connection + .object_server() + .interface::<_, SpotMprisPlayer>("/org/mpris/MediaPlayer2") + .await + { + let mut player = player_ref.get_mut().await; + let ctxt = player_ref.signal_context(); + let res: zbus::Result<()> = match update { + MprisStateUpdate::SetVolume(volume) => { + player.state_mut().set_volume(volume); + player.volume_changed(ctxt).await + } + MprisStateUpdate::SetCurrentTrack { + has_prev, + has_next, + current, + } => { + player.state_mut().set_has_prev(has_prev); + player.state_mut().set_has_next(has_next); + player.state_mut().set_current_track(current); + player.notify_current_track_changed(ctxt).await + } + MprisStateUpdate::SetPositionMs(position) => { + player.state_mut().set_position(position); + Ok(()) + } + MprisStateUpdate::SetLoopStatus { + has_prev, + has_next, + loop_status, + } => { + player.state_mut().set_has_prev(has_prev); + player.state_mut().set_has_next(has_next); + player.state_mut().set_loop_status(loop_status); + player.notify_loop_status(ctxt).await + } + MprisStateUpdate::SetShuffled(shuffled) => { + player.state_mut().set_shuffled(shuffled); + player.shuffle_changed(ctxt).await + } + MprisStateUpdate::SetPlaying(status) => { + player.state_mut().set_playing(status); + player.playback_status_changed(ctxt).await + } + }; + res.expect("Signal emission failed"); + } + }) + .await; + + Ok(()) +} + +pub fn start_dbus_server( + app_model: Rc, + sender: UnboundedSender, +) -> AppPlaybackStateListener { + let mpris = SpotMpris::new(sender.clone()); + let player = SpotMprisPlayer::new(sender); + + let (sender, receiver) = unbounded(); + + thread::spawn(move || dbus_server(mpris, player, receiver)); + + AppPlaybackStateListener::new(app_model, sender) +} diff --git a/src/dbus/mpris.rs b/src/dbus/mpris.rs new file mode 100644 index 0000000..caaa998 --- /dev/null +++ b/src/dbus/mpris.rs @@ -0,0 +1,348 @@ +#![allow(non_snake_case)] +#![allow(unused_variables)] + +use std::collections::HashMap; +use std::convert::TryInto; + +use futures::channel::mpsc::UnboundedSender; +use zbus::fdo::{Error, Result}; +use zbus::{dbus_interface, Interface, SignalContext}; +use zvariant::{ObjectPath, Value}; + +use super::types::*; +use crate::app::models::RepeatMode; +use crate::app::state::PlaybackAction; +use crate::app::AppAction; + +#[derive(Clone)] +pub struct SpotMpris { + sender: UnboundedSender, +} + +impl SpotMpris { + pub fn new(sender: UnboundedSender) -> Self { + Self { sender } + } +} + +#[dbus_interface(interface = "org.mpris.MediaPlayer2")] +impl SpotMpris { + fn quit(&self) -> Result<()> { + Err(Error::NotSupported("Not implemented".to_string())) + } + + fn raise(&self) -> Result<()> { + self.sender + .unbounded_send(AppAction::Raise) + .map_err(|_| Error::Failed("Could not send action".to_string())) + } + + #[dbus_interface(property)] + fn can_quit(&self) -> bool { + false + } + + #[dbus_interface(property)] + fn can_raise(&self) -> bool { + true + } + + #[dbus_interface(property)] + fn has_track_list(&self) -> bool { + false + } + + #[dbus_interface(property)] + fn identity(&self) -> &'static str { + "Spot" + } + + #[dbus_interface(property)] + fn supported_mime_types(&self) -> Vec { + vec![] + } + + #[dbus_interface(property)] + fn supported_uri_schemes(&self) -> Vec { + vec![] + } + + #[dbus_interface(property)] + fn desktop_entry(&self) -> &'static str { + "dev.alextren.Spot" + } +} + +pub struct SpotMprisPlayer { + state: MprisState, + sender: UnboundedSender, +} + +impl SpotMprisPlayer { + pub fn new(sender: UnboundedSender) -> Self { + Self { + state: MprisState::new(), + sender, + } + } + + pub fn state_mut(&mut self) -> &mut MprisState { + &mut self.state + } + + pub async fn notify_current_track_changed(&self, ctxt: &SignalContext<'_>) -> zbus::Result<()> { + let metadata = Value::from(self.metadata()); + let can_go_next = Value::from(self.can_go_next()); + let can_go_previous = Value::from(self.can_go_previous()); + + zbus::fdo::Properties::properties_changed( + ctxt, + Self::name(), + &HashMap::from([ + ("Metadata", &metadata), + ("CanGoNext", &can_go_next), + ("CanGoPrevious", &can_go_previous), + ]), + &[], + ) + .await + } + + pub async fn notify_loop_status(&self, ctxt: &SignalContext<'_>) -> zbus::Result<()> { + let loop_status = Value::from(self.loop_status()); + let can_go_next = Value::from(self.can_go_next()); + let can_go_previous = Value::from(self.can_go_previous()); + + zbus::fdo::Properties::properties_changed( + ctxt, + Self::name(), + &HashMap::from([ + ("LoopStatus", &loop_status), + ("CanGoNext", &can_go_next), + ("CanGoPrevious", &can_go_previous), + ]), + &[], + ) + .await + } +} + +#[dbus_interface(interface = "org.mpris.MediaPlayer2.Player")] +impl SpotMprisPlayer { + pub fn next(&self) -> Result<()> { + self.sender + .unbounded_send(PlaybackAction::Next.into()) + .map_err(|_| Error::Failed("Could not send action".to_string())) + } + + pub fn open_uri(&self, Uri: &str) -> Result<()> { + Err(Error::NotSupported("Not implemented".to_string())) + } + + pub fn pause(&self) -> Result<()> { + self.sender + .unbounded_send(PlaybackAction::Pause.into()) + .map_err(|_| Error::Failed("Could not send action".to_string())) + } + + pub fn play(&self) -> Result<()> { + self.sender + .unbounded_send(PlaybackAction::Play.into()) + .map_err(|_| Error::Failed("Could not send action".to_string())) + } + + pub fn play_pause(&self) -> Result<()> { + self.sender + .unbounded_send(PlaybackAction::TogglePlay.into()) + .map_err(|_| Error::Failed("Could not send action".to_string())) + } + + pub fn previous(&self) -> Result<()> { + self.sender + .unbounded_send(PlaybackAction::Previous.into()) + .map_err(|_| Error::Failed("Could not send action".to_string())) + } + + pub fn seek(&self, Offset: i64) -> Result<()> { + if self.state.current_track().is_none() { + return Ok(()); + } + + let mut new_pos: i128 = (self.state.position() as i128 + i128::from(Offset)) / 1000; + // As per spec, if new position is less than 0 round to 0 + if new_pos < 0 { + new_pos = 0; + } + + let new_pos: u32 = (new_pos) + .try_into() + .map_err(|_| Error::Failed("Could not parse position".to_string()))?; + + // As per spec, if new position is past the length of the song skip to + // the next song + if u64::from(new_pos) >= self.metadata().length / 1000 { + self.sender + .unbounded_send(PlaybackAction::Next.into()) + .map_err(|_| Error::Failed("Could not send action".to_string())) + } else { + self.sender + .unbounded_send(PlaybackAction::Seek(new_pos).into()) + .map_err(|_| Error::Failed("Could not send action".to_string())) + } + } + + pub fn set_position(&self, TrackId: ObjectPath, Position: i64) -> Result<()> { + if self.state.current_track().is_none() { + return Ok(()); + } + + if TrackId.to_string() != self.metadata().id { + return Ok(()); + } + + let length: i64 = self + .metadata() + .length + .try_into() + .map_err(|_| Error::Failed("Could not cast length (too large)".to_string()))?; + + if Position > length { + return Ok(()); + } + + let pos: u32 = (Position / 1000) + .try_into() + .map_err(|_| Error::Failed("Could not parse position".to_string()))?; + + self.sender + .unbounded_send(PlaybackAction::Seek(pos).into()) + .map_err(|_| Error::Failed("Could not send action".to_string())) + } + + pub fn stop(&self) -> Result<()> { + Err(Error::NotSupported("Not implemented".to_string())) + } + + #[dbus_interface(signal)] + pub async fn seeked(ctxt: &SignalContext<'_>, Position: i64) -> zbus::Result<()>; + + #[dbus_interface(property)] + pub fn can_control(&self) -> bool { + true + } + + #[dbus_interface(property)] + pub fn can_go_next(&self) -> bool { + self.state.has_next() + } + + #[dbus_interface(property)] + pub fn can_go_previous(&self) -> bool { + self.state.has_prev() + } + + #[dbus_interface(property)] + pub fn can_pause(&self) -> bool { + true + } + + #[dbus_interface(property)] + pub fn can_play(&self) -> bool { + true + } + + #[dbus_interface(property)] + pub fn can_seek(&self) -> bool { + self.state.current_track().is_some() + } + + #[dbus_interface(property)] + pub fn maximum_rate(&self) -> f64 { + 1.0f64 + } + + #[dbus_interface(property)] + pub fn metadata(&self) -> TrackMetadata { + self.state + .current_track() + .cloned() + .unwrap_or_else(|| TrackMetadata { + id: String::new(), + length: 0, + title: "Not playing".to_string(), + artist: vec![], + album: String::new(), + art: None, + }) + } + + #[dbus_interface(property)] + pub fn minimum_rate(&self) -> f64 { + 1.0f64 + } + + #[dbus_interface(property)] + pub fn playback_status(&self) -> PlaybackStatus { + self.state.status() + } + + #[dbus_interface(property)] + pub fn loop_status(&self) -> LoopStatus { + self.state.loop_status() + } + + #[dbus_interface(property)] + pub fn set_loop_status(&self, value: LoopStatus) -> zbus::Result<()> { + let mode = match value { + LoopStatus::None => RepeatMode::None, + LoopStatus::Track => RepeatMode::Song, + LoopStatus::Playlist => RepeatMode::Playlist, + }; + self.sender + .unbounded_send(PlaybackAction::SetRepeatMode(mode).into()) + .map_err(|_| Error::Failed("Could not send action".to_string()))?; + Ok(()) + } + + #[dbus_interface(property)] + pub fn position(&self) -> i64 { + self.state.position() as i64 + } + + #[dbus_interface(property)] + pub fn rate(&self) -> f64 { + 1.0f64 + } + + #[dbus_interface(property)] + pub fn set_rate(&self, value: f64) {} + + #[dbus_interface(property)] + pub fn shuffle(&self) -> bool { + self.state.is_shuffled() + } + + #[dbus_interface(property)] + pub fn set_shuffle(&self, value: bool) -> zbus::Result<()> { + self.sender + .unbounded_send(PlaybackAction::ToggleShuffle.into()) + .map_err(|_| Error::Failed("Could not send action".to_string()))?; + Ok(()) + } + + #[dbus_interface(property)] + pub fn volume(&self) -> f64 { + self.state.volume() + } + + #[dbus_interface(property)] + pub fn set_volume(&self, value: f64) -> zbus::Result<()> { + // As per spec, if new volume less than 0 round to 0 + // also, we don't support volume higher than 100% at the moment. + let volume = value.clamp(0.0, 1.0); + self.sender + .unbounded_send(PlaybackAction::SetVolume(value).into()) + .map_err(|_| Error::Failed("Could not send action".to_string()))?; + Ok(()) + } +} diff --git a/src/dbus/types.rs b/src/dbus/types.rs new file mode 100644 index 0000000..5594081 --- /dev/null +++ b/src/dbus/types.rs @@ -0,0 +1,247 @@ +use std::convert::{Into, TryFrom}; +use std::time::Instant; +use zvariant::Type; +use zvariant::{Dict, Signature, Str, Value}; + +fn boxed_value<'a, V: Into>>(v: V) -> Value<'a> { + Value::new(v.into()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LoopStatus { + None, + Track, + Playlist, +} + +impl Type for LoopStatus { + fn signature() -> Signature<'static> { + Str::signature() + } +} + +impl From> for LoopStatus { + fn from(value: Value<'_>) -> Self { + String::try_from(value) + .map(|s| match s.as_str() { + "Track" => LoopStatus::Track, + "Playlist" => LoopStatus::Playlist, + _ => LoopStatus::None, + }) + .unwrap_or(LoopStatus::None) + } +} + +impl From for Value<'_> { + fn from(status: LoopStatus) -> Self { + match status { + LoopStatus::None => "None".into(), + LoopStatus::Track => "Track".into(), + LoopStatus::Playlist => "Playlist".into(), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PlaybackStatus { + Playing, + Paused, + Stopped, +} + +impl Type for PlaybackStatus { + fn signature() -> Signature<'static> { + Str::signature() + } +} + +impl From for Value<'_> { + fn from(status: PlaybackStatus) -> Self { + match status { + PlaybackStatus::Playing => "Playing".into(), + PlaybackStatus::Paused => "Paused".into(), + PlaybackStatus::Stopped => "Stopped".into(), + } + } +} + +struct PositionMicros { + last_known_position: u128, + last_resume_instant: Option, + rate: f32, +} + +impl PositionMicros { + fn new(rate: f32) -> Self { + Self { + last_known_position: 0, + last_resume_instant: None, + rate, + } + } + + fn current(&self) -> u128 { + let current_progress = self.last_resume_instant.map(|ri| { + let elapsed = ri.elapsed().as_micros() as f32; + let real_elapsed = self.rate * elapsed; + real_elapsed.ceil() as u128 + }); + self.last_known_position + current_progress.unwrap_or(0) + } + + fn set(&mut self, position: u128, playing: bool) { + self.last_known_position = position; + self.last_resume_instant = if playing { Some(Instant::now()) } else { None } + } + + fn pause(&mut self) { + self.last_known_position = self.current(); + self.last_resume_instant = None; + } + + fn resume(&mut self) { + self.last_resume_instant = Some(Instant::now()); + } +} + +#[derive(Debug, Clone)] +pub struct TrackMetadata { + pub id: String, + pub length: u64, + pub artist: Vec, + pub album: String, + pub title: String, + pub art: Option, +} + +impl Type for TrackMetadata { + fn signature() -> Signature<'static> { + Signature::from_str_unchecked("a{sv}") + } +} + +impl From for Value<'_> { + fn from(meta: TrackMetadata) -> Self { + let mut d = Dict::new(Str::signature(), Value::signature()); + d.append("mpris:trackid".into(), boxed_value(meta.id)) + .unwrap(); + d.append("mpris:length".into(), boxed_value(meta.length)) + .unwrap(); + d.append("xesam:title".into(), boxed_value(meta.title)) + .unwrap(); + d.append("xesam:artist".into(), boxed_value(meta.artist.clone())) + .unwrap(); + d.append("xesam:albumArtist".into(), boxed_value(meta.artist)) + .unwrap(); + d.append("xesam:album".into(), boxed_value(meta.album)) + .unwrap(); + if let Some(art) = meta.art { + d.append("mpris:artUrl".into(), boxed_value(art)).unwrap(); + } + Value::Dict(d) + } +} + +pub struct MprisState { + status: PlaybackStatus, + loop_status: LoopStatus, + volume: f64, + shuffled: bool, + position: PositionMicros, + metadata: Option, + has_prev: bool, + has_next: bool, +} + +impl MprisState { + pub fn new() -> Self { + Self { + status: PlaybackStatus::Stopped, + loop_status: LoopStatus::None, + shuffled: false, + position: PositionMicros::new(1.0), + metadata: None, + has_prev: false, + has_next: false, + volume: 1f64, + } + } + + pub fn volume(&self) -> f64 { + self.volume + } + + pub fn set_volume(&mut self, volume: f64) { + self.volume = volume; + } + + pub fn status(&self) -> PlaybackStatus { + self.status + } + + pub fn loop_status(&self) -> LoopStatus { + self.loop_status + } + + pub fn is_shuffled(&self) -> bool { + self.shuffled + } + + pub fn current_track(&self) -> Option<&TrackMetadata> { + self.metadata.as_ref() + } + + pub fn has_prev(&self) -> bool { + self.has_prev + } + + pub fn has_next(&self) -> bool { + self.has_next + } + + pub fn set_has_prev(&mut self, has_prev: bool) { + self.has_prev = has_prev; + } + + pub fn set_has_next(&mut self, has_next: bool) { + self.has_next = has_next; + } + + pub fn set_current_track(&mut self, track: Option) { + let playing = self.status == PlaybackStatus::Playing; + self.metadata = track; + self.position.set(0, playing); + } + + pub fn position(&self) -> u128 { + self.position.current() + } + + pub fn set_position(&mut self, position: u128) { + let playing = self.status == PlaybackStatus::Playing; + self.position.set(position, playing); + } + + pub fn set_loop_status(&mut self, loop_status: LoopStatus) { + self.loop_status = loop_status; + } + + pub fn set_shuffled(&mut self, shuffled: bool) { + self.shuffled = shuffled; + } + + pub fn set_playing(&mut self, status: PlaybackStatus) { + self.status = status; + match status { + PlaybackStatus::Playing => { + self.position.resume(); + } + PlaybackStatus::Paused => { + self.position.pause(); + } + PlaybackStatus::Stopped => { + self.position.set(0, false); + } + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..381410f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,207 @@ +#[macro_use(clone)] +extern crate glib; +#[macro_use] +extern crate lazy_static; +#[macro_use] +extern crate log; + +use app::state::ScreenName; +use futures::channel::mpsc::UnboundedSender; +use gettextrs::*; +use gio::prelude::*; +use gio::ApplicationFlags; +use gio::SimpleAction; +use gtk::prelude::*; + +mod api; +mod app; +mod config; +mod connect; +mod dbus; +mod player; +mod settings; + +use crate::app::components::expose_custom_widgets; +use crate::app::dispatch::{spawn_task_handler, DispatchLoop}; +use crate::app::{state::PlaybackAction, App, AppAction, BrowserAction}; + +fn main() { + let settings = settings::SpotSettings::new_from_gsettings().unwrap_or_default(); + setup_gtk(&settings); + + // Looks like there's a side effect to declaring widgets that allows them to be referenced them in ui/blueprint files + // so here goes! + expose_custom_widgets(); + + let gtk_app = gtk::Application::new(Some(config::APPID), ApplicationFlags::HANDLES_OPEN); + let builder = gtk::Builder::from_resource("/dev/alextren/Spot/window.ui"); + let window: libadwaita::ApplicationWindow = builder.object("window").unwrap(); + + // In debug mode, the app id is different (see meson config) so we fix the resource path (and add a distinctive style) + // Having a different app id allows running both the stable and development version at the same time + if cfg!(debug_assertions) { + window.add_css_class("devel"); + gtk_app.set_resource_base_path(Some("/dev/alextren/Spot")); + } + + let context = glib::MainContext::default(); + let dispatch_loop = DispatchLoop::new(); + let sender = dispatch_loop.make_dispatcher(); + + // Couple of actions used with shortcuts + register_actions(>k_app, sender.clone()); + setup_credits(builder.object::("about").unwrap()); + + // Main app logic is hooked up here + let app = App::new( + settings, + builder, + sender.clone(), + spawn_task_handler(&context), + ); + context.spawn_local(app.attach(dispatch_loop)); + + let sender_clone = sender.clone(); + gtk_app.connect_activate(move |gtk_app| { + debug!("activate"); + if let Some(existing_window) = gtk_app.active_window() { + existing_window.present(); + } else { + // Only send the Start action if we've just created the window + window.set_application(Some(gtk_app)); + gtk_app.add_window(&window); + sender_clone.unbounded_send(AppAction::Start).unwrap(); + } + }); + + gtk_app.connect_open(move |gtk_app, targets, _| { + gtk_app.activate(); + + // There should only be one target because %u is used in desktop file + let target = &targets[0]; + let uri = target.uri().to_string(); + let action = AppAction::OpenURI(uri) + .unwrap_or_else(|| AppAction::ShowNotification(gettext("Failed to open link!"))); + sender.unbounded_send(action).unwrap(); + }); + + context.invoke_local(move || { + gtk_app.run(); + }); + + std::process::exit(0); +} + +fn setup_gtk(settings: &settings::SpotSettings) { + // Setup logging + env_logger::init(); + + // Setup translations + textdomain("spot") + .and_then(|_| bindtextdomain("spot", config::LOCALEDIR)) + .and_then(|_| bind_textdomain_codeset("spot", "UTF-8")) + .expect("Could not setup localization"); + + // Setup Gtk, Adwaita... + gtk::init().unwrap_or_else(|_| panic!("Failed to initialize GTK")); + libadwaita::init().unwrap_or_else(|_| panic!("Failed to initialize libadwaita")); + + let manager = libadwaita::StyleManager::default(); + manager.set_color_scheme(settings.theme_preference); + + let res = gio::Resource::load(config::PKGDATADIR.to_owned() + "/spot.gresource") + .expect("Could not load resources"); + gio::resources_register(&res); + + let provider = gtk::CssProvider::new(); + provider.load_from_resource("/dev/alextren/Spot/app.css"); + + gtk::style_context_add_provider_for_display( + &gdk::Display::default().unwrap(), + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); +} + +fn setup_credits(about: libadwaita::AboutWindow) { + // Read from a couple files at compile time and update the about dialog + let authors: Vec<&str> = include_str!("../AUTHORS") + .trim_end_matches('\n') + .split('\n') + .collect(); + let translators = include_str!("../TRANSLATORS").trim_end_matches('\n'); + let artists: Vec<&str> = include_str!("../ARTISTS") + .trim_end_matches('\n') + .split('\n') + .collect(); + about.set_version(config::VERSION); + about.set_developers(&authors); + about.set_translator_credits(translators); + about.set_artists(&artists); +} + +fn register_actions(app: >k::Application, sender: UnboundedSender) { + let quit = SimpleAction::new("quit", None); + quit.connect_activate(clone!(@weak app => move |_, _| { + if let Some(existing_window) = app.active_window() { + existing_window.close(); + } + app.quit(); + })); + app.add_action(&quit); + + app.add_action(&make_action( + "toggle_playback", + PlaybackAction::TogglePlay.into(), + sender.clone(), + )); + + app.add_action(&make_action( + "player_prev", + PlaybackAction::Previous.into(), + sender.clone(), + )); + + app.add_action(&make_action( + "player_next", + PlaybackAction::Next.into(), + sender.clone(), + )); + + app.add_action(&make_action( + "nav_pop", + AppAction::BrowserAction(BrowserAction::NavigationPop), + sender.clone(), + )); + + app.add_action(&make_action( + "search", + AppAction::BrowserAction(BrowserAction::NavigationPush(ScreenName::Search)), + sender.clone(), + )); + + app.add_action(&{ + let action = SimpleAction::new("open_playlist", Some(glib::VariantTy::STRING)); + action.set_enabled(true); + action.connect_activate(move |_, playlist_id| { + if let Some(id) = playlist_id.and_then(|s| s.str()) { + sender + .unbounded_send(AppAction::ViewPlaylist(id.to_owned())) + .unwrap(); + } + }); + action + }); +} + +fn make_action( + name: &str, + app_action: AppAction, + sender: UnboundedSender, +) -> SimpleAction { + let action = SimpleAction::new(name, None); + action.connect_activate(move |_, _| { + sender.unbounded_send(app_action.clone()).unwrap(); + }); + action +} diff --git a/src/meson.build b/src/meson.build new file mode 100644 index 0000000..900f3fa --- /dev/null +++ b/src/meson.build @@ -0,0 +1,167 @@ +gnome = import('gnome') +gnome.post_install( + glib_compile_schemas: true, + gtk_update_icon_cache: true, + update_desktop_database: true, +) + +dependency('libadwaita-1') +dependency('gtk4') +dependency('glib-2.0') +dependency('openssl') +dependency('alsa') +dependency('libpulse') + +conf = configuration_data() + +conf.set_quoted('LOCALEDIR', get_option('prefix') / get_option('localedir')) + +pkgdatadir = get_option('prefix') / get_option('datadir') / meson.project_name() +conf.set_quoted('PKGDATADIR', pkgdatadir) + +if get_option('buildtype') == 'debug' + conf.set_quoted('APPID', 'dev.alextren.Spot.Devel') + conf.set('VERSION', meson.project_version() + '-dev') +else + conf.set_quoted('APPID', 'dev.alextren.Spot') + conf.set('VERSION', meson.project_version()) +endif + +blueprints = custom_target('blueprints', + build_always_stale: true, + input: files( + 'app/components/album/album.blp', + 'app/components/artist/artist.blp', + 'app/components/artist_details/artist_details.blp', + 'app/components/details/album_header.blp', + 'app/components/details/details.blp', + 'app/components/details/release_details.blp', + 'app/components/device_selector/device_selector.blp', + 'app/components/headerbar/headerbar.blp', + 'app/components/library/library.blp', + 'app/components/login/login.blp', + 'app/components/now_playing/now_playing.blp', + 'app/components/playback/playback_controls.blp', + 'app/components/playback/playback_info.blp', + 'app/components/playback/playback_widget.blp', + 'app/components/playlist/song.blp', + 'app/components/playlist_details/playlist_details.blp', + 'app/components/playlist_details/playlist_header.blp', + 'app/components/playlist_details/playlist_headerbar.blp', + 'app/components/saved_playlists/saved_playlists.blp', + 'app/components/saved_tracks/saved_tracks.blp', + 'app/components/search/search.blp', + 'app/components/selection/selection_toolbar.blp', + 'app/components/settings/settings.blp', + 'app/components/user_details/user_details.blp', + 'app/components/scrolling_header/scrolling_header.blp', + 'app/components/sidebar/create_playlist.blp', + 'app/components/sidebar/sidebar_row.blp', + 'window.blp', + ), + output: '.', + command: [find_program('blueprint-compiler'), 'batch-compile', '@OUTPUT@', '@CURRENT_SOURCE_DIR@', '@INPUT@'], +) + +gnome.compile_resources('spot', + 'spot.gresource.xml', + gresource_bundle: true, + install: true, + install_dir: pkgdatadir, + dependencies: blueprints +) + +configure_file( + input: 'config.rs.in', + output: 'config.rs', + configuration: conf +) + +# Copy the config.rs output to the source directory. +run_command( + 'cp', + meson.current_build_dir() / 'config.rs', + meson.current_source_dir() / 'config.rs', + check: true +) + +cargo = find_program('cargo', required: true) + +if get_option('buildtype') == 'release' + rust_target = 'release' + cargo_profile_option = '--release' +else + rust_target = 'debug' + cargo_profile_option = '--verbose' +endif + +cargo_manifest = meson.project_source_root() / 'Cargo.toml' +cargo_output = 'src' / rust_target / meson.project_name() + +env = environment() +env.set('CARGO_HOME', meson.project_source_root() / 'cargo') + +cargo_options = [ + '--manifest-path', cargo_manifest, + '--features', get_option('features'), + '--target-dir', meson.project_build_root() / 'src', + cargo_profile_option +] + +if get_option('offline') + cargo_options += ['--offline'] +endif + +cargo_release = custom_target( + 'cargo-build', + build_always_stale: true, + output: 'bin', + console: true, + install: false, + command: [ + cargo, 'build', cargo_options, + ], + env: env +) + +final_bin = custom_target( + 'copy-cargo-build', + build_by_default: true, + build_always_stale: true, + input: cargo_release, + output: meson.project_name(), + console: true, + install: true, + install_dir: get_option('bindir'), + command: [ + 'cp', meson.project_build_root() / cargo_output, '@OUTPUT@' + ] +) + +test('Unit tests', + cargo, + args: [ + 'test', + '--manifest-path', cargo_manifest, + '--target-dir', meson.project_build_root() / 'src', + cargo_profile_option + ], + timeout: 180, + env: env +) + +test('Clippy', + cargo, + args: [ + 'clippy', + '--manifest-path', cargo_manifest, + '--target-dir', meson.project_build_root() / 'src', + '--', + '-D', 'warnings', + '-A', 'clippy::module_inception', + '-A', 'clippy::new_without_default', + '-A', 'clippy::enum-variant-names' + ], + timeout: 180, + env: env +) diff --git a/src/player/mod.rs b/src/player/mod.rs new file mode 100644 index 0000000..d5bb17a --- /dev/null +++ b/src/player/mod.rs @@ -0,0 +1,133 @@ +use futures::channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender}; +use librespot::core::spotify_id::SpotifyId; +use std::cell::RefCell; +use std::rc::Rc; +use std::time::SystemTime; +use tokio::task; + +use crate::app::credentials::Credentials; +use crate::app::state::{LoginAction, PlaybackAction, SetLoginSuccessAction}; +use crate::app::AppAction; +#[allow(clippy::module_inception)] +mod player; +pub use player::*; + +#[derive(Debug, Clone)] +pub enum Command { + PasswordLogin { username: String, password: String }, + TokenLogin { username: String, token: String }, + OAuthLogin, + Logout, + PlayerLoad { track: SpotifyId, resume: bool }, + PlayerResume, + PlayerPause, + PlayerStop, + PlayerSeek(u32), + PlayerSetVolume(f64), + PlayerPreload(SpotifyId), + RefreshToken, + ReloadSettings, +} + +struct AppPlayerDelegate { + sender: RefCell>, +} + +impl AppPlayerDelegate { + fn new(sender: UnboundedSender) -> Self { + let sender = RefCell::new(sender); + Self { sender } + } +} + +impl SpotifyPlayerDelegate for AppPlayerDelegate { + fn end_of_track_reached(&self) { + self.sender + .borrow_mut() + .unbounded_send(PlaybackAction::Next.into()) + .unwrap(); + } + + fn password_login_successful(&self, credentials: Credentials) { + self.sender + .borrow_mut() + .unbounded_send( + LoginAction::SetLoginSuccess(SetLoginSuccessAction::Password(credentials)).into(), + ) + .unwrap(); + } + + fn token_login_successful(&self, credentials: Credentials) { + self.sender + .borrow_mut() + .unbounded_send( + LoginAction::SetLoginSuccess(SetLoginSuccessAction::Token(credentials)).into(), + ) + .unwrap(); + } + + fn refresh_successful(&self, token: String, token_expiry_time: SystemTime) { + self.sender + .borrow_mut() + .unbounded_send( + LoginAction::SetRefreshedToken { + token, + token_expiry_time, + } + .into(), + ) + .unwrap(); + } + + fn report_error(&self, error: SpotifyError) { + self.sender + .borrow_mut() + .unbounded_send(match error { + SpotifyError::LoginFailed => LoginAction::SetLoginFailure.into(), + _ => AppAction::ShowNotification(format!("{error}")), + }) + .unwrap(); + } + + fn notify_playback_state(&self, position: u32) { + self.sender + .borrow_mut() + .unbounded_send(PlaybackAction::SyncSeek(position).into()) + .unwrap(); + } + + fn preload_next_track(&self) { + self.sender + .borrow_mut() + .unbounded_send(PlaybackAction::Preload.into()) + .unwrap(); + } +} + +#[tokio::main] +async fn player_main( + player_settings: SpotifyPlayerSettings, + appaction_sender: UnboundedSender, + receiver: UnboundedReceiver, +) { + task::LocalSet::new() + .run_until(async move { + task::spawn_local(async move { + let delegate = Rc::new(AppPlayerDelegate::new(appaction_sender.clone())); + let player = SpotifyPlayer::new(player_settings, delegate); + player.start(receiver).await.unwrap(); + }) + .await + .unwrap(); + }) + .await; +} + +pub fn start_player_service( + player_settings: SpotifyPlayerSettings, + appaction_sender: UnboundedSender, +) -> UnboundedSender { + let (sender, receiver) = unbounded::(); + std::thread::spawn(move || player_main(player_settings, appaction_sender, receiver)); + sender +} diff --git a/src/player/player.rs b/src/player/player.rs new file mode 100644 index 0000000..31d2986 --- /dev/null +++ b/src/player/player.rs @@ -0,0 +1,444 @@ +use futures::channel::mpsc::UnboundedReceiver; +use futures::stream::StreamExt; + +use librespot::core::authentication::Credentials; +use librespot::core::cache::Cache; +use librespot::core::config::SessionConfig; +use librespot::core::session::Session; + +use librespot::playback::mixer::softmixer::SoftMixer; +use librespot::playback::mixer::{Mixer, MixerConfig}; + +use librespot::playback::audio_backend; +use librespot::playback::config::{AudioFormat, Bitrate, PlayerConfig, VolumeCtrl}; +use librespot::playback::player::{Player, PlayerEvent, PlayerEventChannel}; + +use super::Command; +use crate::api::oauth2::get_access_token; +use crate::app::credentials; +use crate::settings::SpotSettings; +use std::cell::RefCell; +use std::env; +use std::error::Error; +use std::fmt; +use std::rc::Rc; +use std::sync::Arc; +use std::time::{Instant, SystemTime}; + +#[derive(Debug)] +pub enum SpotifyError { + LoginFailed, + TokenFailed, + PlayerNotReady, + TechnicalError, +} + +impl Error for SpotifyError {} + +impl fmt::Display for SpotifyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::LoginFailed => write!(f, "Login failed!"), + Self::TokenFailed => write!(f, "Token retrieval failed!"), + Self::PlayerNotReady => write!(f, "Player is not responding."), + Self::TechnicalError => { + write!(f, "A technical error occured. Check your connectivity.") + } + } + } +} + +pub trait SpotifyPlayerDelegate { + fn end_of_track_reached(&self); + fn password_login_successful(&self, credentials: credentials::Credentials); + fn token_login_successful(&self, credentials: credentials::Credentials); + fn refresh_successful(&self, token: String, token_expiry_time: SystemTime); + fn report_error(&self, error: SpotifyError); + fn notify_playback_state(&self, position: u32); + fn preload_next_track(&self); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AudioBackend { + GStreamer(String), + PulseAudio, + Alsa(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpotifyPlayerSettings { + pub bitrate: Bitrate, + pub backend: AudioBackend, + pub gapless: bool, + pub ap_port: Option, +} + +impl Default for SpotifyPlayerSettings { + fn default() -> Self { + Self { + bitrate: Bitrate::Bitrate160, + gapless: true, + backend: AudioBackend::PulseAudio, + ap_port: None, + } + } +} + +pub struct SpotifyPlayer { + settings: SpotifyPlayerSettings, + player: Option>, + mixer: Option>, + session: Option, + delegate: Rc, +} + +impl SpotifyPlayer { + pub fn new(settings: SpotifyPlayerSettings, delegate: Rc) -> Self { + Self { + settings, + mixer: None, + player: None, + session: None, + delegate, + } + } + + async fn handle(&mut self, action: Command) -> Result<(), SpotifyError> { + match action { + Command::PlayerSetVolume(volume) => { + if let Some(mixer) = self.mixer.as_mut() { + mixer.set_volume((VolumeCtrl::MAX_VOLUME as f64 * volume) as u16); + } + Ok(()) + } + Command::PlayerResume => { + self.player + .as_ref() + .ok_or(SpotifyError::PlayerNotReady)? + .play(); + Ok(()) + } + Command::PlayerPause => { + self.player + .as_ref() + .ok_or(SpotifyError::PlayerNotReady)? + .pause(); + Ok(()) + } + Command::PlayerStop => { + self.player + .as_ref() + .ok_or(SpotifyError::PlayerNotReady)? + .stop(); + Ok(()) + } + Command::PlayerSeek(position) => { + self.player + .as_ref() + .ok_or(SpotifyError::PlayerNotReady)? + .seek(position); + Ok(()) + } + Command::PlayerLoad { track, resume } => { + self.player + .as_mut() + .ok_or(SpotifyError::PlayerNotReady)? + .load(track, resume, 0); + Ok(()) + } + Command::PlayerPreload(track) => { + self.player + .as_mut() + .ok_or(SpotifyError::PlayerNotReady)? + .preload(track); + Ok(()) + } + Command::RefreshToken => { + let session = self.session.as_ref().ok_or(SpotifyError::PlayerNotReady)?; + let (token, token_expiry_time) = get_access_token_and_expiry_time(session).await?; + self.delegate.refresh_successful(token, token_expiry_time); + Ok(()) + } + Command::Logout => { + self.session + .take() + .ok_or(SpotifyError::PlayerNotReady)? + .shutdown(); + let _ = self.player.take(); + Ok(()) + } + Command::PasswordLogin { username, password } => { + let credentials = Credentials::with_password(username, password.clone()); + let new_session = create_session(&credentials, self.settings.ap_port).await?; + let (token, token_expiry_time) = + get_access_token_and_expiry_time(&new_session).await?; + let credentials = credentials::Credentials { + username: new_session.username(), + password, + token, + token_expiry_time: Some(token_expiry_time), + }; + self.delegate.password_login_successful(credentials); + + let new_player = self.create_player(new_session.clone()); + tokio::task::spawn_local(player_setup_delegate( + new_player.get_player_event_channel(), + Rc::clone(&self.delegate), + )); + self.player.replace(new_player); + self.session.replace(new_session); + + Ok(()) + } + Command::TokenLogin { username, token } => { + info!("Login with token, username {}", username); + let credentials = Credentials::with_access_token(token.clone()); + let new_session = create_session(&credentials, self.settings.ap_port).await?; + let credentials = credentials::Credentials { + username: new_session.username(), + password: "".to_string(), + token, + token_expiry_time: None, + }; + self.delegate.token_login_successful(credentials); + + let new_player = self.create_player(new_session.clone()); + tokio::task::spawn_local(player_setup_delegate( + new_player.get_player_event_channel(), + Rc::clone(&self.delegate), + )); + self.player.replace(new_player); + self.session.replace(new_session); + + Ok(()) + } + Command::OAuthLogin => { + let (token, token_expiry_time) = get_access_token_oauth()?; + info!("Login with OAuth2"); + let credentials = Credentials::with_access_token(token.clone()); + let new_session = create_session(&credentials, self.settings.ap_port).await?; + let credentials = credentials::Credentials { + username: new_session.username(), + password: "".to_string(), + token, + token_expiry_time: Some(token_expiry_time), + }; + self.delegate.token_login_successful(credentials); + + let new_player = self.create_player(new_session.clone()); + tokio::task::spawn_local(player_setup_delegate( + new_player.get_player_event_channel(), + Rc::clone(&self.delegate), + )); + self.player.replace(new_player); + self.session.replace(new_session); + + Ok(()) + } + Command::ReloadSettings => { + let settings = SpotSettings::new_from_gsettings().unwrap_or_default(); + self.settings = settings.player_settings; + + let session = self.session.take().ok_or(SpotifyError::PlayerNotReady)?; + let new_player = self.create_player(session); + tokio::task::spawn_local(player_setup_delegate( + new_player.get_player_event_channel(), + Rc::clone(&self.delegate), + )); + self.player.replace(new_player); + + Ok(()) + } + } + } + + fn create_player(&mut self, session: Session) -> Arc { + let backend = self.settings.backend.clone(); + + let player_config = PlayerConfig { + gapless: self.settings.gapless, + bitrate: self.settings.bitrate, + ..Default::default() + }; + info!("bitrate: {:?}", &player_config.bitrate); + + let soft_volume = self + .mixer + .get_or_insert_with(|| { + let mix = Box::new(SoftMixer::open(MixerConfig { + // This value feels reasonable to me. Feel free to change it + volume_ctrl: VolumeCtrl::Log(VolumeCtrl::DEFAULT_DB_RANGE / 2.0), + ..Default::default() + })); + // TODO: Should read volume from somewhere instead of hard coding. + // Sets volume to 100% + mix.set_volume(VolumeCtrl::MAX_VOLUME); + mix + }) + .get_soft_volume(); + Player::new(player_config, session, soft_volume, move || match backend { + AudioBackend::GStreamer(pipeline) => { + let backend = audio_backend::find(Some("gstreamer".to_string())).unwrap(); + backend(Some(pipeline), AudioFormat::default()) + } + AudioBackend::PulseAudio => { + info!("using pulseaudio"); + env::set_var("PULSE_PROP_application.name", "Spot"); + let backend = audio_backend::find(Some("pulseaudio".to_string())).unwrap(); + backend(None, AudioFormat::default()) + } + AudioBackend::Alsa(device) => { + info!("using alsa ({})", &device); + let backend = audio_backend::find(Some("alsa".to_string())).unwrap(); + backend(Some(device), AudioFormat::default()) + } + }) + } + + #[allow(clippy::await_holding_refcell_ref)] + pub async fn start(self, receiver: UnboundedReceiver) -> Result<(), ()> { + let _self = RefCell::new(self); + receiver + .for_each(|action| async { + let mut _self = _self.borrow_mut(); + match _self.handle(action).await { + Ok(_) => {} + Err(err) => _self.delegate.report_error(err), + } + }) + .await; + Ok(()) + } +} + +const REDIRECT_URI: &str = "http://127.0.0.1:8898/login"; +const SPOTIFY_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; + +const SCOPES: &str = "user-read-private,\ +playlist-read-private,\ +playlist-read-collaborative,\ +user-library-read,\ +user-library-modify,\ +user-top-read,\ +user-read-recently-played,\ +user-read-playback-state,\ +playlist-modify-public,\ +playlist-modify-private,\ +user-modify-playback-state,\ +streaming,\ +playlist-modify-public"; + +const KNOWN_AP_PORTS: [Option; 4] = [None, Some(80), Some(443), Some(4070)]; + +async fn get_access_token_and_expiry_time( + session: &Session, +) -> Result<(String, SystemTime), SpotifyError> { + let token = session + .token_provider() + .get_token(SCOPES) + .await + .map_err(|_e| SpotifyError::TokenFailed)?; + let expiry_time = SystemTime::now() + token.expires_in; + Ok((token.access_token, expiry_time)) +} + +fn get_access_token_oauth() -> Result<(String, SystemTime), SpotifyError> { + let scopes: Vec<&str> = vec![ + "user-read-private", + "playlist-read-private", + "playlist-read-collaborative", + "user-library-read", + "user-library-modify", + "user-top-read", + "user-read-recently-played", + "user-read-playback-state", + "playlist-modify-public", + "playlist-modify-private", + "user-modify-playback-state", + "streaming", + "playlist-modify-public", + ]; + + match get_access_token(SPOTIFY_CLIENT_ID, REDIRECT_URI, scopes) { + Ok(token) => { + info!("Success: {token:#?}"); + let duration = token.expires_at.duration_since(Instant::now()); + Ok((token.access_token, SystemTime::now() + duration)) + } + Err(e) => { + error!("Failed: {e}"); + Err(SpotifyError::TokenFailed) + } + } +} + +async fn create_session_with_port( + credentials: &Credentials, + ap_port: Option, +) -> Result { + let session_config = SessionConfig { + ap_port, + ..Default::default() + }; + let root = glib::user_cache_dir().join("spot").join("librespot"); + let cache = Cache::new( + Some(root.join("credentials")), + Some(root.join("volume")), + Some(root.join("audio")), + None, + ) + .map_err(|e| dbg!(e)) + .ok(); + let session = Session::new(session_config, cache); + match session.connect(credentials.clone(), true).await { + Ok(_) => Ok(session), + Err(err) => { + warn!("Login failure: {}", err); + Err(SpotifyError::LoginFailed) + } + } +} + +async fn create_session( + credentials: &Credentials, + ap_port: Option, +) -> Result { + match ap_port { + Some(_) => create_session_with_port(credentials, ap_port).await, + None => { + let mut ports_to_try = KNOWN_AP_PORTS.iter(); + loop { + if let Some(next_port) = ports_to_try.next() { + let res = create_session_with_port(credentials, *next_port).await; + match res { + Err(SpotifyError::TechnicalError) => continue, + _ => break res, + } + } else { + break Err(SpotifyError::TechnicalError); + } + } + } + } +} + +async fn player_setup_delegate( + mut channel: PlayerEventChannel, + delegate: Rc, +) { + while let Some(event) = channel.recv().await { + match event { + PlayerEvent::EndOfTrack { .. } => { + delegate.end_of_track_reached(); + } + PlayerEvent::Playing { position_ms, .. } => { + delegate.notify_playback_state(position_ms); + } + PlayerEvent::TimeToPreloadNextTrack { .. } => { + debug!("Requestiong next track to be preloaded..."); + delegate.preload_next_track(); + } + _ => {} + } + } +} diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..8e67c03 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,115 @@ +use crate::player::{AudioBackend, SpotifyPlayerSettings}; +use gio::prelude::SettingsExt; +use libadwaita::ColorScheme; +use librespot::playback::config::Bitrate; + +const SETTINGS: &str = "dev.alextren.Spot"; + +#[derive(Clone, Debug, Default)] +pub struct WindowGeometry { + pub width: i32, + pub height: i32, + pub is_maximized: bool, +} + +impl WindowGeometry { + pub fn new_from_gsettings() -> Self { + let settings = gio::Settings::new(SETTINGS); + Self { + width: settings.int("window-width"), + height: settings.int("window-height"), + is_maximized: settings.boolean("window-is-maximized"), + } + } + + pub fn save(&self) -> Option<()> { + let settings = gio::Settings::new(SETTINGS); + settings.delay(); + settings.set_int("window-width", self.width).ok()?; + settings.set_int("window-height", self.height).ok()?; + settings + .set_boolean("window-is-maximized", self.is_maximized) + .ok()?; + settings.apply(); + Some(()) + } +} + +// Player (librespot) settings +impl SpotifyPlayerSettings { + pub fn new_from_gsettings() -> Option { + let settings = gio::Settings::new(SETTINGS); + let bitrate = match settings.enum_("player-bitrate") { + 0 => Some(Bitrate::Bitrate96), + 1 => Some(Bitrate::Bitrate160), + 2 => Some(Bitrate::Bitrate320), + _ => None, + }?; + let backend = match settings.enum_("audio-backend") { + 0 => Some(AudioBackend::PulseAudio), + 1 => Some(AudioBackend::Alsa( + settings.string("alsa-device").as_str().to_string(), + )), + 2 => Some(AudioBackend::GStreamer( + "audioconvert dithering=none ! audioresample ! pipewiresink".to_string(), // This should be configurable eventually + )), + _ => None, + }?; + let gapless = settings.boolean("gapless-playback"); + + let ap_port_val = settings.uint("ap-port"); + if ap_port_val > 65535 { + panic!("Invalid access point port"); + } + + // Access points usually use port 80, 443 or 4070. Since gsettings + // does not allow optional values, we use 0 to indicate that any + // port is OK and we should pass None to librespot's ap-port. + let ap_port = match ap_port_val { + 0 => None, + x => Some(x as u16), + }; + + Some(Self { + bitrate, + backend, + gapless, + ap_port, + }) + } +} + +#[derive(Debug, Clone)] +pub struct SpotSettings { + pub theme_preference: ColorScheme, + pub player_settings: SpotifyPlayerSettings, + pub window: WindowGeometry, +} + +// Application settings +impl SpotSettings { + pub fn new_from_gsettings() -> Option { + let settings = gio::Settings::new(SETTINGS); + let theme_preference = match settings.enum_("theme-preference") { + 0 => Some(ColorScheme::ForceLight), + 1 => Some(ColorScheme::ForceDark), + 2 => Some(ColorScheme::Default), + _ => None, + }?; + Some(Self { + theme_preference, + player_settings: SpotifyPlayerSettings::new_from_gsettings()?, + window: WindowGeometry::new_from_gsettings(), + }) + } +} + +impl Default for SpotSettings { + fn default() -> Self { + Self { + theme_preference: ColorScheme::PreferDark, + player_settings: Default::default(), + window: Default::default(), + } + } +} diff --git a/src/spot.gresource.xml b/src/spot.gresource.xml new file mode 100644 index 0000000..83e70d1 --- /dev/null +++ b/src/spot.gresource.xml @@ -0,0 +1,86 @@ + + + + window.ui + app.css + + app/components/login/login.ui + + app/components/settings/settings.ui + + app/components/search/search.ui + + app/components/album/album.ui + app/components/album/album.css + + app/components/artist/artist.ui + + app/components/details/details.ui + app/components/details/album_header.ui + app/components/details/album_header.css + app/components/details/release_details.ui + + app/components/playlist_details/playlist_details.ui + app/components/playlist_details/playlist_header.ui + app/components/playlist_details/playlist_header.css + + app/components/artist_details/artist_details.css + app/components/artist_details/artist_details.ui + + app/components/library/library.ui + + app/components/saved_playlists/saved_playlists.ui + + app/components/now_playing/now_playing.ui + app/components/device_selector/device_selector.ui + + app/components/saved_tracks/saved_tracks.ui + + app/components/playlist/song.css + app/components/playlist/song.ui + + app/components/user_details/user_details.css + app/components/user_details/user_details.ui + + app/components/playback/playback.css + app/components/playback/playback_controls.ui + app/components/playback/playback_info.ui + app/components/playback/playback_widget.ui + + app/components/selection/selection_toolbar.ui + app/components/selection/selection_toolbar.css + + app/components/headerbar/headerbar.ui + app/components/playlist_details/playlist_headerbar.ui + + app/components/sidebar/sidebar_row.ui + app/components/sidebar/create_playlist.ui + + app/components/scrolling_header/scrolling_header.ui + + + app/components/selection/icons/music-queue-symbolic.svg + app/components/selection/icons/playlist2-symbolic.svg + app/components/sidebar/icons/library-music-symbolic.svg + + + app/components/playlist/playback-indicator/playback-paused-symbolic.svg + app/components/playlist/playback-indicator/playback-0-symbolic.svg + app/components/playlist/playback-indicator/playback-1-symbolic.svg + app/components/playlist/playback-indicator/playback-2-symbolic.svg + app/components/playlist/playback-indicator/playback-3-symbolic.svg + app/components/playlist/playback-indicator/playback-4-symbolic.svg + app/components/playlist/playback-indicator/playback-5-symbolic.svg + app/components/playlist/playback-indicator/playback-6-symbolic.svg + app/components/playlist/playback-indicator/playback-7-symbolic.svg + app/components/playlist/playback-indicator/playback-8-symbolic.svg + app/components/playlist/playback-indicator/playback-9-symbolic.svg + app/components/playlist/playback-indicator/playback-10-symbolic.svg + app/components/playlist/playback-indicator/playback-11-symbolic.svg + app/components/playlist/playback-indicator/playback-12-symbolic.svg + app/components/playlist/playback-indicator/playback-13-symbolic.svg + app/components/playlist/playback-indicator/playback-14-symbolic.svg + app/components/playlist/playback-indicator/playback-15-symbolic.svg + app/components/playlist/playback-indicator/playback-16-symbolic.svg + + diff --git a/src/window.blp b/src/window.blp new file mode 100644 index 0000000..38822fa --- /dev/null +++ b/src/window.blp @@ -0,0 +1,134 @@ +using Gtk 4.0; +using Adw 1; + +Adw.ApplicationWindow window { + default-width: 1080; + default-height: 720; + + Box { + orientation: vertical; + + ShortcutController { + scope: local; + + Shortcut { + trigger: "space"; + action: "action(app.toggle_playback)"; + } + + Shortcut { + trigger: "Q"; + action: "action(app.quit)"; + } + + Shortcut { + trigger: "P"; + action: "action(app.player_prev)"; + } + + Shortcut { + trigger: "N"; + action: "action(app.player_next)"; + } + + Shortcut { + trigger: "Left"; + action: "action(app.nav_pop)"; + } + + Shortcut { + trigger: "F"; + action: "action(app.search)"; + } + } + + Adw.Leaflet leaflet { + vexpand: true; + + Adw.LeafletPage { + navigatable: false; + child: Box { + orientation: vertical; + + Adw.HeaderBar { + show-end-title-buttons: bind leaflet.folded; + + Button search_button { + icon-name: "system-search-symbolic"; + } + + [title] + Adw.WindowTitle { + title: "Spot"; + } + + [end] + MenuButton user { + icon-name: "open-menu-symbolic"; + } + } + + ScrolledWindow { + hscrollbar-policy: never; + ListBox home_listbox { + width-request: 200; + vexpand: true; + + styles [ + "navigation-sidebar", + ] + } + } + }; + } + + Adw.LeafletPage { + navigatable: false; + child: Separator { + orientation: vertical; + }; + } + + Adw.LeafletPage { + name: "main"; + child: Box { + orientation: vertical; + + Adw.ToastOverlay main { + hexpand: true; + vexpand: true; + + Stack navigation_stack { + transition-type: slide_left_right; + } + } + + Overlay { + hexpand: true; + + $PlaybackWidget playback { + hexpand: "1"; + } + + [overlay] + $SelectionToolbarWidget selection_toolbar { + hexpand: "1"; + } + } + }; + } + + visible-child: main; + } + } +} + +Adw.AboutWindow about { + modal: true; + destroy-with-parent: true; + transient-for: window; + application-name: "Spot"; + website: "https://github.com/xou816/spot"; + application-icon: "dev.alextren.Spot"; + license-type: mit_x11; +} diff --git a/subprojects/blueprint-compiler.wrap b/subprojects/blueprint-compiler.wrap new file mode 100644 index 0000000..2dbf858 --- /dev/null +++ b/subprojects/blueprint-compiler.wrap @@ -0,0 +1,8 @@ +[wrap-git] +directory = blueprint-compiler +url = https://gitlab.gnome.org/jwestman/blueprint-compiler.git +revision = v0.8.1 +depth = 1 + +[provide] +program_names = blueprint-compiler